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
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

Class Method Summary collapse

Class Attribute Details

.databaseObject (readonly)

Returns the value of attribute database.



121
122
123
# File 'lib/tina4.rb', line 121

def database
  @database
end

.root_dirObject

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.message}")
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.message}")
end

._default_mcp_serverObject

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.message}")
    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: swagger_meta)
  end
end

.background(callback = nil, interval: 1.0, &block) ⇒ Object

Register a periodic background task. Mirrors Python’s tina4_python.core.server.background(fn, interval) and PHP’s $app->background($callback, $interval).

Tina4.background(interval: 5.0) { drain_queue }
Tina4.background(method(:health_check), interval: 30.0)


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_clearObject

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_instanceObject

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_statsObject

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.

Raises:



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_cacheObject

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(**options)
  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 = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
    mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
    DevMessengerProxy.new(mailbox, **options)
  else
    Messenger.new(**options)
  end
end

.databasesObject

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: 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: swagger_meta)
end

.get_framework_frondObject

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_frondObject

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_helpersObject

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
  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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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_portObject

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 options(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: 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: swagger_meta)
end


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 print_banner(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: 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.

Parameters:

  • seed_folder (String) (defaults to: "seeds")

    path to seed files (default: “seeds”)



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: 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: 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: 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: 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: 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.

Examples:

Tina4.seed_batch([
  { orm_class: User, count: 20 },
  { orm_class: Order, count: 100, overrides: { status: "pending" } }
], clear: true)

Parameters:

  • tasks (Array<Hash>)

    each hash has :orm_class, :count, :overrides, :seed

  • clear (Boolean) (defaults to: false)

    delete existing records (reverse-topo order) before seeding

  • strict (Boolean) (defaults to: false)

    re-raise on the first failed row

Returns:

  • (Hash)

    { “ClassName” => SeedSummary }



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.

Parameters:

  • seed_folder (String) (defaults to: "seeds")

    path to seed files (default: “seeds”)



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.message}")
    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.

Parameters:

  • orm_classes (Array<Class>)

    ORM subclasses to seed

  • count (Integer) (defaults to: 10)

    rows per model

  • overrides (Hash) (defaults to: {})

    per-model overrides as { ModelClass => { field: value } } or a single flat hash applied to every model

  • clear (Boolean) (defaults to: false)

    clear each table first (reverse-topo order)

  • seed (Integer, nil) (defaults to: nil)

    PRNG seed (P3) — applied per model

  • strict (Boolean) (defaults to: false)

    re-raise on the first failed row

Returns:

  • (Hash)

    { “ModelName” => SeedSummary } for each model seeded



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.

Examples:

Tina4.seed_orm(User, count: 50)
Tina4.seed_orm(Order, count: 200, overrides: { status: ->(f) { f.choice(%w[pending shipped]) } })

Parameters:

  • orm_class (Class)

    ORM subclass (e.g., User, Product)

  • count (Integer) (defaults to: 10)

    number of records to insert

  • overrides (Hash) (defaults to: {})

    field overrides — static values or lambdas receiving FakeData

  • clear (Boolean) (defaults to: false)

    delete existing records before seeding (P2)

  • seed (Integer, nil) (defaults to: nil)

    random seed for reproducible data (P3)

  • strict (Boolean) (defaults to: false)

    re-raise on the first failed row instead of skipping (P1)

Returns:

  • (SeedSummary)

    {seeded, failed, errors} — also usable as the int count



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.message}")
        raise
      end
      failed += 1
      errors << { row: i, message: e.message }
      Tina4::Log.warning("Seeder: row #{i} failed seeding #{orm_class.name}, skipped: #{e.message}")
    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.

Parameters:

  • table_name (String)

    name of the table

  • columns (Hash, Array)

    { column_name => type_string } OR an array of column descriptor hashes (+{ name:, type: }+) as returned by db.columns. Values may also be callables (or FakeData-receiving lambdas) — parity with the Python field_map.

  • count (Integer) (defaults to: 10)

    number of records to insert

  • overrides (Hash) (defaults to: {})

    static values (or callables) set on every row

  • clear (Boolean) (defaults to: false)

    delete every existing row before seeding (P2)

  • seed (Integer, nil) (defaults to: nil)

    random seed — seeds the FakeData RNG used for any generator that is not an explicit callable (P3 / signature parity)

  • strict (Boolean) (defaults to: false)

    re-raise on the first failed row instead of skipping (P1)

Returns:

  • (SeedSummary)

    {seeded, failed, errors} — also usable as the int count



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.message}")
        raise
      end
      failed += 1
      errors << { row: i, message: e.message }
      Tina4::Log.warning("Seeder: row #{i} failed seeding '#{table_name}', skipped: #{e.message}")
    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, options = {}, &block)
  Tina4::ServiceRunner.register(name, nil, options, &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, **options)
  Tina4::Localization.t(key, **options)
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.

Returns:

  • (Boolean)


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