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/mqtt.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/context.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/docstore.rb,
lib/tina4/feedback.rb,
lib/tina4/rack_app.rb,
lib/tina4/realtime.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/database_url.rb,
lib/tina4/html_element.rb,
lib/tina4/localization.rb,
lib/tina4/mqtt_message.rb,
lib/tina4/rate_limiter.rb,
lib/tina4/error_overlay.rb,
lib/tina4/project_index.rb,
lib/tina4/query_builder.rb,
lib/tina4/cache_backends.rb,
lib/tina4/response_cache.rb,
lib/tina4/service_runner.rb,
lib/tina4/sql_translator.rb,
lib/tina4/context/chunker.rb,
lib/tina4/database_result.rb,
lib/tina4/database_adapter.rb,
lib/tina4/realtime/channel.rb,
lib/tina4/realtime/message.rb,
lib/tina4/realtime/storage.rb,
lib/tina4/dispatch_pipeline.rb,
lib/tina4/realtime/workspace.rb,
lib/tina4/drivers/odbc_driver.rb,
lib/tina4/realtime/attachment.rb,
lib/tina4/realtime/s3_storage.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/realtime/local_storage.rb,
lib/tina4/drivers/firebird_driver.rb,
lib/tina4/drivers/postgres_driver.rb,
lib/tina4/realtime/channel_member.rb,
lib/tina4/database/sqlite3_adapter.rb,
lib/tina4/realtime/storage_backend.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/session_handlers/resp_client.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,
lib/tina4/session_handlers/memcached_handler.rb,
lib/tina4/session_handlers/mongo_wire_client.rb
Overview
frozen_string_literal: true
Defined Under Namespace
Modules: AI, Adapters, Auth, AutoCrud, Background, CacheBackends, Container, CorsMiddleware, Crud, DatabaseAdapter, DevAdmin, DevReload, DispatchPipeline, DocStore, Drivers, Env, ErrorOverlay, Feedback, FieldTypes, Health, HtmlHelpers, Localization, Log, McpDevTools, McpProtocol, Metrics, Plan, ProjectIndex, QueueBackends, Realtime, Router, SessionHandlers, Shutdown, Swagger, Template, Testing Classes: API, APIResponse, AiPortRackApp, AssertionError, CLI, CaseInsensitiveHash, ConnectionPool, Context, CorsClassMiddleware, CsrfMiddleware, Database, DatabaseConnectionError, DatabaseResult, DatabaseUrl, DevMailbox, Docs, ErrorTracker, Events, FakeData, FileUpload, Frond, GraphQL, GraphQLError, GraphQLExecutor, GraphQLParser, GraphQLSchema, GraphQLType, HtmlElement, IndifferentHash, Job, LazySession, LegacyEnvError, McpServer, MessageLog, Messenger, MessengerConnectionError, MessengerError, MetricsEngineError, Middleware, Migration, MigrationBase, Mqtt, MqttError, MqttMessage, MqttTimeoutError, 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
- API_RETRY_STATUSES =
Statuses that warrant an automatic retry when max_retries > 0: rate-limit (429) plus the transient server-side 5xx family. 4xx client errors (401, 404, …) are NOT retried — a repeat won't succeed. Parity with the Python master's _RETRY_STATUSES.
[429, 500, 502, 503, 504].freeze
- API_DOWNLOAD_CHUNK_SIZE =
Streaming download reads/writes this many bytes per chunk so a multi-megabyte body never lands in memory in one piece. Parity with the Python master's _DOWNLOAD_CHUNK_SIZE.
64 * 1024
- API_REDIRECT_STATUSES =
Redirect codes we follow (301/302/303/307/308). Net::HTTP does NOT auto-follow, so the client follows them itself in a bounded loop (see #network_call).
[301, 302, 303, 307, 308].freeze
- API_MAX_REDIRECTS =
Upper bound on redirect hops before we stop following and return the last 3xx response. Matches urllib's default (Python master).
10- API_STRIP_ON_CROSS_ORIGIN =
Headers dropped when a redirect crosses to a different origin — a bearer token or a session cookie must never be handed to a host you didn't authenticate to. Compared case-insensitively. Parity with the Python master's _STRIP_ON_CROSS_ORIGIN.
%w[authorization cookie].freeze
- API_MIME_BY_EXTENSION =
A curated extension -> MIME map for guessing a multipart part's Content-Type from its filename (Ruby core ships no mimetypes db, and pulling one in would break the zero-dependency promise). tina4: a small built-in map covering the common upload types; anything unlisted falls back to application/octet-stream. Mirrors the guess-with-fallback the Python master gets from mimetypes.
{ "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml", "bmp" => "image/bmp", "ico" => "image/x-icon", "tif" => "image/tiff", "tiff" => "image/tiff", "pdf" => "application/pdf", "json" => "application/json", "xml" => "application/xml", "zip" => "application/zip", "gz" => "application/gzip", "tar" => "application/x-tar", "csv" => "text/csv", "txt" => "text/plain", "html" => "text/html", "htm" => "text/html", "css" => "text/css", "js" => "text/javascript", "md" => "text/markdown", "mp3" => "audio/mpeg", "wav" => "audio/wav", "ogg" => "audio/ogg", "mp4" => "video/mp4", "mov" => "video/quicktime", "webm" => "video/webm", "doc" => "application/msword", "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "xls" => "application/vnd.ms-excel", "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "ppt" => "application/vnd.ms-powerpoint", "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation" }.freeze
- 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.97"- 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 =
RFC 6455 section 1.3 magic value. MUST be exactly this string - the client concatenates it to its Sec-WebSocket-Key, SHA-1s, base64s, and compares to our Sec-WebSocket-Accept. A wrong GUID yields a wrong Accept: raw clients that skip the check still connect, but a browser validates it per spec and refuses the upgrade (silent failure). Verified against the RFC test vector: key "dGhlIHNhbXBsZSBub25jZQ==" -> accept "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=".
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"- 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
- .all_env ⇒ Object
- .any(path, auth: false, swagger_meta: {}, &block) ⇒ Object
-
.auto_migrate_on_startup!(root_dir = Dir.pwd) ⇒ Object
Apply pending DB migrations on startup — NON-BREAKING.
-
.background(callback = nil, interval: 1.0, &block) ⇒ Object
Register a periodic background task.
-
.banner_surface_lines(port, swagger_enabled:, dev_admin_enabled:) ⇒ Array<String>
Build the startup banner's optional surface lines (issue #99).
-
.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).
-
.builtin_webserver_pinned? ⇒ Boolean
TINA4_DEFAULT_WEBSERVER=TRUE pins Tina4's BUILT-IN server (WEBrick) even in production, where Puma would otherwise be chosen.
-
.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 for a Messenger configured from the environment.
-
.databases ⇒ Object
Named connection registry.
- .delete(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
-
.describe(name, &block) ⇒ Object
Inline test DSL.
- .env_bool(name, default: false) ⇒ Object
- .env_float(name, default: 0.0) ⇒ Object
- .env_int(name, default: 0) ⇒ Object
- .env_str(name, default: "") ⇒ Object
-
.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_env(key, default = nil) ⇒ Object
-
.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.
- .has_env?(key) ⇒ Boolean
-
.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
Informational only — whether the CONFIGURED host looks local.
-
.is_loopback?(ip) ⇒ Boolean
Whether an address is a loopback (in-process / same-host) peer.
-
.load_env(root = nil) ⇒ Object
Load .env.local then .env from a ROOT DIRECTORY (canonical in all four).
-
.mcp_enabled? ⇒ Boolean
Capability gate — whether the MCP subsystem may run at all.
-
.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.
-
.normalise_ip(value) ⇒ Object
Strip the decorations a peer address can arrive with: the "[::1]" bracket form and an IPv6 zone id ("fe80::1%eth0").
- .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
-
.puma_available? ⇒ Boolean
Is Puma loadable? Separate from start_puma_server so the caller can fall back to WEBrick BEFORE any side effect (opening a browser) happens.
- .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.
-
.register_builtin_routes! ⇒ Object
The framework's own routes, in ONE place.
-
.request_allowed?(remote_ip, has_valid_token: false) ⇒ Boolean
Per-request authorisation — whether THIS caller may use MCP.
-
.require_env(*keys) ⇒ Object
Raises KeyError naming EVERY missing variable; returns the requested map.
- .reset_env ⇒ Object
- .resolve(name) ⇒ Object
-
.resolve_bind_host(default = "0.0.0.0") ⇒ Object
Bind address, with the framework name winning over the bare one.
-
.resolve_bind_port(default = 7147) ⇒ Object
Bind port, with the framework name winning over the bare one.
- .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
-
.secure_websocket(path, &block) ⇒ Object
Register a SECURED WebSocket route — declarative sibling of Tina4.websocket(...).secure, mirroring secure_get/secure_post.
-
.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
-
.singularize(word) ⇒ Object
Singularize a plural relationship name to derive a class name.
-
.snake_to_camel(name) ⇒ Object
Convert a snake_case name to camelCase.
-
.start_puma_server(app, host:, port:) ⇒ Object
Boot the production server (Puma) with Tina4's shutdown contract wired in.
-
.t(key, **options) ⇒ Object
Translation shortcut.
-
.template_global(key, value) ⇒ Object
Template globals.
-
.trusted_proxy?(address) ⇒ Boolean
Is this address a configured trusted proxy?.
-
.trusted_proxy_networks ⇒ Object
The configured trusted-proxy networks, from TINA4_TRUSTED_PROXIES.
- .truthy?(value) ⇒ Boolean
-
.warn_deprecated_bind_var(old_name, new_name, value) ⇒ Object
Warn ONCE per variable.
-
.websocket(path, secure: false, &block) ⇒ Object
WebSocket route registration.
-
.websocket_origin_allowed?(headers) ⇒ Boolean
Return true if the request's Origin is permitted to upgrade to a WebSocket.
-
.ws_authorized(auth_required, headers, query_string = "", subprotocol = "") ⇒ Object
Per-route WebSocket authentication, checked on the upgrade.
-
.ws_bearer_subprotocol_offered?(headers) ⇒ Boolean
Whether the client offered the "bearer" subprotocol — in which case the handshake response must echo "bearer" as the accepted subprotocol (browsers reject a 101 that doesn't echo back a subprotocol they offered).
-
.ws_token(headers, query_string = "", subprotocol = "") ⇒ Object
Extract a bearer token from a WebSocket upgrade handshake.
Class Attribute Details
.database ⇒ Object (readonly)
Returns the value of attribute database.
343 344 345 |
# File 'lib/tina4.rb', line 343 def database @database end |
.root_dir ⇒ Object
Returns the value of attribute root_dir.
342 343 344 |
# File 'lib/tina4.rb', line 342 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.
591 592 593 594 595 596 597 |
# File 'lib/tina4/mcp.rb', line 591 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
745 746 747 |
# File 'lib/tina4.rb', line 745 def after(pattern = nil, &block) Tina4::Middleware.after(pattern, &block) end |
.all_env ⇒ Object
227 228 229 |
# File 'lib/tina4.rb', line 227 def self.all_env Tina4::Env.all_env end |
.any(path, auth: false, swagger_meta: {}, &block) ⇒ Object
685 686 687 688 689 690 |
# File 'lib/tina4.rb', line 685 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 |
.auto_migrate_on_startup!(root_dir = Dir.pwd) ⇒ Object
Apply pending DB migrations on startup — NON-BREAKING. Public so it can be
called explicitly (and unit-tested) as Tina4.auto_migrate_on_startup!.
When a migrations/ folder exists (with at least one .sql file) and
TINA4_AUTO_MIGRATE is not disabled, pending migrations are applied during
boot so the schema is current with no manual tina4ruby migrate step. A
failure here is logged LOUD and the service STILL starts — a bad migration
must never take the backend down. (The explicit tina4ruby migrate CLI
stays fail-fast so CI still gets a non-zero exit.)
Disable with TINA4_AUTO_MIGRATE=false (also 0/no/off) — e.g. multi-instance production that migrates as a separate deploy step (concurrent first-apply can race).
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 835 836 837 838 839 840 841 842 843 |
# File 'lib/tina4.rb', line 805 def auto_migrate_on_startup!(root_dir = Dir.pwd) # Gate 1: a migrations folder with at least one .sql file must exist. migrations_dir = resolve_startup_migrations_dir(root_dir) return unless migrations_dir # Gate 2: TINA4_AUTO_MIGRATE not disabled (default "true"; false/0/no/off off). unless Tina4::Env.is_truthy(ENV.fetch("TINA4_AUTO_MIGRATE", "true")) Tina4::Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations") return end # Gate 3: a database must be resolvable. db = Tina4.database unless db Tina4::Log.debug("Startup migrations skipped (no database configured)") return end begin migration = Tina4::Migration.new(db, migrations_dir: migrations_dir) results = migration.run applied = Array(results).count { |r| r[:status] == "success" } Tina4::Log.info("Applied #{applied} pending migration(s) on startup") if applied.positive? # A migration that records as "failed" surfaces in `run`'s results but # does NOT raise from the runner; treat a recorded failure as loud-log too. if Array(results).any? { |r| r[:status] == "failed" } Tina4::Log.error( "Startup auto-migration failed — the service is starting anyway. " \ "Run `tina4ruby migrate` to retry." ) end rescue => e # NON-BREAKING: never re-raise out of the startup hook. Tina4::Log.error( "Startup auto-migration failed: #{e.} — the service is starting " \ "anyway. Run `tina4ruby migrate` to retry." ) end end |
.background(callback = nil, interval: 1.0, &block) ⇒ Object
775 776 777 |
# File 'lib/tina4.rb', line 775 def background(callback = nil, interval: 1.0, &block) Tina4::Background.register(callback, interval: interval, &block) end |
.banner_surface_lines(port, swagger_enabled:, dev_admin_enabled:) ⇒ Array<String>
Build the startup banner's optional surface lines (issue #99).
Only advertise a surface that is actually REACHABLE. In production, or with TINA4_DEBUG off, /swagger and /__dev return 404 -- printing them anyway both misleads an operator into believing a dev surface is exposed and sends a developer to a dead link.
Kept as a pure function of (port, two booleans) so the contract is unit testable without booting a server and grepping stdout. Parity: Python banner_surface_lines, PHP App::bannerSurfaceLines, Node bannerSurfaceLines.
375 376 377 378 379 380 |
# File 'lib/tina4.rb', line 375 def (port, swagger_enabled:, dev_admin_enabled:) lines = [] lines << " Swagger: http://localhost:#{port}/swagger" if swagger_enabled lines << " Dashboard: http://localhost:#{port}/__dev" if dev_admin_enabled lines end |
.before(pattern = nil, &block) ⇒ Object
Middleware hooks
741 742 743 |
# File 'lib/tina4.rb', line 741 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.
350 351 352 353 354 355 356 357 |
# File 'lib/tina4.rb', line 350 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).
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/tina4/websocket.rb', line 125 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 |
.builtin_webserver_pinned? ⇒ Boolean
TINA4_DEFAULT_WEBSERVER=TRUE pins Tina4's BUILT-IN server (WEBrick) even in production, where Puma would otherwise be chosen. Unset/FALSE keeps today's behaviour, so this is non-breaking.
This is NOT the remedy for a production shutdown problem - an operator must never have to give up Puma to get their database connections closed. It exists so CI can pin the built-in server deterministically without having to switch TINA4_DEBUG on to get there.
599 600 601 |
# File 'lib/tina4.rb', line 599 def builtin_webserver_pinned? Tina4::Env.is_truthy(ENV["TINA4_DEFAULT_WEBSERVER"]) end |
.cache_clear ⇒ Object
Backward-compat alias for cache_clear (deprecated — use clear_cache).
596 597 598 |
# File 'lib/tina4/response_cache.rb', line 596 def cache_clear cache_instance.clear_cache end |
.cache_delete(key) ⇒ Object
581 582 583 |
# File 'lib/tina4/response_cache.rb', line 581 def cache_delete(key) cache_instance.cache_delete(key) end |
.cache_get(key) ⇒ Object
Module-level KV API (parity with Python tina4_python.cache).
573 574 575 |
# File 'lib/tina4/response_cache.rb', line 573 def cache_get(key) cache_instance.cache_get(key) end |
.cache_instance ⇒ Object
Lazy module-level singleton for cache_stats / clear_cache.
568 569 570 |
# File 'lib/tina4/response_cache.rb', line 568 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
577 578 579 |
# File 'lib/tina4/response_cache.rb', line 577 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()).
586 587 588 |
# File 'lib/tina4/response_cache.rb', line 586 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 75 76 77 78 79 |
# 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([ "", "Note: these may come from a .env file loaded by dotenv, not just", "the runtime environment - check your image / build context (a .env", "baked into a Docker image is loaded at startup) as well as k8s/CI env.", "", "FIX: run `tina4 env --migrate` to rewrite your .env automatically", "(it renames every legacy name to its TINA4_ form in place).", "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()).
591 592 593 |
# File 'lib/tina4/response_cache.rb', line 591 def clear_cache cache_instance.clear_cache end |
.compute_accept_key(key) ⇒ Object
Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
24 25 26 |
# File 'lib/tina4/websocket.rb', line 24 def self.compute_accept_key(key) Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}")) end |
.create_messenger(**options) ⇒ Object
Factory for a Messenger configured from the environment.
Defined HERE, eagerly, and not in messenger.rb: autoload only fires on a
CONSTANT reference, and a module function is not a constant. A cold
require "tina4"; Tina4.create_messenger therefore raised NoMethodError until
something else happened to touch Tina4::Messenger first -- the documented entry
point was unreachable on a fresh process. Naming the constant below is what
triggers the autoload.
188 189 190 |
# File 'lib/tina4.rb', line 188 def self.create_messenger(**) Tina4::Messenger.create_messenger(**) end |
.databases ⇒ Object
Named connection registry. bind_database(db, name:) populates it;
models with a Symbol/String self.db resolve against it.
361 |
# File 'lib/tina4.rb', line 361 def databases = (@databases ||= {}) |
.delete(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
680 681 682 683 |
# File 'lib/tina4.rb', line 680 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
755 756 757 |
# File 'lib/tina4.rb', line 755 def describe(name, &block) Tina4::Testing.describe(name, &block) end |
.env_bool(name, default: false) ⇒ Object
304 305 306 |
# File 'lib/tina4.rb', line 304 def self.env_bool(name, default: false) Tina4::Env.bool(name, default: default) end |
.env_float(name, default: 0.0) ⇒ Object
312 313 314 |
# File 'lib/tina4.rb', line 312 def self.env_float(name, default: 0.0) Tina4::Env.float(name, default: default) end |
.env_int(name, default: 0) ⇒ Object
308 309 310 |
# File 'lib/tina4.rb', line 308 def self.env_int(name, default: 0) Tina4::Env.int(name, default: default) end |
.env_str(name, default: "") ⇒ Object
316 317 318 |
# File 'lib/tina4.rb', line 316 def self.env_str(name, default: "") Tina4::Env.str(name, default: default) 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)
490 491 492 493 494 495 496 497 498 499 500 501 502 503 |
# File 'lib/tina4.rb', line 490 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
660 661 662 663 |
# File 'lib/tina4.rb', line 660 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_env(key, default = nil) ⇒ Object
214 215 216 |
# File 'lib/tina4.rb', line 214 def self.get_env(key, default = nil) Tina4::Env.get_env(key, default) 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
723 724 725 |
# File 'lib/tina4.rb', line 723 def group(prefix, auth: nil, &block) Tina4::Router.group(prefix, auth_handler: auth, &block) end |
.has_env?(key) ⇒ Boolean
223 224 225 |
# File 'lib/tina4.rb', line 223 def self.has_env?(key) Tina4::Env.has_env?(key) 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
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 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 |
# File 'lib/tina4.rb', line 425 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. # # No argument here, deliberately (ADR-0041). This used to pass `root_dir`, # and since Ruby correctly lets an explicit argument beat the environment, # that had two measured consequences in every booted Ruby app: # TINA4_LOG_DIR was ENTIRELY DEAD -- the documented variable could not # move the logs anywhere -- and because root_dir is an existing directory # it became the log directory itself, so tina4.log and error.log were # written into the PROJECT ROOT rather than the documented logs/. # root_dir is the framework's default, not a user instruction, so it # belongs below the environment. Resolution is now TINA4_LOG_DIR, then # logs/, matching Python and Node. Tina4::Log.configure 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 # Framework routes FIRST, then the app's. Routes resolve in registration # order, so anything registered later cannot shadow these - and an app # with an ordinary catch-all (`any("/{slug}")`) would otherwise swallow # /__health, which is exactly what a container health check probes. # Python registers its built-ins first for the same reason. register_builtin_routes! # Auto-discover routes auto_discover(root_dir) # Apply pending DB migrations on startup (non-breaking — see method doc). # Runs AFTER route discovery / DB bind, BEFORE serving. auto_migrate_on_startup!(root_dir) Tina4::Log.info("Tina4 initialized successfully") end |
.is_localhost? ⇒ Boolean
Informational only — whether the CONFIGURED host looks local.
NOT the security gate. This reads TINA4_HOST_NAME (the configured bind address), which on a 0.0.0.0 bind looks "local" while still accepting remote clients. Trust decisions use request_allowed? with the RAW socket peer instead. Kept for diagnostics / back-compat.
141 142 143 144 |
# File 'lib/tina4/mcp.rb', line 141 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 |
.is_loopback?(ip) ⇒ Boolean
Whether an address is a loopback (in-process / same-host) peer.
Operates on the RAW socket peer, never X-Forwarded-For. Empty means an
in-process / synthetic request (no socket) and is trusted. The ::ffff:
IPv4-mapped prefix is stripped. NOTE: 0.0.0.0 is a BIND address, never a
client address, so it is deliberately NOT loopback.
Python master parity: tina4_python.mcp.is_loopback.
154 155 156 157 158 159 160 |
# File 'lib/tina4/mcp.rb', line 154 def self.is_loopback?(ip) return true if ip.nil? || ip.to_s.empty? addr = ip.to_s.strip.downcase addr = addr[7..] if addr.start_with?("::ffff:") addr == "::1" || addr == "localhost" || addr.start_with?("127.") end |
.load_env(root = nil) ⇒ Object
Load .env.local then .env from a ROOT DIRECTORY (canonical in all four).
210 211 212 |
# File 'lib/tina4.rb', line 210 def self.load_env(root = nil) root.nil? ? Tina4::Env.load_env : Tina4::Env.load_env(root) end |
.mcp_enabled? ⇒ Boolean
Capability gate — whether the MCP subsystem may run at all.
Pure capability, host-INDEPENDENT (Python master parity):
1. TINA4_MCP set explicitly → use it (sysadmin override, any host).
2. Else TINA4_DEBUG truthy → MCP is a capability of this deployment.
3. Otherwise off.
This NO LONGER consults the host. A debug box bound to 0.0.0.0 still "has" the capability, but request_allowed? is what decides whether a given CALLER may use it — loopback always, remote only with an explicit opt-in plus a valid token. Splitting capability from per-request authorisation closes the hole where a 0.0.0.0 bind auto-exposed DB/file tools to remote unauthenticated callers (pre-3.13.40 is_localhost? treated 0.0.0.0 local).
175 176 177 178 179 180 181 182 |
# File 'lib/tina4/mcp.rb', line 175 def self.mcp_enabled? explicit = ENV["TINA4_MCP"] if explicit && !explicit.empty? return truthy?(explicit) end truthy?(ENV["TINA4_DEBUG"]) 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).
212 213 214 215 216 217 218 |
# File 'lib/tina4/mcp.rb', line 212 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
617 618 619 620 621 |
# File 'lib/tina4/mcp.rb', line 617 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
604 605 606 607 608 609 610 |
# File 'lib/tina4/mcp.rb', line 604 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 |
.normalise_ip(value) ⇒ Object
Strip the decorations a peer address can arrive with: the "[::1]" bracket form and an IPv6 zone id ("fe80::1%eth0").
297 298 299 300 301 302 |
# File 'lib/tina4.rb', line 297 def self.normalise_ip(value) value = value.to_s.strip value = value[1...value.index("]")].to_s if value.start_with?("[") && value.include?("]") value = value.split("%", 2).first.to_s if value.include?("%") value end |
.open_browser(url) ⇒ Object
505 506 507 508 509 510 511 512 513 514 515 |
# File 'lib/tina4.rb', line 505 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
692 693 694 |
# File 'lib/tina4.rb', line 692 def (path, &block) Tina4::Router.add("OPTIONS", path, block) end |
.patch(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
675 676 677 678 |
# File 'lib/tina4.rb', line 675 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
665 666 667 668 |
# File 'lib/tina4.rb', line 665 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
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 |
# File 'lib/tina4.rb', line 382 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})" # Only advertise a surface that is actually reachable (issue #99). ( port, swagger_enabled: Tina4::Swagger.enabled?, dev_admin_enabled: is_debug ).each { |line| puts line } puts " Debug: #{is_debug ? 'ON' : 'OFF'} (Log level: #{log_level})" puts "" rescue puts "#{color}TINA4 Ruby v#{VERSION}#{reset}" end |
.puma_available? ⇒ Boolean
Is Puma loadable? Separate from start_puma_server so the caller can fall back to WEBrick BEFORE any side effect (opening a browser) happens.
605 606 607 608 609 610 611 612 |
# File 'lib/tina4.rb', line 605 def puma_available? require "puma" require "puma/configuration" require "puma/launcher" true rescue LoadError false end |
.put(path, auth: :default, swagger_meta: {}, &block) ⇒ Object
670 671 672 673 |
# File 'lib/tina4.rb', line 670 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
780 781 782 |
# File 'lib/tina4.rb', line 780 def register(name, instance = nil, &block) Tina4::Container.register(name, instance, &block) end |
.register_builtin_routes! ⇒ Object
The framework's own routes, in ONE place. Both entry points call this: Tina4.run! (app.rb) and the CLI's cmd_start. Anything added here is available however the app was launched -- which is the whole point.
520 521 522 523 |
# File 'lib/tina4.rb', line 520 def register_builtin_routes! Tina4::Health.register! Tina4::Frond.register_live_endpoint! end |
.request_allowed?(remote_ip, has_valid_token: false) ⇒ Boolean
Per-request authorisation — whether THIS caller may use MCP.
Rules (Python master parity, tina4_python.mcp.is_request_allowed):
- Capability off ({mcp_enabled?} false) → deny.
- Loopback peer → allow.
- Remote peer → only when TINA4_MCP_REMOTE is truthy AND a valid token
was presented. No configured token ⇒ remote can never pass.
195 196 197 198 199 200 |
# File 'lib/tina4/mcp.rb', line 195 def self.request_allowed?(remote_ip, has_valid_token: false) return false unless mcp_enabled? return true if is_loopback?(remote_ip) truthy?(ENV["TINA4_MCP_REMOTE"]) && has_valid_token end |
.require_env(*keys) ⇒ Object
Raises KeyError naming EVERY missing variable; returns the requested map.
219 220 221 |
# File 'lib/tina4.rb', line 219 def self.require_env(*keys) Tina4::Env.require_env(*keys) end |
.reset_env ⇒ Object
231 232 233 |
# File 'lib/tina4.rb', line 231 def self.reset_env Tina4::Env.reset_env end |
.resolve(name) ⇒ Object
788 789 790 |
# File 'lib/tina4.rb', line 788 def resolve(name) Tina4::Container.get(name) end |
.resolve_bind_host(default = "0.0.0.0") ⇒ Object
Bind address, with the framework name winning over the bare one.
TINA4_HOST > HOST (deprecated) > default. See resolve_bind_port.
62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/tina4.rb', line 62 def self.resolve_bind_host(default = "0.0.0.0") tina4_host = ENV["TINA4_HOST"] return tina4_host if tina4_host && !tina4_host.empty? legacy = ENV["HOST"] if legacy && !legacy.empty? warn_deprecated_bind_var("HOST", "TINA4_HOST", legacy) return legacy end default end |
.resolve_bind_port(default = 7147) ⇒ Object
Bind port, with the framework name winning over the bare one.
TINA4_PORT > PORT (deprecated) > default. Bare PORT is a name anything can set - a shared CI runner, a PaaS, another tool - and it should never outrank the framework's own variable. It is still honoured so no deployment breaks; the warning is what drives the move. Removal is 3.14.
80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/tina4.rb', line 80 def self.resolve_bind_port(default = 7147) tina4_port = ENV["TINA4_PORT"] return tina4_port.to_i if tina4_port && tina4_port.match?(/\A\d+\z/) legacy = ENV["PORT"] if legacy && legacy.match?(/\A\d+\z/) warn_deprecated_bind_var("PORT", "TINA4_PORT", legacy) return legacy.to_i end default end |
.run!(root_dir = nil, port: nil, host: nil, debug: nil) ⇒ Object
525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 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 |
# File 'lib/tina4.rb', line 525 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 # Built-in routes now register inside initialize!, BEFORE route discovery, # so they cannot be shadowed by an app catch-all. Kept here as a safety net # for a caller that reaches run! without initialize! having run (the guard # above skips it when @root_dir is already set). register! is idempotent # and Router.add replaces in place, so a second call is a no-op. # # They used to be registered ONLY by the CLI's cmd_start, so an app booted # the documented way -- `Tina4.run!` from app.rb, which is what # `tina4 init ruby` scaffolds -- served 404 on /health while the identical # code under `tina4ruby serve` served 200. register_builtin_routes! # TINA4_HOST/TINA4_PORT BEFORE the bare names, not after. # # This had both the wrong way round: ENV.fetch("PORT", ...) returns PORT # whenever it is set, so a stray OS-level HOST or PORT outranked the # framework's own variable - the one the CLI documents and prefers, and # the one Tina4::WebServer a few files away already reads first. Two code # paths in one framework disagreeing about the same variable. # # Bare HOST/PORT stay honoured so nothing breaks, and warn so the # migration happens. Removal is 3.14. host = Tina4.resolve_bind_host port = Tina4.resolve_bind_port(7147) 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 && !builtin_webserver_pinned? && puma_available? open_browser(url) start_puma_server(app, host: host, port: port) return 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.
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 133 |
# File 'lib/tina4/mcp.rb', line 108 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
717 718 719 720 |
# File 'lib/tina4.rb', line 717 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)
697 698 699 700 |
# File 'lib/tina4.rb', line 697 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
712 713 714 715 |
# File 'lib/tina4.rb', line 712 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
702 703 704 705 |
# File 'lib/tina4.rb', line 702 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
707 708 709 710 |
# File 'lib/tina4.rb', line 707 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 |
.secure_websocket(path, &block) ⇒ Object
Register a SECURED WebSocket route — declarative sibling of Tina4.websocket(...).secure, mirroring secure_get/secure_post.
736 737 738 |
# File 'lib/tina4.rb', line 736 def secure_websocket(path, &block) Tina4::Router.secure_websocket(path, &block) 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
765 766 767 |
# File 'lib/tina4.rb', line 765 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
784 785 786 |
# File 'lib/tina4.rb', line 784 def singleton(name, &block) Tina4::Container.singleton(name, &block) end |
.singularize(word) ⇒ Object
Singularize a plural relationship name to derive a class name.
has_many :posts → "Post", has_many :categories → "Category". The old derivation was a naive sub(/s$/) that turned "categories" into "Categorie" — a NameError waiting to happen. This handles the common English plural endings before falling back to stripping a trailing "s". (When class_name: is passed explicitly, this is never consulted.)
23 24 25 26 27 28 29 30 31 32 33 34 |
# File 'lib/tina4/orm.rb', line 23 def self.singularize(word) s = word.to_s if s =~ /ies\z/i s.sub(/ies\z/i, "y") elsif s =~ /(ss|sh|ch|x|z)es\z/i s.sub(/es\z/i, "") elsif s =~ /s\z/i && s !~ /ss\z/i s.sub(/s\z/i, "") else s end 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 |
.start_puma_server(app, host:, port:) ⇒ Object
Boot the production server (Puma) with Tina4's shutdown contract wired in.
Feature 9's OUTCOMES are the framework's contract whichever server owns the socket; the MECHANISM differs per server. Puma already stops accepting and drains in-flight requests properly, so that is CONFIGURED, not reimplemented:
* TINA4_SHUTDOWN_TIMEOUT -> Puma's force_shutdown_after. Puma's default
is :forever, so before this the documented env var meant NOTHING on
the path operators actually run.
* raise_exception_on_sigterm false -> Puma's default is true, which
re-raises SignalException after the graceful stop and terminates the
process BY the signal. The contract is a clean exit 0.
What Puma cannot know about is ours, and runs in the ensure: live WebSocket peers get RFC 6455 1001, background tasks stop, and every database connection is closed. Nothing but Tina4 knows those connections exist, so without this they leaked on every production shutdown.
632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 |
# File 'lib/tina4.rb', line 632 def start_puma_server(app, host:, port:) # Puma owns INT/TERM here, so Tina4 installs no handlers of its own. Tina4::Shutdown.setup(trap_signals: false) shutdown_timeout = Tina4::Shutdown.timeout 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 user_config.force_shutdown_after shutdown_timeout user_config.raise_exception_on_sigterm false end Tina4::Log.info("Production server: puma (TINA4_SHUTDOWN_TIMEOUT=#{shutdown_timeout}s)") begin Puma::Launcher.new(config).run ensure Tina4::Shutdown.release_resources end end |
.t(key, **options) ⇒ Object
Translation shortcut
760 761 762 |
# File 'lib/tina4.rb', line 760 def t(key, **) Tina4::Localization.t(key, **) end |
.template_global(key, value) ⇒ Object
Template globals
750 751 752 |
# File 'lib/tina4.rb', line 750 def template_global(key, value) Tina4::Template.add_global(key, value) end |
.trusted_proxy?(address) ⇒ Boolean
Is this address a configured trusted proxy?
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 |
# File 'lib/tina4.rb', line 277 def self.trusted_proxy?(address) networks = trusted_proxy_networks return false if networks.empty? address = normalise_ip(address) return false if address.empty? begin parsed = IPAddr.new(address) rescue IPAddr::Error, ArgumentError return false end # A peer arriving as ::ffff:10.0.0.1 must match an allow-list entry of # 10.0.0.0/8 - dual-stack listeners hand out the mapped form routinely. parsed = parsed.native if parsed.ipv4_mapped? networks.any? { |network| network.include?(parsed) } end |
.trusted_proxy_networks ⇒ Object
The configured trusted-proxy networks, from TINA4_TRUSTED_PROXIES.
Comma-separated exact addresses and/or CIDR ranges, IPv4 and IPv6: "10.0.0.0/8, 192.168.1.5, ::1, fd00::/8". Empty or unset means trust NOTHING, which is the secure default: X-Forwarded-For is then ignored entirely and the raw socket peer identifies the client. See ADR-0019.
249 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 |
# File 'lib/tina4.rb', line 249 def self.trusted_proxy_networks raw = ENV["TINA4_TRUSTED_PROXIES"].to_s cached_raw, cached_networks = @trusted_proxy_cache return cached_networks if raw == cached_raw networks = [] raw.split(",").each do |entry| entry = normalise_ip(entry) next if entry.empty? begin networks << IPAddr.new(entry) rescue IPAddr::Error, ArgumentError # Loud, and exactly once per distinct config value (the cache below # means this parse runs once). A silently-skipped entry would leave a # real proxy untrusted, which looks like the app over-limiting every # client - a very expensive typo to debug. Tina4::Log.error( "TINA4_TRUSTED_PROXIES: ignoring invalid entry '#{entry}' - " \ "expected an IP address or CIDR range, e.g. 10.0.0.0/8 or 192.168.1.5" ) end end @trusted_proxy_cache = [raw, networks.freeze] @trusted_proxy_cache[1] end |
.truthy?(value) ⇒ Boolean
235 236 237 |
# File 'lib/tina4.rb', line 235 def self.truthy?(value) Tina4::Env.is_truthy(value) end |
.warn_deprecated_bind_var(old_name, new_name, value) ⇒ Object
Warn ONCE per variable. Repeated on every call it is noise people filter.
93 94 95 96 97 98 99 100 101 102 |
# File 'lib/tina4.rb', line 93 def self.warn_deprecated_bind_var(old_name, new_name, value) @warned_bind_vars ||= {} return if @warned_bind_vars[old_name] @warned_bind_vars[old_name] = true Tina4::Log.warning( "#{old_name} is deprecated and will be removed in 3.14 - use " \ "#{new_name} instead (using #{value} from #{old_name})" ) end |
.websocket(path, secure: false, &block) ⇒ Object
WebSocket route registration. PUBLIC by default (mirrors GET). Pass secure: true OR chain .secure on the returned route to require a valid JWT on the upgrade.
730 731 732 |
# File 'lib/tina4.rb', line 730 def websocket(path, secure: false, &block) Tina4::Router.websocket(path, secure: secure, &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. "https://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.
40 41 42 43 44 45 46 47 48 49 |
# File 'lib/tina4/websocket.rb', line 40 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 |
.ws_authorized(auth_required, headers, query_string = "", subprotocol = "") ⇒ Object
Per-route WebSocket authentication, checked on the upgrade.
A route is secured when it requires auth (the WebSocketRoute's #auth_required is truthy — set by .secure on the route or by Tina4.secure_websocket). Public routes (the default) always pass. A secured route needs a valid JWT via the Authorization header, the "bearer" subprotocol, or ?token=.
Returns [payload, ok] — the verified token payload (or nil) and whether the upgrade may proceed. Mirrors Python's ws_authorized.
103 104 105 106 107 108 109 110 111 |
# File 'lib/tina4/websocket.rb', line 103 def self.(auth_required, headers, query_string = "", subprotocol = "") return [nil, true] unless auth_required token = ws_token(headers, query_string, subprotocol) return [nil, false] unless token payload = Tina4::Auth.valid_token(token) [payload, !payload.nil?] end |
.ws_bearer_subprotocol_offered?(headers) ⇒ Boolean
Whether the client offered the "bearer" subprotocol — in which case the handshake response must echo "bearer" as the accepted subprotocol (browsers reject a 101 that doesn't echo back a subprotocol they offered).
116 117 118 119 120 121 122 |
# File 'lib/tina4/websocket.rb', line 116 def self.ws_bearer_subprotocol_offered?(headers) headers ||= {} proto = headers["HTTP_SEC_WEBSOCKET_PROTOCOL"] || headers["sec-websocket-protocol"] || headers["Sec-WebSocket-Protocol"] || "" parts = proto.to_s.split(",").map(&:strip).reject(&:empty?) !parts.empty? && parts[0].downcase == "bearer" end |
.ws_token(headers, query_string = "", subprotocol = "") ⇒ Object
Extract a bearer token from a WebSocket upgrade handshake.
Order (mirrors Python's tina4_python.websocket.ws_token):
1. the Authorization: Bearer <jwt> header (server/CLI/mobile clients)
2. the Sec-WebSocket-Protocol subprotocol in the form "bearer, <jwt>"
(the only way a *browser* can pass a token — new WebSocket() cannot set
headers, but it CAN offer subprotocols)
3. a ?token=<jwt> query-string param
Returns the token String, or nil.
headers is a Hash. Lookups are case-insensitive across both the Rack-style
"HTTP_AUTHORIZATION"/"HTTP_SEC_WEBSOCKET_PROTOCOL" keys and plain
"authorization"/"Authorization"/"sec-websocket-protocol" keys so the same
helper serves the rack_app upgrade path and direct callers/tests.
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/tina4/websocket.rb', line 65 def self.ws_token(headers, query_string = "", subprotocol = "") headers ||= {} auth = headers["HTTP_AUTHORIZATION"] || headers["authorization"] || headers["Authorization"] || "" if auth[0, 7].to_s.downcase == "bearer " tok = auth[7..].to_s.strip return tok.empty? ? nil : tok end proto = subprotocol.to_s proto = headers["HTTP_SEC_WEBSOCKET_PROTOCOL"] || headers["sec-websocket-protocol"] || headers["Sec-WebSocket-Protocol"] || "" if proto.empty? parts = proto.to_s.split(",").map(&:strip).reject(&:empty?) if parts.length >= 2 && parts[0].downcase == "bearer" return parts[1].empty? ? nil : parts[1] end qs = query_string.to_s qs = headers["QUERY_STRING"].to_s if qs.empty? unless qs.empty? tok = qs.split("&").each_with_object(nil) do |pair, _acc| k, v = pair.split("=", 2) break v if k == "token" end return tok unless tok.nil? || tok.to_s.empty? end nil end |