Module: Tina4
- Defined in:
- lib/tina4/test.rb,
lib/tina4.rb,
lib/tina4/ai.rb,
lib/tina4/api.rb,
lib/tina4/cli.rb,
lib/tina4/env.rb,
lib/tina4/job.rb,
lib/tina4/log.rb,
lib/tina4/mcp.rb,
lib/tina4/orm.rb,
lib/tina4/auth.rb,
lib/tina4/cors.rb,
lib/tina4/crud.rb,
lib/tina4/docs.rb,
lib/tina4/plan.rb,
lib/tina4/wsdl.rb,
lib/tina4/cache.rb,
lib/tina4/debug.rb,
lib/tina4/frond.rb,
lib/tina4/queue.rb,
lib/tina4/events.rb,
lib/tina4/health.rb,
lib/tina4/router.rb,
lib/tina4/seeder.rb,
lib/tina4/graphql.rb,
lib/tina4/metrics.rb,
lib/tina4/request.rb,
lib/tina4/service.rb,
lib/tina4/session.rb,
lib/tina4/swagger.rb,
lib/tina4/testing.rb,
lib/tina4/version.rb,
lib/tina4/database.rb,
lib/tina4/feedback.rb,
lib/tina4/rack_app.rb,
lib/tina4/response.rb,
lib/tina4/shutdown.rb,
lib/tina4/template.rb,
lib/tina4/auto_crud.rb,
lib/tina4/constants.rb,
lib/tina4/container.rb,
lib/tina4/dev_admin.rb,
lib/tina4/messenger.rb,
lib/tina4/migration.rb,
lib/tina4/validator.rb,
lib/tina4/webserver.rb,
lib/tina4/websocket.rb,
lib/tina4/background.rb,
lib/tina4/middleware.rb,
lib/tina4/dev_mailbox.rb,
lib/tina4/field_types.rb,
lib/tina4/test_client.rb,
lib/tina4/html_element.rb,
lib/tina4/localization.rb,
lib/tina4/rate_limiter.rb,
lib/tina4/error_overlay.rb,
lib/tina4/project_index.rb,
lib/tina4/query_builder.rb,
lib/tina4/scss_compiler.rb,
lib/tina4/cache_backends.rb,
lib/tina4/response_cache.rb,
lib/tina4/service_runner.rb,
lib/tina4/database_result.rb,
lib/tina4/sql_translation.rb,
lib/tina4/drivers/odbc_driver.rb,
lib/tina4/websocket_backplane.rb,
lib/tina4/drivers/mssql_driver.rb,
lib/tina4/drivers/mysql_driver.rb,
lib/tina4/drivers/schema_split.rb,
lib/tina4/drivers/sqlite_driver.rb,
lib/tina4/drivers/mongodb_driver.rb,
lib/tina4/drivers/firebird_driver.rb,
lib/tina4/drivers/postgres_driver.rb,
lib/tina4/database/sqlite3_adapter.rb,
lib/tina4/cache_backends/base_backend.rb,
lib/tina4/cache_backends/file_backend.rb,
lib/tina4/queue_backends/lite_backend.rb,
lib/tina4/cache_backends/mongo_backend.rb,
lib/tina4/cache_backends/redis_backend.rb,
lib/tina4/queue_backends/kafka_backend.rb,
lib/tina4/queue_backends/mongo_backend.rb,
lib/tina4/cache_backends/memory_backend.rb,
lib/tina4/cache_backends/valkey_backend.rb,
lib/tina4/session_handlers/file_handler.rb,
lib/tina4/session_handlers/mongo_handler.rb,
lib/tina4/session_handlers/redis_handler.rb,
lib/tina4/cache_backends/database_backend.rb,
lib/tina4/queue_backends/rabbitmq_backend.rb,
lib/tina4/session_handlers/valkey_handler.rb,
lib/tina4/cache_backends/memcached_backend.rb,
lib/tina4/session_handlers/database_handler.rb
Overview
WebSocket Backplane Abstraction for Tina4 Ruby.
Enables broadcasting WebSocket messages across multiple server instances using a shared pub/sub channel (e.g. Redis). Without a backplane configured, broadcast() only reaches connections on the local process.
Configuration via environment variables:
TINA4_WS_BACKPLANE — Backend type: "redis", "nats", or "" (default: none)
TINA4_WS_BACKPLANE_URL — Connection string (default: redis://localhost:6379)
Usage:
backplane = Tina4::WebSocketBackplane.create_backplane
if backplane
backplane.subscribe("chat") { |msg| relay_to_local(msg) }
backplane.publish("chat", '{"user":"A","text":"hello"}')
end
Defined Under Namespace
Modules: AI, Adapters, Auth, AutoCrud, Background, CacheBackends, Container, CorsMiddleware, Crud, DevAdmin, DevReload, Drivers, Env, ErrorOverlay, Feedback, FieldTypes, Health, HtmlHelpers, Localization, Log, McpDevTools, McpProtocol, Metrics, Plan, ProjectIndex, QueueBackends, Router, ScssCompiler, SessionHandlers, Shutdown, Swagger, Template, Testing Classes: API, APIResponse, AiPortRackApp, AssertionError, CLI, CaseInsensitiveHash, ConnectionPool, CorsClassMiddleware, CsrfMiddleware, Database, DatabaseResult, DevMailbox, DevMessengerProxy, Docs, ErrorTracker, Events, FakeData, FileUpload, Frond, GraphQL, GraphQLError, GraphQLExecutor, GraphQLParser, GraphQLSchema, GraphQLType, HtmlElement, IndifferentHash, Job, LazySession, LegacyEnvError, McpServer, MessageLog, Messenger, MessengerConnectionError, MessengerError, Middleware, Migration, MigrationBase, NATSBackplane, ORM, QueryBuilder, QueryCache, Queue, RackApp, RateLimiter, RateLimiterMiddleware, RedisBackplane, Request, RequestInspector, RequestLoggerMiddleware, Response, ResponseCache, Route, SQLTranslator, SafeString, SecurityHeadersMiddleware, SeedSummary, Service, ServiceContext, ServiceRunner, Session, Test, TestClient, TestResponse, Validator, WSDL, WebServer, WebSocket, WebSocketBackplane, WebSocketConnection, WebSocketRoute
Constant Summary collapse
- BANNER =
<<~'BANNER' ______ _ __ __ /_ __/(_)___ ____ _/ // / / / / / __ \/ __ `/ // /_ / / / / / / / /_/ /__ __/ /_/ /_/_/ /_/\__,_/ /_/ BANNER
- LEGACY_ENV_VARS =
Legacy env var names that v3.12 has retired. If any of these are set in the environment we refuse to boot — silently ignoring them would cause auth/db/mail to fall back to defaults with no warning. Each maps to its new TINA4_-prefixed canonical name.
{ "DATABASE_URL" => "TINA4_DATABASE_URL", "DATABASE_USERNAME" => "TINA4_DATABASE_USERNAME", "DATABASE_PASSWORD" => "TINA4_DATABASE_PASSWORD", "DB_URL" => "TINA4_DATABASE_URL", "SECRET" => "TINA4_SECRET", "API_KEY" => "TINA4_API_KEY", "JWT_ALGORITHM" => "TINA4_JWT_ALGORITHM", "SMTP_HOST" => "TINA4_MAIL_HOST", "SMTP_PORT" => "TINA4_MAIL_PORT", "SMTP_USERNAME" => "TINA4_MAIL_USERNAME", "SMTP_PASSWORD" => "TINA4_MAIL_PASSWORD", "SMTP_FROM" => "TINA4_MAIL_FROM", "SMTP_FROM_NAME" => "TINA4_MAIL_FROM_NAME", "IMAP_HOST" => "TINA4_MAIL_IMAP_HOST", "IMAP_PORT" => "TINA4_MAIL_IMAP_PORT", "IMAP_USER" => "TINA4_MAIL_IMAP_USERNAME", "IMAP_PASS" => "TINA4_MAIL_IMAP_PASSWORD", "HOST_NAME" => "TINA4_HOST_NAME", "SWAGGER_TITLE" => "TINA4_SWAGGER_TITLE", "SWAGGER_DESCRIPTION" => "TINA4_SWAGGER_DESCRIPTION", "SWAGGER_VERSION" => "TINA4_SWAGGER_VERSION", "ORM_PLURAL_TABLE_NAMES" => "TINA4_ORM_PLURAL_TABLE_NAMES" }.freeze
- TYPE_MAP =
── Type mapping ──────────────────────────────────────────────────
{ "String" => "string", "Integer" => "integer", "Float" => "number", "Numeric" => "number", "TrueClass" => "boolean", "FalseClass"=> "boolean", "Array" => "array", "Hash" => "object" }.freeze
- CRUD =
Uppercase alias for convenience: Tina4::CRUD.to_crud(…)
Crud- Debug =
Log- VERSION =
"3.13.38"- HTTP_OK =
── HTTP Status Codes ──
200- HTTP_CREATED =
201- HTTP_ACCEPTED =
202- HTTP_NO_CONTENT =
204- HTTP_MOVED =
301- HTTP_REDIRECT =
302- HTTP_NOT_MODIFIED =
304- HTTP_BAD_REQUEST =
400- HTTP_UNAUTHORIZED =
401- HTTP_FORBIDDEN =
403- HTTP_NOT_FOUND =
404- HTTP_METHOD_NOT_ALLOWED =
405- HTTP_CONFLICT =
409- HTTP_GONE =
410- HTTP_UNPROCESSABLE =
422- HTTP_TOO_MANY =
429- HTTP_SERVER_ERROR =
500- HTTP_BAD_GATEWAY =
502- HTTP_UNAVAILABLE =
503- APPLICATION_JSON =
── Content Types ──
"application/json"- APPLICATION_XML =
"application/xml"- APPLICATION_FORM =
"application/x-www-form-urlencoded"- APPLICATION_OCTET =
"application/octet-stream"- TEXT_HTML =
"text/html; charset=utf-8"- TEXT_PLAIN =
"text/plain; charset=utf-8"- TEXT_CSV =
"text/csv"- TEXT_XML =
"text/xml"- HTTP_REASON_PHRASES =
── HTTP Reason Phrases (RFC 7231 / RFC 9110) ──
Used to write a correct HTTP/1.1 status line wherever the framework emits one manually. Previously code paths that built the status line by hand wrote “HTTP/1.1 404 OK” regardless of code, which is malformed. “Tina4.http_reason(status)“ always returns a non-empty phrase that matches the status family.
{ 100 => "Continue", 101 => "Switching Protocols", 200 => "OK", 201 => "Created", 202 => "Accepted", 204 => "No Content", 206 => "Partial Content", 301 => "Moved Permanently", 302 => "Found", 303 => "See Other", 304 => "Not Modified", 307 => "Temporary Redirect", 308 => "Permanent Redirect", 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 406 => "Not Acceptable", 409 => "Conflict", 410 => "Gone", 413 => "Content Too Large", 415 => "Unsupported Media Type", 422 => "Unprocessable Content", 429 => "Too Many Requests", 500 => "Internal Server Error", 501 => "Not Implemented", 502 => "Bad Gateway", 503 => "Service Unavailable", 504 => "Gateway Timeout" }.freeze
- IMAP_CONNECTION_ERRORS =
Errors that mean “we could not talk to the mail server”, as opposed to “we talked fine and the mailbox is empty”. These must fail loud — LOG and RAISE — never be silently swallowed into an empty result. Mirrors the Python master’s _IMAP_CONNECTION_ERRORS tuple.
Built lazily so a missing net/imap gem (LoadError above) doesn’t break loading this file.
[ SocketError, # DNS / host resolution failures IOError, # closed/broken stream, EOF mid-conversation SystemCallError, # Errno::ECONNREFUSED / ECONNRESET / ETIMEDOUT etc. Timeout::Error, # connect/read timeout (Net::OpenTimeout descends from this) MessengerError # our own protocol-failure signal (re-raised as-is) ].tap do |errors| errors << Net::IMAP::Error if defined?(Net::IMAP::Error) errors << OpenSSL::SSL::SSLError if defined?(OpenSSL::SSL::SSLError) end.freeze
- WEBSOCKET_GUID =
"258EAFA5-E914-47DA-95CA-5AB5DC11AD37"- WEBSOCKET_BACKPLANE_CHANNEL =
Shared pub/sub channel name + envelope shape for the WebSocket backplane. MUST stay byte-identical across all four frameworks (Python/PHP/Ruby/Node) so a broadcast published by one framework’s instance is relayed by another.
"tina4:ws"- Raw =
Primary name for the trusted-markup wrapper — an alias of SafeString.
SafeString
Class Attribute Summary collapse
-
.database ⇒ Object
readonly
Returns the value of attribute database.
-
.root_dir ⇒ Object
Returns the value of attribute root_dir.
Class Method Summary collapse
-
._clear_orm(orm_class) ⇒ Object
Delete every row backing an ORM model.
-
._clear_table(db, table) ⇒ Object
Delete every row in
table. -
._default_mcp_server ⇒ Object
The default dev MCP server, mounted at /__dev/mcp.
-
._foreign_key_pools(orm_class, fields) ⇒ Object
For each foreign-key column on the model, fetch the existing primary-key values of the referenced table so seeded child rows reference a real parent (P4a).
-
._foreign_keys_for(orm_class) ⇒ Object
Resolve the foreign-key columns declared on a model to their referenced ORM classes.
-
._normalize_columns(columns) ⇒ Object
Normalise the
columnsargument ofseed_tableinto a uniform { column_name => type_or_callable } hash. -
._normalize_sql_type(type) ⇒ Object
Map a raw SQL/driver type string to a FakeData field type symbol.
-
._resolve_model_by_name(class_name) ⇒ Object
Find a loaded Tina4::ORM subclass by its simple (unqualified) class name.
-
._topo_sort_models(orm_classes) ⇒ Object
Topologically sort ORM models so parents (referenced tables) come before children (tables with a FK pointing at them).
-
._validate_types(fields, attrs, model_name) ⇒ Object
P4c — when a generated/static value’s Ruby type clearly mismatches the target column’s field type, LOG a warning (never hard-fail).
-
.add_html_helpers(namespace) ⇒ Object
Inject _div(), _p(), _a(), etc.
- .after(pattern = nil, &block) ⇒ Object
- .any(path, auth: false, swagger_meta: {}, &block) ⇒ Object
-
.background(callback = nil, interval: 1.0, &block) ⇒ Object
Register a periodic background task.
-
.before(pattern = nil, &block) ⇒ Object
Middleware hooks.
-
.bind_database(db, name: nil) ⇒ Object
Bind a database connection.
-
.build_frame(opcode, data, fin: true) ⇒ Object
Build a WebSocket frame (server→client, never masked).
-
.cache_clear ⇒ Object
Backward-compat alias for cache_clear (deprecated — use clear_cache).
- .cache_delete(key) ⇒ Object
-
.cache_get(key) ⇒ Object
Module-level KV API (parity with Python tina4_python.cache).
-
.cache_instance ⇒ Object
Lazy module-level singleton for cache_stats / clear_cache.
- .cache_set(key, value, ttl: 0) ⇒ Object
-
.cache_stats ⇒ Object
Module-level cache stats (parity with Python tina4_python.cache.cache_stats()).
-
.camel_to_snake(name) ⇒ Object
Convert a camelCase name to snake_case.
-
.check_legacy_env_vars!(io: $stderr, exit_on_error: true) ⇒ Object
Refuse to boot if pre-3.12 un-prefixed env vars are still set.
-
.clear_cache ⇒ Object
Module-level cache clear (parity with Python tina4_python.cache.clear_cache()).
-
.compute_accept_key(key) ⇒ Object
Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
-
.create_messenger(**options) ⇒ Object
Factory: returns a DevMailbox-intercepting messenger in dev mode, or a real Messenger in production.
-
.databases ⇒ Object
Named connection registry.
- .delete(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
-
.describe(name, &block) ⇒ Object
Inline test DSL.
-
.find_available_port(start, max_tries = 10) ⇒ Object
Initialize and start the web server.
-
.get(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
DSL methods for route registration GET is public by default (matching tina4_python behavior) POST/PUT/PATCH/DELETE are secured by default — use auth: false to make public.
-
.get_framework_frond ⇒ Object
Return the singleton Frond engine for built-in framework templates.
-
.get_frond ⇒ Object
Return the global Frond engine, creating a default if needed.
-
.group(prefix, auth: nil, &block) ⇒ Object
Route groups.
-
.html_helpers ⇒ Object
Module-level convenience: Tina4.html_helpers returns a module you can include.
-
.http_reason(status) ⇒ Object
Return the canonical HTTP reason phrase for “status“.
- .initialize!(root_dir = Dir.pwd) ⇒ Object
-
.is_localhost? ⇒ Boolean
Check if the server is running on localhost.
-
.mcp_enabled? ⇒ Boolean
Resolve whether the built-in MCP dev server should be active.
-
.mcp_port ⇒ Object
Resolve the dedicated MCP port.
-
.mcp_resource(uri, description: "", mime_type: "application/json", server: nil, &block) ⇒ Object
Register a block as an MCP resource.
-
.mcp_tool(name, description: "", server: nil, &block) ⇒ Object
Register a block as an MCP tool.
- .open_browser(url) ⇒ Object
- .options(path, &block) ⇒ Object
- .patch(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
- .post(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
- .print_banner(host: "0.0.0.0", port: 7147, server_name: nil) ⇒ Object
- .put(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
-
.Raw(value) ⇒ Object
Convenience constructor so callers can write Tina4::Raw(“x”) in addition to Tina4::Raw.new(“x”).
-
.register(name, instance = nil, &block) ⇒ Object
DI container shortcuts.
- .resolve(name) ⇒ Object
- .run!(root_dir = nil, port: nil, host: nil, debug: nil) ⇒ Object
-
.run_seeds(seed_folder: "seeds", clear: false) ⇒ Object
Run all seed files in the given folder.
-
.schema_from_method(method_obj) ⇒ Object
Extract JSON Schema input schema from a Ruby method’s parameters.
- .secure_delete(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
-
.secure_get(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
Explicit secure variants (always secured, regardless of HTTP method).
- .secure_patch(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
- .secure_post(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
- .secure_put(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
-
.seed_batch(tasks, clear: false, strict: false) ⇒ Hash
Seed multiple ORM classes in batch with dependency-aware ordering.
-
.seed_dir(seed_folder: "seeds", clear: false) ⇒ Object
Run all seed files in the given folder.
-
.seed_models(orm_classes, count: 10, overrides: {}, clear: false, seed: nil, strict: false) ⇒ Hash
Batch-seed several ORM models, ordering by their ForeignKeyField dependency graph (P4a).
-
.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil, strict: false) ⇒ SeedSummary
Seed an ORM class with auto-generated fake data.
-
.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil, strict: false) ⇒ SeedSummary
Seed a raw database table (no ORM class needed).
-
.service(name, options = {}, &block) ⇒ Object
Service runner DSL.
-
.set_frond(engine) ⇒ Object
Register a pre-configured Frond engine for response.render().
- .singleton(name, &block) ⇒ Object
-
.snake_to_camel(name) ⇒ Object
Convert a snake_case name to camelCase.
-
.t(key, **options) ⇒ Object
Translation shortcut.
-
.template_global(key, value) ⇒ Object
Template globals.
-
.websocket(path, &block) ⇒ Object
WebSocket route registration.
-
.websocket_origin_allowed?(headers) ⇒ Boolean
Return true if the request’s Origin is permitted to upgrade to a WebSocket.
Class Attribute Details
.database ⇒ Object (readonly)
Returns the value of attribute database.
121 122 123 |
# File 'lib/tina4.rb', line 121 def database @database end |
.root_dir ⇒ Object
Returns the value of attribute root_dir.
120 121 122 |
# File 'lib/tina4.rb', line 120 def root_dir @root_dir end |
Class Method Details
._clear_orm(orm_class) ⇒ Object
Delete every row backing an ORM model. Tolerant — logs and continues.
735 736 737 738 739 740 741 742 743 |
# File 'lib/tina4/seeder.rb', line 735 def self._clear_orm(orm_class) db = orm_class.get_db return unless db db.delete(orm_class.table_name, "1=1") Tina4::Log.info("Seeder: Cleared #{orm_class.table_name}") rescue => e Tina4::Log.warning("Seeder: could not clear #{orm_class.name}: #{e.}") end |
._clear_table(db, table) ⇒ Object
Delete every row in table. Tolerant — logs and continues on error.
727 728 729 730 731 732 |
# File 'lib/tina4/seeder.rb', line 727 def self._clear_table(db, table) db.delete(table, "1=1") Tina4::Log.info("Seeder: Cleared #{table}") rescue => e Tina4::Log.warning("Seeder: could not clear '#{table}': #{e.}") end |
._default_mcp_server ⇒ Object
The default dev MCP server, mounted at /__dev/mcp. Built lazily on first access and pre-loaded with the built-in dev tools (database_query, file_read, route_list, …) so both the REST shim (/__dev/api/mcp/*) and the JSON-RPC + SSE endpoints (/__dev/mcp, /__dev/mcp/sse) share one fully-populated tool registry.
431 432 433 434 435 436 437 |
# File 'lib/tina4/mcp.rb', line 431 def self._default_mcp_server @_default_mcp_server ||= begin server = McpServer.new("/__dev/mcp", name: "Tina4 Dev Tools") McpDevTools.register(server) server end end |
._foreign_key_pools(orm_class, fields) ⇒ Object
For each foreign-key column on the model, fetch the existing primary-key values of the referenced table so seeded child rows reference a real parent (P4a). Returns { fk_column_sym => [pk_value, …] }; columns with no resolvable / empty parent table are omitted (the generic generator then fills them, and the row may fail loudly — never silently).
750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 |
# File 'lib/tina4/seeder.rb', line 750 def self._foreign_key_pools(orm_class, fields) pools = {} fk_columns = _foreign_keys_for(orm_class) fields.each_key do |name| ref_class = fk_columns[name.to_s] next unless ref_class begin db = ref_class.get_db next unless db pk = ref_class.primary_key_field || :id rows = db.fetch("SELECT #{pk} FROM #{ref_class.table_name}", [], limit: 100_000) list = rows.respond_to?(:to_a) ? rows.to_a : Array(rows) values = list.map { |r| r[pk] || r[pk.to_s] }.compact pools[name] = values unless values.empty? rescue => e Tina4::Log.warning("Seeder: could not resolve FK pool for #{name}: #{e.}") end end pools end |
._foreign_keys_for(orm_class) ⇒ Object
Resolve the foreign-key columns declared on a model to their referenced ORM classes. Reads the model’s belongs_to relationship metadata (the foreign_key_field DSL wires a belongs_to whose :foreign_key is the column and :class_name names the parent). Returns { “column_name” => ParentClass }.
777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 |
# File 'lib/tina4/seeder.rb', line 777 def self._foreign_keys_for(orm_class) out = {} return out unless orm_class.respond_to?(:relationship_definitions) orm_class.relationship_definitions.each_value do |rel| next unless rel[:type] == :belongs_to fk = (rel[:foreign_key] || "").to_s next if fk.empty? target = _resolve_model_by_name(rel[:class_name]) out[fk] = target if target end out end |
._normalize_columns(columns) ⇒ Object
Normalise the columns argument of seed_table into a uniform { column_name => type_or_callable } hash. Accepts a plain hash (the documented form) OR an array of column-descriptor hashes (+{ name:, type:, primary_key:, … }+) as returned by db.columns, skipping auto-increment / id primary keys so they are left to the engine.
692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 |
# File 'lib/tina4/seeder.rb', line 692 def self._normalize_columns(columns) return columns if columns.is_a?(Hash) map = {} Array(columns).each do |col| next unless col.is_a?(Hash) name = col[:name] || col["name"] next if name.nil? pk = col[:primary_key] || col["primary_key"] lname = name.to_s.downcase # Skip primary-key id columns — the engine assigns them. next if pk && lname == "id" type = col[:type] || col["type"] || "string" map[name.to_sym] = _normalize_sql_type(type) end map end |
._normalize_sql_type(type) ⇒ Object
Map a raw SQL/driver type string to a FakeData field type symbol.
714 715 716 717 718 719 720 721 722 723 724 |
# File 'lib/tina4/seeder.rb', line 714 def self._normalize_sql_type(type) t = type.to_s.downcase return :integer if t =~ /int|serial/ return :float if t =~ /real|float|double|numeric|decimal|money/ return :boolean if t =~ /bool|bit/ return :datetime if t =~ /datetime|timestamp/ return :date if t == "date" return :blob if t =~ /blob|binary/ return :text if t =~ /text|clob/ :string end |
._resolve_model_by_name(class_name) ⇒ Object
Find a loaded Tina4::ORM subclass by its simple (unqualified) class name.
837 838 839 840 841 842 843 844 845 846 847 |
# File 'lib/tina4/seeder.rb', line 837 def self._resolve_model_by_name(class_name) return nil if class_name.nil? return class_name if class_name.is_a?(Class) simple = class_name.to_s.split("::").last return nil unless defined?(Tina4::ORM) && Tina4::ORM.respond_to?(:model_subclasses) Tina4::ORM.model_subclasses.find do |k| k.name && k.name.split("::").last == simple end end |
._topo_sort_models(orm_classes) ⇒ Object
Topologically sort ORM models so parents (referenced tables) come before children (tables with a FK pointing at them). Uses the belongs_to FK metadata. Models not in the input list are ignored as dependencies. Cycles / unresolved deps fall back to declared order so nothing is dropped.
797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 |
# File 'lib/tina4/seeder.rb', line 797 def self._topo_sort_models(orm_classes) in_set = orm_classes.uniq by_name = {} in_set.each { |m| by_name[m.name.to_s.split("::").last] = m } deps_of = {} in_set.each do |model| deps = [] _foreign_keys_for(model).each_value do |ref_class| simple = ref_class.name.to_s.split("::").last target = by_name[simple] deps << target if target && !target.equal?(model) end deps_of[model] = deps.uniq end ordered = [] placed = [] remaining = in_set.dup progressed = true while !remaining.empty? && progressed progressed = false still = [] remaining.each do |model| if deps_of[model].all? { |d| placed.include?(d) } ordered << model placed << model progressed = true else still << model end end remaining = still end # Cycle / unresolved deps — append in declared order so we never drop a model. ordered.concat(remaining) ordered end |
._validate_types(fields, attrs, model_name) ⇒ Object
P4c — when a generated/static value’s Ruby type clearly mismatches the target column’s field type, LOG a warning (never hard-fail). bool-in-int is allowed (Ruby has no bool/int subclass relation, but seeded booleans are represented as 0/1 integers here, so only flag truly suspicious cases).
853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 |
# File 'lib/tina4/seeder.rb', line 853 def self._validate_types(fields, attrs, model_name) expected = { integer: Integer, float: Float, boolean: Integer } attrs.each do |name, value| next if value.nil? field = fields[name] next if field.nil? want = expected[field[:type]] next if want.nil? # A Float landing in an :integer column (or vice-versa) is the suspicious # case; everything that is_a? the expected numeric is fine. next if value.is_a?(want) next if want == Integer && value.is_a?(Numeric) && field[:type] == :boolean Tina4::Log.warning( "Seeder: #{model_name}.#{name} expected #{want} but generated " \ "#{value.class} (#{value.inspect}) — inserting anyway" ) end end |
.add_html_helpers(namespace) ⇒ Object
Inject _div(), _p(), _a(), etc. helper methods into the given namespace (hash or object).
Usage:
h = {}
Tina4.add_html_helpers(h)
h[:_div].call({ class: "card" }, h[:_p].call("Hello"))
204 205 206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/tina4/html_element.rb', line 204 def self.add_html_helpers(namespace) helper = Object.new.extend(HtmlHelpers) HtmlElement::HTML_TAGS.each do |tag| name = "_#{tag}" fn = helper.method(name.to_sym) if namespace.is_a?(Hash) namespace[name.to_sym] = fn else namespace.define_singleton_method(name.to_sym, &fn) end end end |
.after(pattern = nil, &block) ⇒ Object
396 397 398 |
# File 'lib/tina4.rb', line 396 def after(pattern = nil, &block) Tina4::Middleware.after(pattern, &block) end |
.any(path, auth: false, swagger_meta: {}, &block) ⇒ Object
344 345 346 347 348 349 |
# File 'lib/tina4.rb', line 344 def any(path, auth: false, swagger_meta: {}, &block) auth_handler = resolve_auth(auth) %w[GET POST PUT PATCH DELETE].each do |method| Tina4::Router.add(method, path, block, auth_handler: auth_handler, swagger_meta: ) end end |
.background(callback = nil, interval: 1.0, &block) ⇒ Object
426 427 428 |
# File 'lib/tina4.rb', line 426 def background(callback = nil, interval: 1.0, &block) Tina4::Background.register(callback, interval: interval, &block) end |
.before(pattern = nil, &block) ⇒ Object
Middleware hooks
392 393 394 |
# File 'lib/tina4.rb', line 392 def before(pattern = nil, &block) Tina4::Middleware.before(pattern, &block) end |
.bind_database(db, name: nil) ⇒ Object
Bind a database connection.
bind_database(db) → sets the global default (Tina4.database)
bind_database(db, name: :analytics) → registers a named connection
A model with ‘self.db = :analytics` resolves from this named registry; otherwise models fall back to the global default / TINA4_DATABASE_URL.
128 129 130 131 132 133 134 135 |
# File 'lib/tina4.rb', line 128 def bind_database(db, name: nil) if name.nil? @database = db else (@databases ||= {})[name.to_sym] = db end db end |
.build_frame(opcode, data, fin: true) ⇒ Object
Build a WebSocket frame (server→client, never masked).
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/tina4/websocket.rb', line 46 def self.build_frame(opcode, data, fin: true) first_byte = (fin ? 0x80 : 0x00) | opcode frame = [first_byte].pack("C") length = data.bytesize if length < 126 frame += [length].pack("C") elsif length < 65536 frame += [126, length].pack("Cn") else frame += [127, length].pack("CQ>") end frame + data end |
.cache_clear ⇒ Object
Backward-compat alias for cache_clear (deprecated — use clear_cache).
478 479 480 |
# File 'lib/tina4/response_cache.rb', line 478 def cache_clear cache_instance.clear_cache end |
.cache_delete(key) ⇒ Object
463 464 465 |
# File 'lib/tina4/response_cache.rb', line 463 def cache_delete(key) cache_instance.cache_delete(key) end |
.cache_get(key) ⇒ Object
Module-level KV API (parity with Python tina4_python.cache).
455 456 457 |
# File 'lib/tina4/response_cache.rb', line 455 def cache_get(key) cache_instance.cache_get(key) end |
.cache_instance ⇒ Object
Lazy module-level singleton for cache_stats / clear_cache.
450 451 452 |
# File 'lib/tina4/response_cache.rb', line 450 def cache_instance @default_cache ||= ResponseCache.new(ttl: ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 60) end |
.cache_set(key, value, ttl: 0) ⇒ Object
459 460 461 |
# File 'lib/tina4/response_cache.rb', line 459 def cache_set(key, value, ttl: 0) cache_instance.cache_set(key, value, ttl: ttl) end |
.cache_stats ⇒ Object
Module-level cache stats (parity with Python tina4_python.cache.cache_stats()).
468 469 470 |
# File 'lib/tina4/response_cache.rb', line 468 def cache_stats cache_instance.cache_stats end |
.camel_to_snake(name) ⇒ Object
Convert a camelCase name to snake_case.
12 13 14 |
# File 'lib/tina4/orm.rb', line 12 def self.camel_to_snake(name) name.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "") end |
.check_legacy_env_vars!(io: $stderr, exit_on_error: true) ⇒ Object
Refuse to boot if pre-3.12 un-prefixed env vars are still set.
Tina4 v3.12 hard-renamed every framework-specific env var to use the TINA4_ prefix. Booting silently with a legacy DATABASE_URL or SECRET would let auth, DB, or mail fall back to insecure defaults while the user thought their config was being read. Better to die loudly with a list of names to fix.
Bypass with TINA4_ALLOW_LEGACY_ENV=true in CI / migration scripts that genuinely need both names set during a transition window.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/tina4/env.rb', line 47 def self.check_legacy_env_vars!(io: $stderr, exit_on_error: true) bypass = ENV["TINA4_ALLOW_LEGACY_ENV"].to_s.downcase return if %w[true 1 yes].include?(bypass) found = LEGACY_ENV_VARS.keys.select { |name| ENV.key?(name) }.sort return if found.empty? sep = "─" * 72 lines = ["", sep, "Tina4 v3.12 requires TINA4_ prefix on all framework env vars.", "Your environment still has these legacy names:", ""] found.each do |old| new_name = LEGACY_ENV_VARS[old] lines << format(" %-28s → %s", old, new_name) end lines.concat([ "", "Run `tina4 env --migrate` to rewrite your .env automatically,", "or rename manually. See https://tina4.com/release/3.12.0", "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.", sep, "" ]) io.puts lines.join("\n") raise LegacyEnvError, "Legacy env vars present: #{found.join(', ')}" unless exit_on_error exit(2) end |
.clear_cache ⇒ Object
Module-level cache clear (parity with Python tina4_python.cache.clear_cache()).
473 474 475 |
# File 'lib/tina4/response_cache.rb', line 473 def clear_cache cache_instance.clear_cache end |
.compute_accept_key(key) ⇒ Object
Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
18 19 20 |
# File 'lib/tina4/websocket.rb', line 18 def self.compute_accept_key(key) Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}")) end |
.create_messenger(**options) ⇒ Object
Factory: returns a DevMailbox-intercepting messenger in dev mode, or a real Messenger in production.
654 655 656 657 658 659 660 661 662 663 664 665 666 |
# File 'lib/tina4/messenger.rb', line 654 def self.create_messenger(**) dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) smtp_configured = ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty? if dev_mode && !smtp_configured mailbox_dir = .delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"] mailbox = DevMailbox.new(mailbox_dir: mailbox_dir) DevMessengerProxy.new(mailbox, **) else Messenger.new(**) end end |
.databases ⇒ Object
Named connection registry. bind_database(db, name:) populates it; models with a Symbol/String ‘self.db` resolve against it.
139 |
# File 'lib/tina4.rb', line 139 def databases = (@databases ||= {}) |
.delete(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
339 340 341 342 |
# File 'lib/tina4.rb', line 339 def delete(path, auth: :default, swagger_meta: {}, &block) auth_handler = resolve_auth(auth) Tina4::Router.add("DELETE", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.describe(name, &block) ⇒ Object
Inline test DSL
406 407 408 |
# File 'lib/tina4.rb', line 406 def describe(name, &block) Tina4::Testing.describe(name, &block) end |
.find_available_port(start, max_tries = 10) ⇒ Object
Initialize and start the web server. This is the primary entry point for app.rb files:
Tina4.initialize!(__dir__)
Tina4.run!
Or combined: Tina4.run!(__dir__)
223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/tina4.rb', line 223 def find_available_port(start, max_tries = 10) require "socket" max_tries.times do |offset| port = start + offset begin server = TCPServer.new("127.0.0.1", port) server.close return port rescue Errno::EADDRINUSE, Errno::EACCES next end end start end |
.get(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
DSL methods for route registration GET is public by default (matching tina4_python behavior) POST/PUT/PATCH/DELETE are secured by default — use auth: false to make public
319 320 321 322 |
# File 'lib/tina4.rb', line 319 def get(path, auth: nil, swagger_meta: {}, &block) auth_handler = auth == false ? nil : auth Tina4::Router.add("GET", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.get_framework_frond ⇒ Object
Return the singleton Frond engine for built-in framework templates.
18 19 20 21 22 23 24 25 26 27 28 29 30 |
# File 'lib/tina4/response.rb', line 18 def self.get_framework_frond framework_dir = ::File.join(::File.dirname(__FILE__), "templates") if @_framework_frond.nil? && ::File.directory?(framework_dir) @_framework_frond = Tina4::Frond.new(template_dir: framework_dir) end # Sync custom filters/globals from the user engine if @_framework_frond user_engine = get_frond @_framework_frond.instance_variable_get(:@filters).merge!(user_engine.instance_variable_get(:@filters)) @_framework_frond.instance_variable_get(:@globals).merge!(user_engine.instance_variable_get(:@globals)) end @_framework_frond end |
.get_frond ⇒ Object
Return the global Frond engine, creating a default if needed.
13 14 15 |
# File 'lib/tina4/response.rb', line 13 def self.get_frond @_global_frond ||= Tina4::Frond.new(template_dir: "src/templates") end |
.group(prefix, auth: nil, &block) ⇒ Object
Route groups
382 383 384 |
# File 'lib/tina4.rb', line 382 def group(prefix, auth: nil, &block) Tina4::Router.group(prefix, auth_handler: auth, &block) end |
.html_helpers ⇒ Object
Module-level convenience: Tina4.html_helpers returns a module you can include.
193 194 195 |
# File 'lib/tina4/html_element.rb', line 193 def self.html_helpers HtmlHelpers end |
.http_reason(status) ⇒ Object
Return the canonical HTTP reason phrase for “status“.
Falls back to a sensible label when an exotic status is used. Never returns an empty string — the HTTP/1.1 status line requires a phrase. Prefers Rack::Utils::HTTP_STATUS_CODES when Rack is available so the phrase tracks Rack’s mapping, otherwise uses the local table above.
75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/tina4/constants.rb', line 75 def self.http_reason(status) code = status.to_i if defined?(Rack::Utils::HTTP_STATUS_CODES) phrase = Rack::Utils::HTTP_STATUS_CODES[code] return phrase if phrase && !phrase.empty? end phrase = HTTP_REASON_PHRASES[code] return phrase if phrase && !phrase.empty? return "OK" if code >= 200 && code < 300 "Error" end |
.initialize!(root_dir = Dir.pwd) ⇒ Object
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/tina4.rb', line 180 def initialize!(root_dir = Dir.pwd) @root_dir = root_dir # Print banner # Load environment. Precedence: real-env > .env.local > .env # (.env.local loads first, both first-wins, so a real env var always wins). Tina4::Env.load_env(root_dir) # Setup debug logging Tina4::Log.configure(root_dir) Tina4::Log.info("Tina4 Ruby v#{VERSION} initializing...") # Fail-safe dev secret: in dev (and NOT CI/prod) mint a per-machine # random TINA4_SECRET into gitignored .env.local if it is blank; in # CI/prod with a blank secret, emit the actionable warning. Runs once at # boot after env load, before any auth use. Never crashes boot. Tina4::Auth.ensure_dev_secret(root_dir) # Setup auth keys Tina4::Auth.setup(root_dir) # Load translations Tina4::Localization.load(root_dir) # Auto-wire t() into template globals if locales were loaded autowire_i18n_template_global # Connect database if configured setup_database # Auto-discover routes auto_discover(root_dir) Tina4::Log.info("Tina4 initialized successfully") end |
.is_localhost? ⇒ Boolean
Check if the server is running on localhost.
135 136 137 138 |
# File 'lib/tina4/mcp.rb', line 135 def self.is_localhost? host = ENV.fetch("TINA4_HOST_NAME", "localhost:7145").split(":").first ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host) end |
.mcp_enabled? ⇒ Boolean
Resolve whether the built-in MCP dev server should be active.
Precedence:
* TINA4_MCP set explicitly → use that (truthy/falsey).
* Otherwise: enabled only when TINA4_DEBUG=true.
145 146 147 148 149 150 151 |
# File 'lib/tina4/mcp.rb', line 145 def self.mcp_enabled? explicit = ENV["TINA4_MCP"] if explicit && !explicit.empty? return %w[true 1 yes on].include?(explicit.to_s.strip.downcase) end %w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase) end |
.mcp_port ⇒ Object
Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps MCP tooling reachable on a stable, predictable channel separate from the main HTTP port and the AI test port (port + 1000).
156 157 158 159 160 161 162 |
# File 'lib/tina4/mcp.rb', line 156 def self.mcp_port explicit = ENV["TINA4_MCP_PORT"] return explicit.to_i if explicit && !explicit.empty? && explicit.to_i > 0 base_port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i base_port + 2000 end |
.mcp_resource(uri, description: "", mime_type: "application/json", server: nil, &block) ⇒ Object
Register a block as an MCP resource.
Tina4.mcp_resource("app://tables", description: "Database tables") do
db.tables
end
457 458 459 460 461 |
# File 'lib/tina4/mcp.rb', line 457 def self.mcp_resource(uri, description: "", mime_type: "application/json", server: nil, &block) target = server || _default_mcp_server target.register_resource(uri, block, description, mime_type) block end |
.mcp_tool(name, description: "", server: nil, &block) ⇒ Object
Register a block as an MCP tool.
Tina4.mcp_tool("lookup_invoice", description: "Find invoice by number") do |invoice_no:|
db.fetch_one("SELECT * FROM invoices WHERE invoice_no = ?", [invoice_no])
end
444 445 446 447 448 449 450 |
# File 'lib/tina4/mcp.rb', line 444 def self.mcp_tool(name, description: "", server: nil, &block) target = server || _default_mcp_server handler = block tool_desc = description.empty? ? name : description target.register_tool(name, handler, tool_desc) handler end |
.open_browser(url) ⇒ Object
238 239 240 241 242 243 244 245 246 247 248 |
# File 'lib/tina4.rb', line 238 def open_browser(url) require "rbconfig" Thread.new do sleep 2 case RbConfig::CONFIG["host_os"] when /darwin/i then system("open", url) when /mswin|mingw/i then system("start", url) else system("xdg-open", url) end end end |
.options(path, &block) ⇒ Object
351 352 353 |
# File 'lib/tina4.rb', line 351 def (path, &block) Tina4::Router.add("OPTIONS", path, block) end |
.patch(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
334 335 336 337 |
# File 'lib/tina4.rb', line 334 def patch(path, auth: :default, swagger_meta: {}, &block) auth_handler = resolve_auth(auth) Tina4::Router.add("PATCH", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.post(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
324 325 326 327 |
# File 'lib/tina4.rb', line 324 def post(path, auth: :default, swagger_meta: {}, &block) auth_handler = resolve_auth(auth) Tina4::Router.add("POST", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.print_banner(host: "0.0.0.0", port: 7147, server_name: nil) ⇒ Object
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/tina4.rb', line 141 def (host: "0.0.0.0", port: 7147, server_name: nil) # TINA4_SUPPRESS — short-circuit ALL banner output for headless / CI runs. return if Tina4::Env.is_truthy(ENV["TINA4_SUPPRESS"]) is_tty = $stdout.respond_to?(:isatty) && $stdout.isatty color = is_tty ? "\e[31m" : "" reset = is_tty ? "\e[0m" : "" is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) log_level = (ENV["TINA4_LOG_LEVEL"] || "[TINA4_LOG_ALL]").upcase display = (host == "0.0.0.0" || host == "::") ? "localhost" : host # Auto-detect server name if not provided if server_name.nil? if is_debug server_name = "WEBrick" else begin require "puma" server_name = "puma" rescue LoadError server_name = "WEBrick" end end end puts "#{color}#{BANNER}#{reset}" puts " TINA4 — The Intelligent Native Application 4ramework" puts " Simple. Fast. Human. | Built for AI. Built for you." puts "" puts " Server: http://#{display}:#{port} (#{server_name})" puts " Swagger: http://localhost:#{port}/swagger" puts " Dashboard: http://localhost:#{port}/__dev" puts " Debug: #{is_debug ? 'ON' : 'OFF'} (Log level: #{log_level})" puts "" rescue puts "#{color}TINA4 Ruby v#{VERSION}#{reset}" end |
.put(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
329 330 331 332 |
# File 'lib/tina4.rb', line 329 def put(path, auth: :default, swagger_meta: {}, &block) auth_handler = resolve_auth(auth) Tina4::Router.add("PUT", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.Raw(value) ⇒ Object
Convenience constructor so callers can write Tina4::Raw(“x”) in addition to Tina4::Raw.new(“x”).
24 25 26 |
# File 'lib/tina4/html_element.rb', line 24 def self.Raw(value) Raw.new(value.to_s) end |
.register(name, instance = nil, &block) ⇒ Object
DI container shortcuts
431 432 433 |
# File 'lib/tina4.rb', line 431 def register(name, instance = nil, &block) Tina4::Container.register(name, instance, &block) end |
.resolve(name) ⇒ Object
439 440 441 |
# File 'lib/tina4.rb', line 439 def resolve(name) Tina4::Container.get(name) end |
.run!(root_dir = nil, port: nil, host: nil, debug: nil) ⇒ Object
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/tina4.rb', line 250 def run!(root_dir = nil, port: nil, host: nil, debug: nil) # Handle legacy call: run!(port: 7147) where root_dir receives the hash if root_dir.is_a?(Hash) port ||= root_dir[:port] host ||= root_dir[:host] debug = root_dir[:debug] if debug.nil? && root_dir.key?(:debug) root_dir = nil end root_dir ||= Dir.pwd ENV["PORT"] = port.to_s if port ENV["HOST"] = host.to_s if host ENV["TINA4_DEBUG"] = debug.to_s unless debug.nil? initialize!(root_dir) unless @root_dir host = ENV.fetch("HOST", ENV.fetch("TINA4_HOST", "0.0.0.0")) port = ENV.fetch("PORT", ENV.fetch("TINA4_PORT", "7147")).to_i actual_port = find_available_port(port) if actual_port != port Tina4::Log.info("Port #{port} in use, using #{actual_port}") port = actual_port end display_host = (host == "0.0.0.0" || host == "::") ? "localhost" : host url = "http://#{display_host}:#{port}" app = Tina4::RackApp.new(root_dir: root_dir) is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) # Try Puma first (production-grade), fall back to WEBrick if !is_debug begin require "puma" require "puma/configuration" require "puma/launcher" config = Puma::Configuration.new do |user_config| user_config.bind "tcp://#{host}:#{port}" user_config.app app user_config.threads 0, 16 user_config.workers 0 user_config.environment "production" user_config.log_requests false user_config.quiet end Tina4::Log.info("Production server: puma") Tina4::Shutdown.setup open_browser(url) launcher = Puma::Launcher.new(config) launcher.run return rescue LoadError # Puma not installed, fall through to WEBrick end end Tina4::Log.info("Development server: WEBrick") open_browser(url) server = Tina4::WebServer.new(app, host: host, port: port) server.start end |
.run_seeds(seed_folder: "seeds", clear: false) ⇒ Object
Run all seed files in the given folder.
Parity: Python/PHP/Node use ‘seed(n)` to set the PRNG seed on FakeData. Ruby’s FakeData.seed already does that — this folder-runner is named differently to avoid the collision.
883 884 885 |
# File 'lib/tina4/seeder.rb', line 883 def self.run_seeds(seed_folder: "seeds", clear: false) seed_dir(seed_folder: seed_folder, clear: clear) end |
.schema_from_method(method_obj) ⇒ Object
Extract JSON Schema input schema from a Ruby method’s parameters.
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/tina4/mcp.rb', line 107 def self.schema_from_method(method_obj) properties = {} required = [] method_obj.parameters.each do |kind, name| next if name == :self name_s = name.to_s # Default type is "string" -- Ruby doesn't have inline type annotations prop = { "type" => "string" } case kind when :req, :keyreq required << name_s when :opt, :key # Has a default -- we cannot inspect the default value easily in Ruby, # so we just mark it as optional (no "default" key) end properties[name_s] = prop end schema = { "type" => "object", "properties" => properties } schema["required"] = required unless required.empty? schema end |
.secure_delete(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
376 377 378 379 |
# File 'lib/tina4.rb', line 376 def secure_delete(path, auth: nil, swagger_meta: {}, &block) auth_handler = auth || Tina4::Auth.default_secure_auth Tina4::Router.add("DELETE", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.secure_get(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
Explicit secure variants (always secured, regardless of HTTP method)
356 357 358 359 |
# File 'lib/tina4.rb', line 356 def secure_get(path, auth: nil, swagger_meta: {}, &block) auth_handler = auth || Tina4::Auth.default_secure_auth Tina4::Router.add("GET", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.secure_patch(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
371 372 373 374 |
# File 'lib/tina4.rb', line 371 def secure_patch(path, auth: nil, swagger_meta: {}, &block) auth_handler = auth || Tina4::Auth.default_secure_auth Tina4::Router.add("PATCH", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.secure_post(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
361 362 363 364 |
# File 'lib/tina4.rb', line 361 def secure_post(path, auth: nil, swagger_meta: {}, &block) auth_handler = auth || Tina4::Auth.default_secure_auth Tina4::Router.add("POST", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.secure_put(path, auth: nil, swagger_meta: {}, &block) ⇒ Object
366 367 368 369 |
# File 'lib/tina4.rb', line 366 def secure_put(path, auth: nil, swagger_meta: {}, &block) auth_handler = auth || Tina4::Auth.default_secure_auth Tina4::Router.add("PUT", path, block, auth_handler: auth_handler, swagger_meta: ) end |
.seed_batch(tasks, clear: false, strict: false) ⇒ Hash
Seed multiple ORM classes in batch with dependency-aware ordering.
Backwards-compatible task form (+[{ orm_class:, count:, overrides:, seed: }]+). The tasks are reordered by the FK dependency graph so parents seed before children; clear: true clears in reverse-topo order. Strict mode re-raises on the first failed row of any task.
660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 |
# File 'lib/tina4/seeder.rb', line 660 def self.seed_batch(tasks, clear: false, strict: false) by_class = {} tasks.each { |t| by_class[t[:orm_class]] = t } ordered_classes = _topo_sort_models(tasks.map { |t| t[:orm_class] }) if clear ordered_classes.reverse_each { |orm_class| _clear_orm(orm_class) } end results = {} ordered_classes.each do |orm_class| task = by_class[orm_class] results[orm_class.name] = seed_orm( orm_class, count: task[:count] || 10, overrides: task[:overrides] || {}, clear: false, seed: task[:seed], strict: strict ) end results end |
.seed_dir(seed_folder: "seeds", clear: false) ⇒ Object
Run all seed files in the given folder.
890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 |
# File 'lib/tina4/seeder.rb', line 890 def self.seed_dir(seed_folder: "seeds", clear: false) unless Dir.exist?(seed_folder) Tina4::Log.info("Seeder: No seeds folder found at #{seed_folder}") return end files = Dir.glob(File.join(seed_folder, "*.rb")).sort files.reject! { |f| File.basename(f).start_with?("_") } if files.empty? Tina4::Log.info("Seeder: No seed files found in #{seed_folder}") return end Tina4::Log.info("Seeder: Found #{files.length} seed file(s) in #{seed_folder}") files.each do |filepath| begin Tina4::Log.info("Seeder: Running #{File.basename(filepath)}...") load filepath Tina4::Log.info("Seeder: Completed #{File.basename(filepath)}") rescue => e Tina4::Log.error("Seeder: Failed to run #{File.basename(filepath)}: #{e.}") end end end |
.seed_models(orm_classes, count: 10, overrides: {}, clear: false, seed: nil, strict: false) ⇒ Hash
Batch-seed several ORM models, ordering by their ForeignKeyField dependency graph (P4a). Parent tables seed before children (topological sort over the ORM’s belongs_to/has_many FK metadata); when clear: true the clear runs in the REVERSE order so children are removed before parents — no FK violations regardless of the order the caller lists the models in.
622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 |
# File 'lib/tina4/seeder.rb', line 622 def self.seed_models(orm_classes, count: 10, overrides: {}, clear: false, seed: nil, strict: false) ordered = _topo_sort_models(orm_classes) if clear ordered.reverse_each { |model| _clear_orm(model) } end results = {} ordered.each do |model| model_overrides = overrides if overrides.is_a?(Hash) && overrides.key?(model) model_overrides = overrides[model] end results[model.name] = seed_orm( model, count: count, overrides: model_overrides || {}, clear: false, seed: seed, strict: strict ) end results end |
.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil, strict: false) ⇒ SeedSummary
Seed an ORM class with auto-generated fake data.
Visible-but-resilient: each row is wrapped. On a row failure the cause is logged (with the row index) and the row is skipped — unless strict: true, in which case the FIRST failure RE-RAISES. A one-line summary is logged at the end. This replaces both the old crash-prone path and the silent swallow.
447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 |
# File 'lib/tina4/seeder.rb', line 447 def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil, strict: false) fake = FakeData.new(seed: seed) fields = orm_class.field_definitions table = orm_class.table_name if fields.empty? Tina4::Log.error("Seeder: No fields found on #{orm_class.name}") return SeedSummary.new end db = Tina4.database unless db Tina4::Log.error("Seeder: No database connection. Call Tina4.bind_database(db) first.") return SeedSummary.new end # Idempotency short-circuit (Ruby-specific, additive to the Python master): # without an explicit clear, skip when the table already has >= count rows. unless clear begin result = db.fetch_one("SELECT count(*) as cnt FROM #{table}") if result && result[:cnt].to_i >= count Tina4::Log.info("Seeder: #{table} already has #{result[:cnt]} records, skipping") return SeedSummary.new end rescue => e # Table might not exist — fall through and let row inserts surface it. end end _clear_orm(orm_class) if clear insert_fields = fields.reject { |name, opts| opts[:primary_key] && opts[:auto_increment] } # P4a — resolve FK columns to REAL parent PKs so a child row references an # existing parent. Snapshotted once (parents are seeded first by # seed_models's topo-sort, so the table is populated by now). fk_pools = _foreign_key_pools(orm_class, insert_fields) seeded = 0 failed = 0 errors = [] count.times do |i| begin attrs = {} insert_fields.each do |name, field_def| if overrides.key?(name) val = overrides[name] attrs[name] = val.respond_to?(:call) ? val.call(fake) : val elsif fk_pools[name] && !fk_pools[name].empty? attrs[name] = fake.choice(fk_pools[name]) else generated = fake.for_field(field_def, name) attrs[name] = generated unless generated.nil? end end _validate_types(fields, attrs, orm_class.name) obj = orm_class.new(attrs) # ORM#save returns false (it rolls back internally) instead of raising # on a constraint failure — convert that falsy result into a counted # failure so it is never reported as success. if obj.save seeded += 1 else reason = obj.errors.empty? ? "save returned false" : obj.errors.join(", ") raise "save failed: #{reason}" end rescue => e if strict Tina4::Log.error("Seeder: row #{i} failed seeding #{orm_class.name} (strict): #{e.}") raise end failed += 1 errors << { row: i, message: e. } Tina4::Log.warning("Seeder: row #{i} failed seeding #{orm_class.name}, skipped: #{e.}") end end Tina4::Log.info("Seeder: #{orm_class.name} — seeded #{seeded}, #{failed} failed") SeedSummary.new(seeded: seeded, failed: failed, errors: errors) end |
.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil, strict: false) ⇒ SeedSummary
Seed a raw database table (no ORM class needed).
Visible-but-resilient: each row is wrapped. On a row failure the cause is logged (with the row index) and the row is skipped — unless strict: true, in which case the FIRST failure RE-RAISES. A one-line summary is logged at the end.
551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 |
# File 'lib/tina4/seeder.rb', line 551 def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil, strict: false) fake = FakeData.new(seed: seed) db = Tina4.database unless db Tina4::Log.error("Seeder: No database connection.") return SeedSummary.new end field_map = _normalize_columns(columns) _clear_table(db, table_name) if clear seeded = 0 failed = 0 errors = [] count.times do |i| begin row = {} field_map.each do |col_name, type_str| if overrides.key?(col_name) val = overrides[col_name] row[col_name] = val.respond_to?(:call) ? val.call(fake) : val elsif type_str.respond_to?(:call) # field_map value is itself a generator (Python field_map parity). row[col_name] = type_str.arity.zero? ? type_str.call : type_str.call(fake) else field_def = { type: type_str.to_sym } row[col_name] = fake.for_field(field_def, col_name) end end db.insert(table_name, row) seeded += 1 rescue => e if strict Tina4::Log.error("Seeder: row #{i} failed seeding '#{table_name}' (strict): #{e.}") raise end failed += 1 errors << { row: i, message: e. } Tina4::Log.warning("Seeder: row #{i} failed seeding '#{table_name}', skipped: #{e.}") end end begin db.commit rescue StandardError # Autocommit-on engines / pooled standalone writes may not need an # explicit commit; never let the summary itself crash. end Tina4::Log.info("Seeder: '#{table_name}' — seeded #{seeded}, #{failed} failed") SeedSummary.new(seeded: seeded, failed: failed, errors: errors) end |
.service(name, options = {}, &block) ⇒ Object
Service runner DSL
416 417 418 |
# File 'lib/tina4.rb', line 416 def service(name, = {}, &block) Tina4::ServiceRunner.register(name, nil, , &block) end |
.set_frond(engine) ⇒ Object
Register a pre-configured Frond engine for response.render().
33 34 35 |
# File 'lib/tina4/response.rb', line 33 def self.set_frond(engine) @_global_frond = engine end |
.singleton(name, &block) ⇒ Object
435 436 437 |
# File 'lib/tina4.rb', line 435 def singleton(name, &block) Tina4::Container.singleton(name, &block) end |
.snake_to_camel(name) ⇒ Object
Convert a snake_case name to camelCase.
6 7 8 9 |
# File 'lib/tina4/orm.rb', line 6 def self.snake_to_camel(name) parts = name.to_s.split("_") parts[0] + parts[1..].map(&:capitalize).join end |
.t(key, **options) ⇒ Object
Translation shortcut
411 412 413 |
# File 'lib/tina4.rb', line 411 def t(key, **) Tina4::Localization.t(key, **) end |
.template_global(key, value) ⇒ Object
Template globals
401 402 403 |
# File 'lib/tina4.rb', line 401 def template_global(key, value) Tina4::Template.add_global(key, value) end |
.websocket(path, &block) ⇒ Object
WebSocket route registration
387 388 389 |
# File 'lib/tina4.rb', line 387 def websocket(path, &block) Tina4::Router.websocket(path, &block) end |
.websocket_origin_allowed?(headers) ⇒ Boolean
Return true if the request’s Origin is permitted to upgrade to a WebSocket.
Controlled by TINA4_WS_ALLOWED_ORIGINS (comma-separated exact origins, e.g. “app.example.com,https://admin.example.com”).
Empty/unset = allow ALL origins (current behaviour, non-breaking). When set, only requests whose Origin exactly matches a listed value are allowed; a missing Origin header is rejected once the allow-list is active.
headers is a Hash. The Origin is looked up case-insensitively across both the Rack-style “HTTP_ORIGIN” key and a plain “origin”/“Origin” key so the same helper serves the rack_app upgrade path and direct callers/tests.
34 35 36 37 38 39 40 41 42 43 |
# File 'lib/tina4/websocket.rb', line 34 def self.websocket_origin_allowed?(headers) raw = (ENV["TINA4_WS_ALLOWED_ORIGINS"] || "").strip return true if raw.empty? # No allow-list configured — permit everything. allowed = raw.split(",").map(&:strip).reject(&:empty?) return true if allowed.empty? origin = headers["HTTP_ORIGIN"] || headers["origin"] || headers["Origin"] allowed.include?(origin) end |