Chapter 35: Release Notes
v3.13.49 (2026-06-30) - Current tina4-js runtime bundle + reactive-select guidance
Refreshes the bundled tina4-js runtime that every Tina4 app loads from /js/tina4js.min.js. The shipped script-tag bundle had drifted behind npm: it predated persistent signals and the i18n module, because the minified IIFE was never committed to the tina4-js repo, so tina4 install tina4-js downloaded a 404 and fell back to a stale copy. tina4-js 1.4.1 fixes the source of the drift (the bundle is now tracked in git and built in CI), and this release vendors the current bundle so a fresh install serves persistent signals and i18n out of the box. Run tina4 install tina4-js to refresh an existing app immediately.
It also documents a reactive-<select> footgun in the bundled tina4-js skill. A <select> whose options come from a reactive block loses its selection when the options re-render if you bind .value on the select. Bind ?selected on each <option> instead, so every option owns its selected state and survives a re-render. No framework code changed.
v3.13.48 (2026-06-29) - i18n hardening, swagger decorator-stacking fix, and skill env-name corrections
Three threads, all verified against real dependencies, no mocks.
i18n now agrees across all four frameworks. Interpolation is partial and never throws: a {name} token present in the parameters is replaced, and a missing or malformed placeholder ({x.y}, {n:d}, a stray brace) stays literal, so a broken template can no longer crash t(). Leaf-key aliasing is first-wins and never overwrites an explicit flat key, so a real top-level home beats a derived nav.home alias. Non-string locale values render JSON-native: true, false, null. Ruby also stops crashing on a malformed JSON or YAML locale file (it logs the file and skips it), loads a locale lazily on set_locale, and lists available_locales from the files on disk. Breaking: the PHP and Node I18n constructor argument order is now (locale, path) to match the Python master, and Ruby interpolation tokens are now {name}, not Rails-style %{name}. Update each new I18n(...) call to the new order, and change %{name} to {name} in shared locale files.
Swagger decorators stopped dropping metadata (Python). Stacking @description above @tags above @get lost the description with no error: every decorator returned a new wrapper carrying only its own attribute, but @get is innermost and registers the bare handler, so only the decorator touching @get reached the generated spec. The decorators now annotate the handler in place and return the same object, so every field survives in any order. A regression test stacks five decorators through the real router and asserts each one lands in the OpenAPI operation. PHP, Ruby, and Node attach Swagger metadata through a single meta object, so they never had this bug; each gains a lock-in test that proves a combined annotation keeps every field.
The bundled AI skill shipped wrong env names. The skill told assistants to write SECRET=, SWAGGER_TITLE=, and API_KEY= in .env. Tina4 3.12 made the TINA4_ prefix mandatory, and the startup guard refuses to boot on a legacy un-prefixed name, so an app scaffolded from the skill never bound its port. The skill now teaches TINA4_SECRET, TINA4_SWAGGER_TITLE, and TINA4_API_KEY, and the connection-string examples use the schemes the driver registry accepts (postgresql://, sqlite:). The JWT secret reads from TINA4_SECRET only.
No new third-party dependencies.
v3.13.47 (2026-06-25) - Open-issue batch: migration comment splitting, global middleware, SCSS interpolation
Three reported issues, fixed and locked in with tests against the real thing.
Migration statement splitter (#54). A migration whose SQL carried a ; inside a -- ... line comment fragmented into broken pieces, because the runner split on the ; delimiter before it stripped comments. A CREATE TABLE with a trailing -- drop then re-add; old way comment raised "incomplete input" on SQLite. The splitter is now a single-pass, quote- and comment-aware scanner: it strips -- line and /* */ block comments, copies single- and double-quoted string literals verbatim (honouring the ''/"" escape), keeps $$/// stored-procedure blocks intact, and splits on the delimiter only outside all of that. A ; or -- inside a comment or a string literal can no longer split or corrupt a statement. Named regression specs plus an end-to-end migrate against a real temp SQLite database lock it in, with no mocks.
Global middleware lock-in (#55). Middleware registered globally with Tina4::Router.use(...) already ran on every route in Ruby. A lock-in spec now guards the contract so the regression fixed in the Python master this release cannot creep in here.
SCSS #{} interpolation (#116). The SCSS compiler did not support interpolation, so calc(100% - #{$gap}) left the #{...} in the output and corrupted the CSS around it. The compiler now resolves #{ ... } before variable substitution and nesting: a $variable inside the braces resolves to its value and anything else inlines verbatim, so calc(100% - #{$gap}) becomes calc(100% - 20px) and .icon-#{$name} becomes .icon-home. Shipped across all four frameworks for parity.
No new third-party dependencies. Full suite: 3,593 passing.
v3.13.46 (2026-06-24) - MSSQL fetch_one fix + real MySQL/MSSQL batch tests
The MySQL and MSSQL batch-insert specs were placeholders that skipped even when the engines were provisioned. They now run for real against both engines and assert the full batch contract, including a bad row rolling the whole batch back. Making the MSSQL one run exposed a real adapter bug: apply_limit emitted an OFFSET/FETCH page with no ORDER BY, which SQL Server rejects, so any fetch_one of an unordered query (a COUNT or MAX aggregate) raised "Incorrect syntax near '0'". It now adds a no-op ORDER BY when the query has none, matching the Python master. No new third-party dependencies.
v3.13.45 (2026-06-24) - Real-service test hardening
The Valkey and Redis session handlers talk through the redis client gem. The test bundle now installs it alongside the other optional service drivers, so the live Valkey session round-trip - write a value, read it back, destroy it against a real Valkey - runs on every pass with the infrastructure up instead of skipping. Under TINA4_REQUIRE_SERVICES a provisioned service that goes unexercised is a hard failure, so this closes a gap where the session path slipped through untested. The gem stays in the optional group, so a plain install on a machine without it is unchanged. No framework runtime change. No new third-party dependencies.
v3.13.44 (2026-06-24) - Real-service bug-fix sweep (no mocks)
Standing up live infrastructure - PostgreSQL, MongoDB, Redis, Valkey, Memcached, RabbitMQ, Kafka - and running the suites against the real services surfaced a batch of bugs that mock-based and skipped tests had hidden. This release fixes them across the family and makes the no-mock rule absolute: a test that touches a dependency exercises the real service, never a stand-in. Migrations on PostgreSQL/MySQL/MSSQL: the migration runner built its bookkeeping table (tina4_migration) with SQLite-only DDL (id INTEGER PRIMARY KEY AUTOINCREMENT) for every non-Firebird engine, so migrate failed on PostgreSQL with a syntax error and never applied a single file. The tracking-table id is now engine-aware - SERIAL on PostgreSQL, AUTO_INCREMENT on MySQL, IDENTITY on MSSQL, AUTOINCREMENT on SQLite. The existing migration tests only covered SQLite, which is why this shipped; a gated live-PostgreSQL migration spec now guards the engine-aware path. Kafka: dequeue waits for the partition assignment on first subscribe, so a single short poll right after produce no longer returns nothing. MongoDB queue: the backend honours an explicit db when a connection URI is given - it was defaulting to the admin database and writing every job there. ORM: a UUID primary-key insert returns its id. Tests: rdkafka is now an optional development dependency, so the live Kafka path has a real spec. No new third-party runtime dependencies. All four suites pass against live services.
A parity sweep shipped in the same release. The RabbitMQ backend now acknowledges each job: it dequeues with manual ack and acks on a new complete step, so an at-least-once consumer neither drops nor redelivers work. The Redis and Valkey session handlers gained a zero-dependency raw RESP fallback, so sessions work without the optional redis gem (matching Python and Node), and the MongoDB session handler drops and recreates its TTL index instead of failing when the expiry changes. The live Redis, Valkey, and MongoDB session round-trips run by default whenever the service is reachable, no longer pending on an optional gem. The smoke tests were rewritten to assert real behaviour.
MySQL and MSSQL join the provisioned test services (#262). Both engines now run live round-trip tests by default, gated on reachability the same way the other services are. The non-skippable real-service gate fails on a MySQL or MSSQL skip under TINA4_REQUIRE_SERVICES, so a missing engine in CI breaks the build instead of passing quiet. CI gained a MySQL 8 container and a SQL Server 2022 container. Running the suites against these two engines for the first time surfaced adapter bugs that no prior test could reach, and Ruby carried several.
The MySQL driver now connects over TCP. When a port is named it rewrites a localhost host to 127.0.0.1, so the connection no longer falls back to a UNIX socket and reaches the provisioned server. Boolean binding is fixed in the SQLite and MSSQL drivers: a raw boolean now binds as 1/0 at the parameter boundary instead of crashing or stringifying. The insert id now survives. On MySQL the driver captures the id at write time, so a later COMMIT no longer resets it to zero; on MSSQL the driver runs the INSERT and SELECT SCOPE_IDENTITY in one batch, so last_id returns the new IDENTITY value and holds through a later SELECT. The MSSQL row-count probe was already correct here and earned a regression test to keep it that way.
The no-mock rule reached further this release (#250). The messenger SMTP and IMAP tests now talk to a real GreenMail mail server, the WebSocket backplane tests run against a real Redis backplane, and the HTTP-client tests hit a real loopback HTTP server. Every in-test double in those paths is gone. The dev mailbox carried a real crash: it wrote message JSON as raw UTF-8 bytes, so reading a message with an accented name under a US-ASCII locale - a common CI default - raised a decode error and broke the inbox. Ruby now reads and writes those files as UTF-8, and a non-ASCII round-trip regression test guards the path. Python, PHP, and Node were never vulnerable, and each gained the same regression test for parity.
v3.13.43 (2026-06-22) - Queue: MongoDB fail() now dead-letters correctly
Part of a cross-framework queue-lifecycle unification (the active backend owns the whole lifecycle). Ruby already routed the lifecycle to the active backend, and complete acknowledged MongoDB correctly. But the MongoDB fail requeue path reset the job to pending without persisting the incremented attempt count - so every poll saw a stale count, the attempts >= max_retries check never tripped, and a repeatedly-failing job was requeued forever instead of dead-lettering. The requeue now persists the incremented attempts, so fail dead-letters at max_retries. consume(topic) already honoured its argument. Verified end-to-end against a live MongoDB: complete yields no redelivery; topic isolation holds; fail dead-letters at the limit. A lock-in spec asserts the requeue carries the incremented attempt count. Full suite: 3,593 examples, zero failures.
v3.13.42 (2026-06-22) - Swagger configurability for external and public APIs
Closes four gaps that pushed teams to hand-roll their own OpenAPI spec instead of using the built-in generator. Configurable security schemes: the built-in bearerAuth scheme honours TINA4_SWAGGER_BEARER_FORMAT (default JWT; set opaque for sk_live_-style keys), and setting TINA4_SWAGGER_API_KEY_NAME emits an apiKeyAuth scheme (TINA4_SWAGGER_API_KEY_IN is header/query/cookie). Register any scheme - including an oauth2 flow with scopes - programmatically with Tina4::Swagger.add_security_scheme(name, definition) (Tina4::Swagger.reset_registry clears it). Per-route security: a route declares its own requirement through swagger_meta: - security: as a scheme name plus a scopes: array, a {name => [scopes]} map, a list of maps for an OR requirement, or "public" to mark a write route open (emits security: []). A secured route with no explicit declaration falls back to TINA4_SWAGGER_DEFAULT_SCHEME (default bearerAuth). Scopes stay spec-valid: only oauth2/openIdConnect schemes carry them, every other type gets [], so the output validates against 3.0 and 3.1. Path filtering: TINA4_SWAGGER_INCLUDE documents only routes whose path starts with one of its comma-separated prefixes; TINA4_SWAGGER_EXCLUDE drops matching prefixes; framework internals (/swagger, /__dev) are always excluded. OpenAPI 3.1 opt-in: TINA4_SWAGGER_OPENAPI (default 3.0.3) emits 3.1.0 when set to 3.1/3.1.0. Reusable component schemas: register a shared shape with Tina4::Swagger.add_schema(name, schema) and reference it from a route's request_schema: / response_schemas: metadata, extending the ORM-model $ref mechanism to arbitrary schemas. Identical behaviour and tests across all four frameworks. Zero new third-party dependencies. Full suite: 3,576 examples, zero failures.
v3.13.41 (2026-06-22) - Queue reservation/visibility timeout (at-least-once delivery)
A targeted fix for silent job loss in multi-replica / rolling-deploy setups. When a consumer reserved a queue message and then died before acknowledging - a crash, an OOM kill, a Kubernetes pod eviction - the message was stranded forever: never re-delivered, never retried, never dead-lettered. The file backend deleted the job on pop (lost outright); the MongoDB backend flipped the document to processing without advancing available_at and never re-evaluated it. Now a popped job is held as a reservation with available_at = now + visibility_timeout (plus a reserved_at stamp). If the consumer does not acknowledge in time, the next dequeue reclaims the abandoned reservation: it increments attempts and re-enqueues the job, or dead-letters it once it has hit max_retries. A dead consumer can no longer strand a job - standard at-least-once delivery, the contract SQS and RabbitMQ already provide. The window is configurable via TINA4_QUEUE_VISIBILITY_TIMEOUT (default 300 seconds; <= 0 disables the reclaim) or the per-queue visibility_timeout: option; complete/fail/retry clear the reservation. RabbitMQ and Kafka are unchanged - the broker already owns redelivery there. Regression tests lock the behaviour in across all four frameworks (file backend: reclaim after the timeout, no reclaim before it, dead-letter past max_retries, complete/fail clear the reservation, env override, disable-at-zero; MongoDB: dequeue advances available_at and the reclaim requeues or dead-letters). Zero new third-party dependencies. Full suite: 3,560 examples, zero failures.
v3.13.40 (2026-06-22) - MCP security hardening + Swagger/OpenAPI overhaul
A coordinated cross-framework release with two themes: MCP transport security and a full Swagger/OpenAPI sweep. MCP security: the built-in dev MCP server now authorises every request on the raw socket peer rather than a configured host name, closing a remote-reach surface where a debug box bound to 0.0.0.0 exposed the file and database tools to unauthenticated callers. The gate is two layers - a host-independent capability check (TINA4_MCP, else TINA4_DEBUG) and a per-request authorisation: loopback always passes, while a remote caller needs TINA4_MCP_REMOTE=true AND a token matching TINA4_MCP_TOKEN (fallback TINA4_API_KEY), sent as Authorization: Bearer, X-MCP-Token, or X-Api-Key. Every MCP surface returns 404 to a disallowed caller, the SQL tool is read-only (SELECT/WITH, no stacked statements), and the file tools are sandboxed to the project root. Swagger: the production on/off switch is wired for real - set TINA4_SWAGGER_ENABLED=false to disable /swagger and /swagger/openapi.json in any environment, or true to expose them in production (it falls back to TINA4_DEBUG when unset). Secured routes now carry a bearerAuth security requirement, so the documentation no longer presents protected endpoints as public. ORM models become reusable components.schemas referenced by $ref across all four frameworks. The spec is valid where it was not: wildcard and splat routes emit proper {name} path parameters, operationId values are de-duplicated, and WebSocket routes no longer leak an invalid method. Swagger configuration: TINA4_SWAGGER_SERVERS (comma-separated) drives a multi-server block, TINA4_SWAGGER_UI_CDN points the UI assets at a self-hosted mirror for air-gapped use, and the generator adds typed-parameter formats, enums, top-level tags, and multipart request bodies. SqliteDocStore (new): a pymongo-style document store with a zero-config SQLite fallback. get_collection(name) returns a real Mongo collection when a Mongo URI is set (TINA4_MONGO_URI, then TINA4_SESSION_MONGO_URI, then the legacy TINA4_SESSION_MONGO_URL), otherwise a SQLite-backed collection over a local file (TINA4_DOC_STORE_PATH, default data/tina4_docstore.db) - the call sites are identical, only the backend differs. It pushes filters down to JSON1 json_extract (equality, $in, $nin, $gt/$gte/$lt/$lte, $ne, $exists, $regex, $or, $and, dotted nested keys), supports $set/$unset/$inc/replace/upsert with lazy sort/limit/skip/projection cursors, ships a zero-dependency 12-byte ObjectId, and round-trips times and ObjectIds so values stay queryable. Develop against the local store and switch to MongoDB in production by setting one env var. Developer experience: the blank-TINA4_SECRET warning now explains why it fired - the run was not detected as development - and gives both fixes (set TINA4_SECRET, or set TINA4_DEBUG=true to auto-generate one into .env.local); the legacy-env strict check now hints the names may come from a .env baked into a Docker image and points at tina4 env --migrate. Session Mongo env parity: the session and DocStore Mongo URI is canonical as TINA4_SESSION_MONGO_URI across all four frameworks, with TINA4_SESSION_MONGO_URL kept as a back-compat legacy alias (Python and PHP historically read _URL). Tests: new DB contract tests (execute raises on a bad statement, read-after-write, generator monotonicity, transaction bracketing) and a queue isolation contract (a job in one topic never leaks into another, and a queue on a fresh storage path starts empty). Breaking: the Swagger and MCP production gates are now enforced - if you relied on /swagger being reachable in production, set TINA4_SWAGGER_ENABLED=true, and a remote MCP caller now needs TINA4_MCP_REMOTE=true and a token. Zero new third-party dependencies. Full suite: 3,545 examples, zero failures.
v3.13.39 (2026-06-21) - Auto-migration, unified critical log level, fail-loud ORM, per-route WebSocket auth
A cross-framework parity sweep that hardens the data layer and tightens a few safe-by-default behaviours. Migrations: pending migrations can now run on startup, gated by TINA4_AUTO_MIGRATE and off by default so existing apps are untouched. A footgun and clear-bug pass adds per-file transactions, row-existence tracking, numeric-aware ordering (so 10_ sorts after 2_, not after 1_), CREATE TABLE idempotency, a URL-safe // delimiter, and smart/curly-quote normalization in migration SQL before it runs. ORM: save(), create(), the query builder, and the Mongo path now fail loud instead of swallowing errors - save() returns self on success and false with the reason on get_error when a write fails, and the silent fallbacks are gone. Logging: critical is now a first-class top-level severity, a new Tina4::Log.enabled?(level) lets callers skip building an expensive log payload, and logs default to stdout-only in production and container environments to avoid file bloat. WebSockets: a route can require authentication on the upgrade itself, before the socket is established. Security: the /__dev/mcp endpoint enforces its localhost guard and honors TINA4_MCP_REMOTE, and the built-in Api client strips the Authorization header on a cross-origin redirect, fixes a dead verify_ssl setting, and gains opt-in retry with backoff. Env: defaults align to the canonical TINA4_ manifest with a uniform .env.example, plus an autocommit durability fix and the ORM-plural default turned off. Tooling: the tina4 metrics coverage detection now counts full-package-path requires and short (3-character) constants, and the complexity counter no longer over-counts string literals. Breaking: critical is now a top-level severity and the previous strict flag was renamed - update any logging configuration that relied on it. Full suite: 3,462 examples, zero failures.
v3.13.38 (2026-06-19) - Coordinated security & robustness release
A large bundled release closing a cross-framework hardening sweep. WebSockets: the Redis/NATS backplane is now wired for real - local-first delivery, then a published envelope on the shared tina4:ws channel, relayed with an origin guard (no own-echo, no cluster loop) - and rack WS upgrades are now owned by a shared manager, so a broadcast reaches every connection (previously each socket spun an isolated engine). Plus an origin allow-list (TINA4_WS_ALLOWED_ORIGINS), an idle reaper (TINA4_WS_IDLE_TIMEOUT), and SSE hardening (mid-stream error / client disconnect). Sessions: the API-key fast-path is now timing-safe (validate_api_key), the guessable default session secret was removed, and a log-loud-and-degrade backend-failure policy applies (TINA4_SESSION_STRICT to re-raise). GraphQL/WSDL: a SOAP <!DOCTYPE> is rejected before parsing (SOAP 1.1 forbids DTDs - closes the REXML entity-expansion / external-entity surface), a recursion-depth guard (TINA4_GRAPHQL_MAX_DEPTH, default 50) catches deep queries and circular fragments, resolver/SOAP faults are masked in production (full detail only under TINA4_DEBUG), and GraphQL directives (@skip/@include/@auth/@role) are now actually parsed and honored - the parser never populated them before. Tooling: a new tina4 metrics command reports the top-N code-health offenders with --top/--json/--fail-on/--path, and the coverage test-detection is now precise (a real require/defined-constant reference, not a name-substring scan). Minimal dependencies. Full suite: 3,323 passing.
v3.13.37 (2026-06-18) - Dev-admin editor: syntax highlighting now works
The dev-admin file-read endpoint returned {path, content, bytes} with no language field, so the dashboard editor couldn't pick a CodeMirror grammar - nothing highlighted. It now returns a language (canonical extension map matching the Python master, plus no-extension Dockerfile), and the rebuilt editor bundle adds the Ruby/Rust/Go/Java/SCSS grammars - so .rb, .ts, and friends highlight correctly. Dev-mode tooling only. Full suite: 3,154 passing.
v3.13.36 (2026-06-18) - Instant WebSocket dev-reload + dev-admin file browser fix
Dev-reload is now a WebSocket push, matching Python. tina4 serve POSTs /__dev/api/reload; the server re-loads changed route files in-process (rescan_routes!, mtime-tracked, no respawn) and broadcasts {type, file, mtime} over /__dev_reload (held open by a process-wide manager; the upgrade needs a hijack-capable server such as Puma). The injected client is WebSocket-primary and only polls /__dev/api/mtime when the socket is down. Also fixed: the dev-admin file browser returned type instead of is_dir, so folders never rendered in the dashboard tree - /__dev/api/files now returns is_dir, has_children, real per-entry git_status and the repo branch, full parity with Python/PHP. Full suite: 3,149 passing.
v3.13.35 (2026-06-17) - Live MCP endpoint for AI agents
The built-in MCP server is now actually reachable, and its tools actually work. Two bugs: the dev tools were never registered on the default server (so it had zero tools), and route_list called route[:method] (subscript) on Tina4::Route objects that only expose attr-readers (every call raised). Both fixed; DevAdmin.handle_request now mounts /__dev/mcp (JSON-RPC) + /__dev/mcp/sse in debug mode, giving an AI agent (Claude Desktop/Code) live access (DB queries, file I/O, routes, docs) scoped to the running project. 17 new specs; full suite 3,136 examples.
v3.13.34 (2026-06-17) - Cross-framework parity release (no functional change in Ruby)
Version alignment with Python/PHP/Node. Ruby's example app, env handling, and AI dual-port dev mode (main port hot-reloads; port+1000 stable) were already correct - verified live this release (main injects the reload script; port+1000 shows the AI-port badge with no reload). No code change. Full suite: 3,119 passing.
v3.13.33 (2026-06-17) - Queues: priority pop + automatic dead-lettering (⚠ behavioural change)
Behavioural change. job.fail(reason) now re-enqueues (incrementing attempts) until attempts >= max_retries, then dead-letters - a consume loop retries max_retries times automatically. pop/consume are now priority-ordered (was FIFO); new additive retry_backoff:. Bug fixes: consume(id:) no longer raises and pop_by_id now works (implements find_by_id). Only the file backend changed. Queue chapter rewritten to match. Full suite: 3,119 passing.
v3.13.32 (2026-06-17) - Caching: per-query bypass + X-Cache headers (chapter rewritten to match code)
Added a per-query bypass - db.fetch(..., no_cache: true) (also fetch_one/fetch_all) skips lookup + store. Tina4::ResponseCache now sets X-Cache: HIT|MISS + X-Cache-TTL (this fixed a bug where the MISS header silently no-op'd on a real Request). The caching chapter was rewritten to match code (real cache_stats shapes, all seven backends + file fallback, the three cache layers, accurate env/defaults), dropping earlier aspirational claims. Full suite: 3,099 passing.
v3.13.31 (2026-06-17) - Request/response parity with Python/PHP/Node (⚠ breaking: request.body)
Breaking. request.body now returns the parsed body (JSON/form → Hash) like the other three frameworks; the raw string moves to request.body_raw. Apps that read the raw body - webhook signature/HMAC verification, JSON.parse(request.body), SOAP/XML - must switch to request.body_raw. Reading fields via request.body["key"] now works directly. (Framework internals graphql/wsdl were repointed.)
Additive: uploaded files gain a raw-bytes content field with indifferent string/symbol keys (file["content"] / file[:content]), parity with the others - materialised lazily on first access so :tempfile-only handlers never buffer large uploads in memory. response.stream now accepts a positional generator (stream(gen)) in addition to the block form. Full suite: 3,090 passing.
v3.13.30 (2026-06-16) - Cross-framework parity release (no functional change in Ruby)
Version alignment with Python/PHP/Node, which gained typed-route-param coercion this release. Ruby already coerced {id:int} → Integer and {price:float} → Float (it was the reference for the cross-framework fix), so there is no behavioural change here. Verified green against the routing and auth suites. Full suite: 3,079 passing.
v3.13.29 (2026-06-16) - Live API search ranks qualified queries + resolves natural names
Parity with the Python master fix for the api_* live-reflection tools. (Ruby's Frond.add_filter/add_global/add_test are plain methods, already indexed - the metaprogramming gap that hit Python/PHP doesn't apply here.)
- Class-qualified ranking.
api_search("Frond.add_test")now ranksTina4::Frond#add_testfirst - the owning class, fqn segments, and an exactClass.methodmatch are scored, instead of the qualifier being dead weight. - Natural-name lookups.
api_class/api_methodresolve a bare class name (Database) and longer nested paths via a new resolver, not just the exact fqn.
The bundled AI skills now tell assistants to query api_* before guessing. Full suite: 3,079 passing.
v3.13.27 (2026-06-16) - Frond template-engine parity fixes
A 50-case cross-engine audit (every Frond tag, filter, and test rendered through all four frameworks with identical templates) surfaced five places where Ruby's output diverged from the Twig/Jinja standard. All are now fixed to match:
- Expression-parser literal bug -
{{ 'a' ~ 'b' }}and{{ 'Y' if x else 'N' }}came out mangled (the literal detector stripped the outer quotes off the whole expression before concatenation / inline-if ran). The detector now only treats an expression as a single string literal when the opening quote's match is the final character, so concatenation and inline-if of two quoted literals work. {{ x | e }}/nl2brreturn aSafeString, so the auto-escaper no longer double-escapes them;nl2brescapes its input and emits<br />.url_encodepercent-encodes spaces as%20(was form-encoding them as+).
Behavioural note: these change rendered output for the affected cases - correctness fixes toward the documented Twig/Jinja behaviour. TINA4_AUTOCOMMIT=false strict mode remains unsupported in Ruby (unchanged). Full suite: 3,075 examples.
v3.13.26 (2026-06-16) - pooling parity confirmed; standalone writes auto-commit
Ruby already had the correct behaviour, so this release is a parity + test pass rather than a code change. Standalone writes auto-commit on their own connection (the pg, sqlite3, and mysql2 drivers run in autocommit mode by default), pooled connections are independent (PG.connect opens a fresh backend per pool slot), and explicit transactions (start_transaction/commit/rollback) stay atomic. A new regression test asserts a standalone write is visible across pooled connections, bringing Ruby in line with the pooling fix shipped to Python, PHP, and Node.
The cross-framework default is now autocommit on for standalone writes (durable + pool-visible); explicit transactions stay atomic. Note: TINA4_AUTOCOMMIT=false strict manual-commit mode is not supported in Ruby - its drivers don't expose an autocommit toggle - but the autocommit-on default matches the other frameworks.
Verified live on PostgreSQL: standalone write visible from a separate connection, explicit rollback discards, explicit commit persists, pooled standalone writes visible across every round-robin connection. Full suite: 3,071 passing.
v3.13.24 (2026-06-15) - unified cache backends across response, KV, and persistent DB cache
The response/KV cache now supports seven backends, selected by TINA4_CACHE_BACKEND: memory (default), file, redis, valkey, memcached, mongodb, and database. TINA4_CACHE_URL carries the connection string for redis/valkey/memcached/mongodb, or a SQL URL for the database backend (which falls back to TINA4_DATABASE_URL). Credentials can be embedded in the URL (redis://user:pass@host, redis://:pass@host, mongodb://user:pass@host) or supplied via TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD (mirroring TINA4_DATABASE_USERNAME/_PASSWORD); memcached is unauthenticated. The usual TINA4_CACHE_TTL (60), TINA4_CACHE_MAX_ENTRIES (1000), and TINA4_CACHE_DIR (data/cache) still apply.
Graceful fallback: if a configured backend's driver is missing or the service/credentials are unreachable or wrong, the cache logs a warning and falls back to the file backend - a real persistent cache, never a silent no-op.
The persistent DB query cache (TINA4_DB_CACHE=true) now routes through the same backend set via TINA4_DB_CACHE_BACKEND + TINA4_DB_CACHE_URL, so multiple instances share one cache with global write-invalidation. cache_stats now reports a backend field alongside mode.
Full suite: 3,070 examples passing.
v3.13.23 (2026-06-15) - request-scoped DB query cache, on by default
A new request-scoped query cache protects your database from rapid repeat reads. Within a single request, identical SELECTs and ORM reads are deduped automatically - the DB is hit once and subsequent identical reads are served from memory. The cache is cleared at the start of every request (so it never serves stale rows across requests) and flushed on any write (insert/update/delete/execute). For non-request contexts (scripts, workers) a short safety TTL applies.
It is on by default via TINA4_AUTO_CACHING=true (off-switch TINA4_AUTO_CACHING=false); the in-request TTL is TINA4_AUTO_CACHING_TTL (default 5 seconds). The existing TINA4_DB_CACHE (default false) remains the separate persistent cross-request cache (TTL TINA4_DB_CACHE_TTL, default 30s) and is not cleared per request. cache_stats now reports a mode field: "request" (default), "persistent", or "off".
Also fixed: the response-cache default TTL changed 0 → 60 seconds, matching Python, PHP, and Node.
Full suite: 3,049 examples passing.
v3.13.22 (2026-06-15) - session default TTL standardised to 1 hour
The default session lifetime now matches across all four frameworks: 3600 seconds (1 hour). Ruby previously defaulted to 86400s (24 hours). The session cookie Max-Age and the file-handler gc window now use 3600 by default - override via Session.new(env, max_age: ...). PHP and Node already used 3600 and are unchanged.
v3.13.21 (2026-06-15) - docs: render() corrections + version re-sync
Documentation consistency pass - no behavior change. The response.template(...) reference in llms.txt is corrected to response.render(...) - the real method; template is only the route-level binding, not a response method. Version re-synced to 3.13.21 with the other frameworks (this release also carries a Python-side JWT-secret security hardening).
Full suite: 3,040 examples passing.
v3.13.19 (2026-06-15) - return domain objects, construct from JSON, and one database binder
Three ergonomic improvements surfaced by the live side-by-side review of the book's own examples across all four frameworks.
response serializes domain objects
Return an ORM model, an array of models, or a query result straight from a route - Tina4 serializes it to JSON. No more hand-rolled to_h / to_json:
Tina4::Router.get("/api/users") do |request, response|
response.json(User.all) # array of models -> JSON array
endA single model becomes a JSON object; an array of models or a DatabaseResult becomes a JSON array. Plain Hashes, Arrays and Strings behave exactly as before - purely additive.
Construct a model from a JSON object string
Widget.new('{"name": "Alice"}') # JSON object string -> one record
Widget.new(name: "Alice") # still works
Widget.new("name" => "Alice") # still worksPassing an Array to a single-record constructor now raises a clear ArgumentError. To build many records, map over the list.
⚠ Breaking - one database binder: bind_database
The ORM-to-database binder is now Tina4.bind_database (the Tina4.database = db writer is gone; Tina4.database remains as a reader). The default is unchanged - models still auto-bind to TINA4_DATABASE_URL, so apps relying on the .env default need no change.
# Most apps: nothing to do - the .env default is auto-bound.
Tina4.bind_database(Tina4::Database.new("sqlite:///app.db")) # override the default
# Register a NAMED connection and point a model at it:
Tina4.bind_database(
Tina4::Database.new("postgres://.../analytics", username: "u", password: "p"),
name: :analytics
)
class Visit < Tina4::ORM
self.db = :analytics # uses the analytics connection (symbol = named connection)
endTina4.bind_database(db, name: :...) registers a named connection; a model selects it with self.db = :.... A missing named connection raises a clear error.
Migration: replace Tina4.database = db → Tina4.bind_database(db). Reading Tina4.database is unchanged.
Full suite: 3,040 examples passing. Shipped with parity across all four frameworks.
v3.13.18 (2026-06-15) - ORM Model.query + foreign-key wiring fixes
Found by the live side-by-side validation against PostgreSQL.
Model.queryraisedNoMethodError- it calledQueryBuilder.from, but the factory isfrom_table. Fixed:MyModel.query.where(...).getnow works.foreign_key_fieldwith a string / forward reference never wired the has_many side - the deferred registry (apply_fk_registry!) was never invoked (noinheritedhook). Aninheritedhook onTina4::ORMnow applies it as model classes load, soreferences: "Author"(string) wires bothbelongs_toandhas_manyregardless of definition order. (A bare constant used before its class is defined is still plain-Ruby ordering - use the string form to defer.)- Doc: the has_many accessor is the declaring class name (lowercased) +
"s"- the cross-framework convention - overridable withrelated_name:(the CLAUDE.md "tableName" wording was wrong).
Full suite: 3,018 examples, 0 failures.
v3.13.17 (2026-06-15) - PostgreSQL reads return native Ruby types
Found by the live side-by-side validation against PostgreSQL. The pg gem returns every column as a String by default - id as "1", a boolean as "t", timestamps as strings - so a Tina4 app written on SQLite (native types) silently changed behaviour on PostgreSQL, diverging from Python and Node. The PostgreSQL driver now installs PG::BasicTypeMapForResults on the connection, so reads decode by type: integer → Integer, boolean → true/false, float → Float, numeric → BigDecimal, timestamp → Time, date → Date. uuid/json/jsonb stay strings; bytea stays binary.
So db.fetch(...)[0] is now {id: 1, active: true, created: <Time>} instead of all-strings - matching SQLite, Python, and Node.
Full suite: 3,011 examples, 0 failures.
v3.13.16 (2026-06-15) - create_table works on PostgreSQL + DatabaseResult index access
Found by the live documentation-verification pass - running the book's own samples against a real PostgreSQL database. The documented code-first schema path, create_table, was silently broken on PostgreSQL: it emitted SQLite-only DDL (AUTOINCREMENT/DATETIME), PG rejected it, the error was swallowed, and it returned true while creating no table.
Root cause: get_database_type didn't exist
create_table called db.get_database_type to pick engine-appropriate types - but that method was never defined on Database, so the engine was always blank and every column fell back to SQLite DDL. (This also means the v3.13.11 engine-aware BooleanField work had never actually fired on Ruby until now.) get_database_type is now implemented, and create_table is engine-aware:
- datetime →
TIMESTAMPon PostgreSQL/Firebird;DATETIMEon SQLite/MySQL/MSSQL. - boolean → native
BOOLEAN(PostgreSQL/MySQL),BIT(MSSQL),INTEGER(SQLite/Firebird); booleanDEFAULTs engine-aware (TRUE/FALSEvs1/0). - Auto-increment translated per engine (
SERIALon PostgreSQL) viaSQLTranslator. - A failed
CREATEnow returnsfalse(and logs) instead of reporting success.
DatabaseResult index + slice access
result[0] already worked; widened [] to ranges/slices (result[1, 2], result[1..3]) and added to_ary for destructuring - full parity with the documented behaviour.
Verified against PostgreSQL 16: a model with id (auto-increment) + string + boolean + datetime creates, inserts, and round-trips (SERIAL, boolean DEFAULT true, timestamp; WHERE active = TRUE matches). New postgres_create_table_spec (PG-gated). Full suite: 3,010 examples, 0 failures. Shipped with parity across all four frameworks.
v3.13.14 (2026-06-13) - Logs reach stdout in containers + per-request logging + schema-qualified tables (#48)
Cross-framework release (all four). Deployed Docker containers were getting no application logs. Ruby actually did write to stdout by default (TINA4_LOG_OUTPUT=both), but it never set $stdout.sync - and a container's stdout is a non-TTY pipe, which Ruby block-buffers. Logs sat in the buffer until it filled or the process exited, so docker logs looked empty (and a crash lost the tail). A follow-on report - the dev server going silent after startup - surfaced a second gap: requests were never logged to stdout.
Per-request logging - on by default in dev
Every request now logs one line through Tina4::Log (→ stdout), on by default in dev and opt-in for production via TINA4_LOG_REQUESTS:
2026-06-12T10:15:03.221Z [INFO ] GET /api/users -> 200 (12.3ms)rack_app emits it after every request (the dev inspector previously only fed the /__dev UI). Format is identical across all four frameworks: METHOD /path -> STATUS (Nms). Default: on under TINA4_DEBUG, off in production; TINA4_LOG_REQUESTS=true/false overrides. RequestLoggerMiddleware dropped its [RequestLogger] prefix for parity.
What changed (stdout)
$stdout.sync = trueis set inLog.configure(unless output is file-only). Logs now flush to the container's stdout immediately.- Default log level is
INFO(was[TINA4_LOG_ALL]). Surfaces request/startup/warn/error without debug noise. TINA4_LOG_LEVELnow accepts plain names (ERROR,info) in addition to the legacy bracket form ([TINA4_LOG_ERROR]) - so the env value is portable with Python/PHP/Node. Unknown values fall back to INFO.
# In a container, default config:
Tina4::Log.info("worker started")
# pre-v3.13.14: buffered on the non-TTY pipe → docker logs lagged / lost on crash
# v3.13.14: flushed immediately to stdoutWhy it spanned all four
The same logging-in-containers gap showed up in every framework:
| Framework | Pre-v3.13.14 cause | Fix |
|---|---|---|
| Python | not _is_production gate suppressed stdout; default ERROR | stdout always on (flushed); default INFO |
| PHP | $stdout = $development (file-only in prod); no TINA4_LOG_LEVEL read | stdout default on + fflush; reads TINA4_LOG_LEVEL; default INFO |
| Ruby | stdout written but never flushed (block-buffered on non-TTY); default ALL | $stdout.sync = true; default INFO; accepts plain + bracket names |
| Node | !isProduction() gate suppressed console; default DEBUG | console always on; production emits JSON; default INFO |
The Rust tina4 CLI was already correct (inherits child stdio).
Schema-qualified tables (#48) + a PostgreSQL fetch() regression
Issue #48 - "Database Table Does Not Exist" on PostgreSQL. A model whose table lives in a non-default schema (gift_cards.gift_card, MSSQL dbo.widget, MySQL otherdb.table, SQLite ATTACH extra.widget) was invisible to the framework's introspection. table_exists?, tables, and columns hardcoded the default namespace (public) and matched the whole dotted string as one flat name - so plain reads worked, but create_table, migrations, and auto-CRUD were blind to the table and reported it missing.
Each driver now resolves a qualified name through a shared SchemaSplit helper, and Database#table_exists? delegates to the driver when it can answer:
- PostgreSQL -
table_exists?usesto_regclass()(honours schema +search_path);columnsfilters bytable_schema;tableslists every non-system schema and returns non-publictables schema-qualified. - MySQL - schema = database; a qualified name checks that catalog, a bare name defaults to
DATABASE(). - MSSQL - honours
dbo.table; a bare name matches in any schema. - SQLite - honours an ATTACH alias (
extra.widget) for bothtable_exists?andcolumns. - Firebird - N/A (no schemas).
Verified against a live PostgreSQL 16 container: table_exists?('gift_cards.gift_card') → true, tables → ['gift_cards.gift_card', 'gift_cards.transaction'], columns → 12 columns - identical results across all four frameworks.
PHP also fixed a v3.13.12 regression found while cross-checking #48. Its
PostgresAdapterreferencedstripTrailingSemicolons()(added in v3.13.12) and the newsplitSchema()but never mixed inSqlNormalizerTrait- so every PostgreSQLfetch/fetchOne/getColumnsfatalled. It shipped silently because the PostgreSQL test suite skips without a live server. Fixed and pinned by server-free reflection guards.
Tests
- Ruby: 2,999 passed (+23 new - level resolution +
$stdout.sync; request-log gate + dispatch; #48 schema split + SQLite ATTACH introspection) - Family: Python 2,829 · PHP 2,394 · Ruby 2,999 · Node 3,628 - 11,850 total, zero regressions. (PHP also fixed #119, a
cli-serverboot crash, and the PGfetchregression above.)
v3.13.12 (2026-06-11) - SQL safety + implicit ORM binding + fetch_all correctness
Three high-impact fixes that close out long-standing footguns. All three ship with full parity across all four frameworks - Ruby gets the auto-discover wiring as the headline change.
fetch_all actually fetches ALL rows now (no silent 100-row truncation)
Pre-v3.13.12 the convenience method defaulted to limit: 100 and silently truncated. The name says fetch_all - it should fetch them all:
# 150 rows in the table
db.fetch_all("SELECT * FROM rows")
# pre-v3.13.12: returns 100 rows, silently drops the other 50
# v3.13.12: returns all 150 rowsThe new default is limit: nil, which the driver's apply_limit already treats as "no LIMIT injection" - your SQL runs verbatim. To opt back into a cap, pass limit: explicitly:
db.fetch_all("SELECT * FROM events", limit: 500) # capped
db.fetch_all("SELECT * FROM users") # all rowsdb.fetch (the paginated sibling that returns a DatabaseResult with count metadata) keeps its 100-row default - pagination is its job. Only the fetch_all convenience changed.
Breaking change: callers who relied on the silent 100-row cap now get every row. For very large tables, switch to fetch (which paginates with metadata) or pass an explicit limit:.
Trailing ; is now stripped from user SQL in fetch / fetch_one
The framework appends LIMIT n OFFSET m to the user-supplied query (and wraps it in SELECT COUNT(*) FROM (...) AS subq for the count probe). When the user's query already ended with a ;, both rewrites broke:
db.fetch("SELECT * FROM users;")
# pre-v3.13.12: syntax error near "LIMIT" - the appended LIMIT followed a ;
# v3.13.12: works - trailing ; is stripped before LIMIT is appendedThe strip is conservative: only trailing whitespace + semicolons are removed (any number of them, including ;;), nothing inside the statement is touched. Parameters and quoting are unchanged - the existing parameter-binding defense against injection still does all the heavy lifting.
Lives as Tina4::Database.strip_trailing_semicolons(sql) and is called from fetch and fetch_one.
Ruby ORM now auto-discovers TINA4_DATABASE_URL (the binding fix)
This was the Ruby outlier. When TINA4_DATABASE_URL was set in .env but Tina4.bind! had never been called, the model's db accessor returned nil - every save / find / where silently no-op'd. Python, PHP, and Node already discovered the env var on first use; Ruby had the helper (auto_discover_db) defined but never called.
# .env has TINA4_DATABASE_URL=sqlite://./app.db, no explicit Tina4.bind! anywhere
User.find(1)
# pre-v3.13.12: nil (db accessor returned nil, query never ran)
# v3.13.12: #<User id: 1, ...> (auto-discovered on first model access)The db accessor on Tina4::ORM now resolves in this order:
def db
@db || Tina4.database || auto_discover_db
endExplicit Tina4.bind!(db) still takes precedence - use it to bind a second database or override the env-driven default. The behaviour now matches Python's database_url_auto_discover(), PHP's adapter auto-init, and Node's initDatabase() env fallback.
Cross-framework parity
| Fix | Python | PHP | Ruby | Node |
|---|---|---|---|---|
fetch_all/fetchAll returns ALL rows by default | ✓ limit=0 default | ✓ $limit = 0 default | ✓ limit: nil default | ✓ already correct (limit? undefined) |
Strip trailing ; from fetch SQL | ✓ shared helper on DatabaseAdapter | ✓ SqlNormalizerTrait on 5 adapters | ✓ Tina4::Database.strip_trailing_semicolons | ✓ exported stripTrailingSemicolons |
| Implicit ORM binding from env | ✓ already worked | ✓ already worked | ✓ fixed (wired auto_discover_db) | ✓ already worked |
Tests
- Python: 2,811 passed (+24 new)
- PHP: 2,316 passed (+13 new)
- Ruby: 2,980 passed (+18 new)
- Node: 3,612 passed across 95 files (+16 new)
11,719 tests across the family, +71 new for v3.13.12, zero regressions.
v3.13.11 (2026-06-11) - ORM correctness pass
Mirrors Python's ORM correctness pass. Two Ruby-side changes plus regression-pinning tests.
#50.1 - Callable Proc defaults are now resolved per-instance
class GiftCard < Tina4::ORM
integer_field :id, primary_key: true, auto_increment: true
datetime_field :created_at, default: -> { Time.now }
endPre-v3.13.11 the Proc was stored verbatim; on save it reached the driver as the Proc object. Now initialize invokes the Proc per instance, so each row gets a fresh value. Classes are excluded - default: Integer survives verbatim.
BooleanField - engine-aware DDL on PG / MySQL / MSSQL
Tina4::ORM.create_table now picks each engine's native bool type. SQLite and Firebird stay on INTEGER (SQLite has no native bool; Firebird's driver round-trip is uneven). PostgreSQL gets BOOLEAN, MySQL gets BOOLEAN (alias for TINYINT(1)), MSSQL gets BIT.
#50.2 - natural-key INSERT (already correct, now pinned)
Ruby's save() already routes through the @persisted flag - set to false by initialize, true by from_hash and after a successful save - so natural-key INSERT was working correctly all along. Pinned with a regression spec so a future refactor can't silently break it.
PG error-visibility fixes (Python only)
The tina4.request.error event hook, the explicit-txn log gap, the COUNT-probe swallow, and the BooleanField PG cascade are all psycopg2-specific. Ruby's pg gem uses libpq in autocommit mode - the cascade never happens. No Ruby changes needed.
Tests
2,962 examples passing, 7 pending (+10 new - spec/orm_v3_13_11_spec.rb). No regressions.
v3.13.9 (2026-06-10)
Non-destructive AI installer - Tina4::AI.install_selected / install_all no longer clobber the user's CLAUDE.md. They write (or refresh) a marker-bracketed Tina4 skill block and leave the rest of the file alone.
The bug
Pre-v3.13.9 the installer wrote a full developer guide to CLAUDE.md (and to .cursorules / .github/copilot-instructions.md / .windsurfrules / CONVENTIONS.md / .clinerules / AGENTS.md / .antigravity/context.md) on every run, clobbering whatever the user had put there. Comment in the old code: "Always overwrite -- user chose to install" - well, sort of, but they didn't choose to lose their notes.
The fix
A marker-bracketed skill block - HTML comments for .md files, #-prefixed line comments for rule files:
<!-- tina4-skills:start -->
## Tina4 Skills
- **tina4-maintainer** -- Read `.claude/skills/tina4-maintainer/SKILL.md` for framework-level changes.
- **tina4-developer** -- Read `.claude/skills/tina4-developer/SKILL.md` before building features.
- **tina4-js** -- Read `.claude/skills/tina4-js/SKILL.md` for frontend work.
<!-- tina4-skills:end -->Four behaviours:
- Fresh install → write the framework guide plus the skill block.
- Marker refresh (idempotent) → file exists with our markers → replace only the bracketed block.
- One-time migration → file starts with the pre-v3.13.9 framework header → replace the old dump with the new framework guide + skill block.
- Preserve user content → file exists with the user's own content (no markers, no old header) → append the skill block to the end, leave everything else verbatim.
The Ruby implementation also force-encodes UTF-8 on both read and write, so File.read returning ASCII-8BIT no longer trips up the string concatenation with non-ASCII content (em-dashes, ✓ characters in the skill block).
Same algorithm in Python / PHP / Node
Identical four-branch logic, identical marker syntax, identical canonical action verbs in the log output. Skill content stays consistent across the family.
Tests
18 new specs in spec/ai_installer_spec.rb. All four branches plus marker detection, block replacement, idempotency, old-header detection, and rule-file vs markdown-file behaviour.
2,952 examples, 0 failures, 7 pending - no regressions.
What you'll see when you re-install
✓ Migrated (replaced old framework dump in) CLAUDE.md ← first run after upgrade
✓ Refreshed skill block in CLAUDE.md ← every subsequent run
✓ Appended skill block to CLAUDE.md ← user-curated filev3.13.7 (2026-06-10)
Two changes from the 24rent app-platform team (PLATFORM-2159) - one observability hook, one production-safety fix. Both ship across all four frameworks with identical event payload shape.
NEW: tina4.request.error event
When handle_500 catches a route exception, it now emits tina4.request.error before rendering the 500 page. Listeners receive a hash { exception:, request: } and can ship the failure to CloudWatch / Sentry / Slack - even though the framework caught it.
Tina4::Events.on("tina4.request.error") do |payload|
exc = payload[:exception]
req = payload[:request]
Tina4::Log.error("Route error: #{exc.class}: #{exc.message}",
method: req&.method, path: req&.path)
# ...or POST to your centralised logging pipeline
end- Fires for caught route exceptions. Does NOT fire for 404s - those aren't server errors.
- Listener errors are swallowed + warning-logged so a broken listener can't break the 500 render.
- Listeners fire in priority order (higher priority first, matching the existing
Tina4::Events.on(event, priority: N)). - Identical event name + payload across Python / PHP / Node - only the per-language syntax differs.
The framework already logged via Tina4::Log.error before - that line is unchanged.
FIX: Stack trace removed from production 500 body (CWE-209)
Before v3.13.7, an unhandled route exception in Ruby would render "#{error.message}\n#{error.backtrace.first(10).join("\n")}" into the 500 response body - absolute file paths, the top 10 frames, the exception message - regardless of TINA4_DEBUG. That's CWE-209 / OWASP A05: information disclosure.
The framework's own lib/tina4/templates/errors/500.twig now guards the trace block with {% if error_message %}. When TINA4_DEBUG=false, handle_500 passes an empty error_message and the trace block doesn't render. The trace stays in Tina4::Log.error (server-side) and reaches observability via the new event.
When TINA4_DEBUG=true, the rich Tina4::ErrorOverlay page is unchanged.
Tests
Six new specs in spec/router_error_event_spec.rb: event payload shape, dev/prod symmetry, listener priority order, no traceback markers in prod body, request_id still surfaces, listener-error safety.
- 2,934 examples passing, 7 pending (PG container), no regressions.
Background
Reported by DevProx on the 24rent platform - they centralise observability by scraping structured JSON lines from stderr → CloudWatch → a Slack notifier. Route-level exceptions weren't surfacing because the framework caught them silently. The event hook fixes that without forcing any team's logging convention; the trace-leak fix is independently a security concern.
v3.13.6 (2026-06-09)
Two fixes: one Ruby-specific (spec contamination from v3.13.5), one cross-framework polish (driver install hints).
Spec contamination from Frond static-facade - fixed
v3.13.5 introduced Tina4::Frond.add_filter / add_global / add_test as a class-level registry (matching Python / PHP / Node). One side effect: globals set in one spec leaked into specs that expected the missing-variable fallback.
spec/spec_helper.rb now resets the registry between examples:
config.after(:each) do
# ...existing cleanup...
Tina4::Frond.clear_registry if defined?(Tina4::Frond) && Tina4::Frond.respond_to?(:clear_registry)
endNo production code change - only the test harness. Matches the autouse fixture in Python and the clearRegistry() call in Node's i18n-leaf-alias.test.ts.
Better driver install hints (#47)
Driver gems (pg, mysql2, tiny_tds, ruby-odbc, mongo, fb) now raise a multi-line LoadError suggesting both Bundler and bare-gem install:
The 'pg' gem is required for PostgreSQL connections. Install one of:
bundle add pg # if your project uses Bundler
gem install pg # bare driverReplaces the previous bare LoadError (or single-line Install: gem install pg).
#46 - PostgreSQL transaction cascade (no fix needed)
The cascade behaviour that prompted Python's #46 fix is psycopg2-specific. Ruby's pg gem uses libpq in autocommit mode by default - each statement is its own transaction, so a failed query does not poison subsequent ones. Verified.
Tests
2,928 passing, 7 pending (Postgres container).
v3.13.5 (2026-06-05)
Frond static-facade parity across PHP, Ruby, Node.js. Closes the last documented v3 parity gap (tina4-python task #32). Python's Frond.add_filter / add_global / add_test have worked as classmethods since v3.13.0 - now PHP / Ruby / Node match.
What changes
Filters, globals, and tests registered at app-startup persist across new Frond() instances. Every framework now supports the same pattern:
// PHP
\Tina4\Frond::addFilter("money", fn($v) => number_format((float)$v, 2));
\Tina4\Frond::addGlobal("APP_NAME", "My App");
\Tina4\Frond::addTest("positive", fn($v) => $v > 0);# Ruby
Tina4::Frond.add_filter("money") { |v| "%.2f" % v.to_f }
Tina4::Frond.add_global("APP_NAME", "My App")
Tina4::Frond.add_test("positive") { |v| v > 0 }// Node.js
Frond.addFilter("money", (v) => Number(v).toFixed(2));
Frond.addGlobal("APP_NAME", "My App");
Frond.addTest("positive", (v) => Number(v) > 0);# Python - already shipped in v3.13.0
Frond.add_filter("money", lambda v: f"{float(v):.2f}")
Frond.add_global("APP_NAME", "My App")
Frond.add_test("positive", lambda v: v > 0)In every framework, registering at the class level updates a static registry. The next new Frond() drains that registry into its own filter/global/test maps automatically. No need to thread a single Frond instance through the application - register at startup, render everywhere.
Instance form still works
Existing per-instance registration continues to work, and now propagates to the class registry too - so the lifecycle is symmetric:
$frond = new \Tina4\Frond();
$frond->addFilter("currency", $fn);
// Future `new Frond()` instances also see "currency"clearRegistry() for test fixtures
Every framework exposes a class-level method to wipe user-registered filters/globals/tests without touching the built-ins (upper, lower, length, defined, even, ...). Useful in test setup/teardown to prevent state leaks between specs.
\Tina4\Frond::clearRegistry();Tina4::Frond.clear_registryFrond.clearRegistry();Frond.clear_registry()Implementation notes per framework
| Framework | Mechanism |
|---|---|
| Python | _ClassOrInstanceMethod descriptor - one method, dual-callable via __get__ |
| PHP | __call + __callStatic magic-method pair - PHP can't have same-name static and instance methods |
| Ruby | Same-name class method and instance method - Ruby naturally allows this |
| Node.js | TypeScript class supports same-name static foo() and foo() instance methods - distinct lookup spaces |
Test count
| Framework | Before | After | New |
|---|---|---|---|
| Python | 2,741 | 2,741 | 0 (already covered) |
| PHP | 2,858 | 2,871 | +13 |
| Ruby | 2,907 | 2,928 | +21 |
| Node.js | 3,508 | 3,526 | +18 |
| Total | 12,014 | 12,066 | +52 |
Upgrade
Drop-in patch. No breaking changes. Existing instance-form code ($frond->addFilter(...) / frond.add_filter / frond.addFilter) keeps working unchanged. The new static form is purely additive.
v3.13.4 (2026-06-04)
Three middleware/header bug fixes across all four frameworks, plus Python chapter 10 + 18 docs rewrites. Reported in tina4-book#140 and tina4-book#141 by MichaelC8E.
PY-10-02 - @middleware() no longer silently disables auth (SECURITY)
Before: Applying @middleware(...) to a POST/PUT/PATCH/DELETE route silently flipped auth_required = false, removing the framework's built-in Bearer-token gate. A developer adding custom logging or rate-limiting middleware to an admin endpoint would, with no warning, open it to unauthenticated callers.
After: Middleware is purely additive. Write routes stay Bearer-token-gated by default. Use @noauth() to open a write route, @secured() to lock a read route. Same rule across all four frameworks.
This is a behaviour change - if your code relied on the old auto-disable to handle auth in custom middleware, add @noauth() (and have your middleware enforce auth on its own).
PY-10-03 - request.headers is now case-insensitive
Before: request.headers["Content-Type"] returned None/undefined/nil. The dict was lowercase-only; mixed-case lookups silently failed. Six chapter 10 examples (Content-Type, X-API-Key, Authorization, User-Agent) were broken.
After: HTTP headers are case-insensitive per RFC 7230 §3.2. Same is true in every framework:
| Framework | Implementation |
|---|---|
| Python | CaseInsensitiveDict (dict subclass, normalises string keys to lowercase on read/write) |
| PHP | Tina4\CaseInsensitiveArray (ArrayAccess + IteratorAggregate + Countable) |
| Ruby | Tina4::CaseInsensitiveHash < Hash (overrides [], []=, key?, delete, etc.) |
| Node | Proxy wrapper around http.IncomingHttpHeaders |
request.headers.get("Content-Type"), request.headers.get("content-type"), and request.headers.get("CONTENT-TYPE") all return the same value. Existing lowercase code keeps working unchanged.
PY-10-01 - Function-based middleware now runs
Before: Chapter 10 taught Express-style async def mw(req, resp, next_handler) in 8+ examples, but the Python framework's dispatcher only looked for class-based before_*/after_* methods. Function-style middleware was silently inert - body never executed. PHP and Ruby had similar gaps (closures ran but no next continuation).
After: Express-style continuation chain is implemented across the family. Python adds _is_function_middleware() + _invoke_handler_with_middleware(). PHP wraps closures with array_reverse continuation. Ruby uses lambdas + reverse_each. Node already had next() continuation - added a regression test to keep it green.
@middleware(my_mw)
@post("/api/orders")
async def create_order(req, resp):
...
async def my_mw(req, resp, next_handler):
if not authorised(req):
return resp.json({"error": "forbidden"}, 403)
result = await next_handler(req, resp) # continue the chain
return resultFirst-declared middleware is the outermost layer; calling next_handler descends to the next layer (or the route handler if last). Omitting the next_handler call short-circuits the chain.
Python chapter rewrites - book + docs
- Chapter 18 (Testing) - Fixed PY-18-04 (test runner output now shows real pytest output, not the fictional
[PASS] test_additionformat), PY-18-07a (added missingfrom src.orm.Product import Productimport), PY-18-08 (resp.status_code→resp.statusacross 14+ call sites, positional bodyself.post(path, dict)→ keywordself.post(path, json=dict)). - Chapter 10 (Middleware) - Added two callouts: headers are case-insensitive in v3.13.4+;
@middleware()is purely additive (does not change auth_required). Existing mixed-case header examples now work against v3.13.4.
Test count
| Framework | Before | After | New |
|---|---|---|---|
| Python | 2,725 | 2,741 | +16 |
| PHP | 2,844 | 2,858 | +14 |
| Ruby | 2,887 | 2,906 | +19 |
| Node.js | 3,477 | 3,508 | +31 |
| Total | 11,933 | 12,013 | +80 |
Upgrade
PY-10-02 is a behaviour change with a security implication. Audit routes that use @middleware() on POST/PUT/PATCH/DELETE: if you rely on custom middleware to handle auth, add @noauth() above @middleware() (and make sure your middleware enforces auth). Otherwise, no action - your write routes were always supposed to require Bearer tokens.
PY-10-01 and PY-10-03 are purely additive - no breaking changes.
v3.13.3 (2026-06-03)
Two reporter-driven ergonomic additions, shipped across all four frameworks with full parity per feedback_parity.
Env typed env-var helpers (tina4-python#43)
Reading env vars by hand gets old fast: every boolean flag becomes a os.getenv("TINA4_DEBUG", "false").lower() in ("1", "true", "on", "yes") incantation. Every numeric tuning knob needs a try/except around int(). Env centralises it:
from tina4_python import Env
debug = Env.bool("TINA4_DEBUG", default=False)
workers = Env.int("WORKERS", default=4)
rate = Env.float("RATE_LIMIT", default=10.0)
region = Env.str("AWS_REGION", default="us-east-1")Same API across all four frameworks:
- Python -
from tina4_python import Env - PHP -
Tina4\Env::bool / int / float / str - Ruby -
Tina4::Env.bool / int / float / str - Node.js -
import { Env } from "@tina4/core"
Truthy tokens (case-insensitive after strip/trim): 1, true, on, yes, y, t. Falsy: 0, false, off, no, n, f, empty string. Anything else returns the default - never raises. int/float parse failures log a warning via Log and fall back to default.
Function-name in log lines (tina4-python#41)
Opt-in via TINA4_LOG_FUNC=true. When enabled, the calling function name is injected into every log line so a tail -f gives you free context:
2026-06-03T14:22:18.341Z [INFO ] [super_trooper] Hello from inside the functionOr in JSON mode:
{"timestamp":"...","level":"INFO","function":"super_trooper","message":"Hello..."}Default off - zero overhead unless opted in. When on, ~5% per-call cost from the stack walk.
Per-framework implementation:
- Python -
inspect.currentframe()walk past Log's own frames - PHP -
debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)+{closure}filter - Ruby -
caller_locations(2, 16)+ block-noise regex - Node.js -
new Error().stackregex parse + anonymous filter
Anonymous frames (<lambda>, <module>, {closure}, anonymous IIFEs) are filtered as noise - showing [<lambda>] would be uglier than nothing.
Test count
| Framework | Before | After | New |
|---|---|---|---|
| Python | 2,675 | 2,725 | +50 |
| PHP | 2,780 | 2,844 | +64 |
| Ruby | 2,839 | 2,887 | +49 (+1 pre-existing rack_app fail unchanged) |
| Node.js | 3,420 | 3,477 | +57 |
| Total | 11,714 | 11,933 | +220 |
Upgrade
Drop-in patch. No breaking changes. Two new exports (Env, plus one new env var TINA4_LOG_FUNC). Existing logs and existing code keep working unchanged.
v3.13.2 (2026-06-03)
Bug-fix patch - three field reports, fixed with full cross-framework parity audit per feedback_crosscheck_bugs.
SCSS calc() with mixed units (tina4-python#42, tina4-php#116, tina4-nodejs#1)
The SCSS math evaluator silently folded mixed-unit arithmetic by keeping operand 1's unit and dropping operand 2's, producing wrong CSS:
max-height: calc(100vh - 170px)→calc(-70vh)(negative, layout-breaking)width: 100% - 20px→80%(pixel term silently lost)padding: 1rem + 4px→5rem
Fixed in Python, PHP, and Node - the evaluator now captures both operand units, only folds when units match (or one side is unitless for *//), and masks calc(...) ranges so the browser computes them as intended. Ruby unaffected (delegates to libsass).
Router.group docs taught a crashing pattern (tina4-python#44)
The Python book and docs site showed Router.group("/api/v1", lambda: [...]) with a zero-arg lambda. Source intentionally passes a RouteGroup instance to the callback, so users hit TypeError: <lambda>() takes 0 positional arguments but 1 was given. Docs rewritten to lambda group: [group.get(...), group.post(...)] matching the real contract (Node has always taught this correctly; PHP and Ruby use ambient state, no group arg needed).
DATABASE_URL → TINA4_DATABASE_URL drift (tina4-python#45)
Three real bugs:
- Python ORM error message told users to "set DATABASE_URL in .env" - but the v3.12 boot guard rejects that bare name. Users following the error message hit a hard stop.
- Python dev-admin
.envwriter stripped theTINA4_prefix when updating existing rows, actively corrupting the config every time the user saved a new connection through the dashboard. - Node
Database.fromEnv()defaulted to"DATABASE_URL"as the env-var key, missing the project's actual connection. The ORM error message had the same drift.
All fixed. PHP and Ruby audited - already correct in both.
Test count
| Framework | Before | After | New |
|---|---|---|---|
| Python | 2,665 | 2,675 | +10 |
| PHP | 2,774 | 2,780 | +6 |
| Ruby | 2,839 | 2,839 | 0 (parity bump only) |
| Node.js | 3,406 | 3,420 | +14 |
| Total | 11,684 | 11,714 | +30 |
Upgrade
Drop-in patch. No breaking changes. No new public API.
v3.13.1 (2026-06-02)
Cross-framework parity patch. Closes the remaining audit-flagged docs-vs-code gaps that didn't make 3.13.0 - the documentation claimed APIs across PHP / Ruby / Node that only Python had. This release ships those APIs everywhere and rewrites the PHP chapters that referenced fictional symbols.
Convenience parity additions (Groups A / B)
Three highest-impact cross-framework methods every documentation set already claimed existed. PHP / Ruby / Node now match Python:
db.fetchAll(sql, params)/db.fetch_all/$db->fetchAll- returns the records list directly. Symmetric withfetch_one. For the 80% case where you don't need theDatabaseResultmetadata.Database.getConnection(url)/.get_connection/::getConnection- classmethod factory matching SQLAlchemy'sengine.connect(). Falls back to in-memory SQLite when no URL or env resolves.Api(bearerToken=, username=, password=, headers=, verifySsl=)ergonomic kwargs - three setter calls collapse to one constructor. Bearer wins over basic-auth when both are passed.verifySsl=Falseis the positive form ofignoreSsl=true.
Decorator-style GraphQL resolvers across the family
Python @GraphQL.resolve shipped in 3.13.0. This release adds:
- PHP -
GraphQL::resolve("Type", "field", $callable)static method + class-level resolver registry thatnew GraphQL()drains into its schema. - Ruby -
Tina4::GraphQL.resolve("Type", "field") { |root, args, ctx| ... }with block-based registration. - Node.js -
GraphQL.resolve(typeName, fieldName, resolver)matching the cross-framework shape.
All four frameworks now support the FastAPI / Strawberry / Ariadne pattern where resolvers register at module-import time before any GraphQL instance is constructed, and where post-startup registrations land in the active default singleton via setDefault(gql) / Tina4::GraphQL.default_instance = gql.
Class-based service pattern across the family
class FooWorker extends Service { run() { ... } } - chapter 27 / equivalent docs have long taught this pattern. Until 3.13.1, only the runner was real:
- PHP - new
Tina4\Serviceabstract base class +ServiceRunner::registerService($name, $service)static helper. - Ruby - new
Tina4::Serviceclass +Tina4::ServiceRunner.register_service(name, service). - Node.js - new
Tina4Serviceabstract class +ServiceRunner.registerService(name, service). - Python - new
tina4_python.service.Servicebase +ServiceRunner.register_service(name, service)(this release closes the gap; Python had only the function-style runner before).
All four ship run() (abstract), stop(), and should_stop() / shouldStop() helpers backed by an internal flag. Function-style services using bare callables continue to work alongside the new class-based pattern.
PHP chapter rewrites (docs/php/ and book-2-php/)
The 3.13.0 audit found that the PHP testing-chapter disaster was the tip of a larger pattern - multiple PHP chapters taught APIs that didn't exist. 3.13.1 rewrites all seven of them:
- Chapter 15 - Logging - primary surface now
Tina4\Log::info()/warning()/error()instead of the legacyTina4\Debug::message()shim (still works). - Chapter 18 - Testing -
$response->statusCode→$response->statusacross 23 occurrences; CLI section updated (tina4 testruns the suite;vendor/bin/phpunitfor targeted runs). - Chapter 19 - Scaffolding - v2
Tina4\Get::add()/Post::add()/Put::add()/Delete::add()syntax replaced withTina4\Router::get/post/put/delete; fictional->description()chain replaced with real->swagger([...]). - Chapter 22 - GraphQL - chapter's decorator pattern (
GraphQL::resolve("Type", "field", $fn)) now matches real source (built this release). - Chapter 25 - WSDL -
@wsdl_operationdocblock replaced with#[WSDLOperation([...])]PHP attribute; methods now return associative arrays matching the response-shape spec;Router::soap()→Router::any()+ manual(new Service($request))->handle(). - Chapter 27 - ServiceRunner -
new ServiceRunner()+->add()instance API replaced withServiceRunner::registerService()+ServiceRunner::start()static API. TheTina4\Servicebase class the chapter teaches now exists. - Chapter 34 - Deployment - un-prefixed env vars (
SECRET,CORS_ORIGINS,SMTP_USER,JWT_SECRET,API_KEY,SWAGGER_TITLE) replaced withTINA4_-prefixed forms. The v3.12 boot guard rejects the legacy names withexit(2).
Test count
Net new across the family this release:
| Framework | Before | After | New |
|---|---|---|---|
| Python | 2,654 | 2,665 | +11 |
| PHP | 2,749 | 2,774 | +25 |
| Ruby | 2,827 | 2,839 | +12 |
| Node.js | 3,384 | 3,406 | +22 |
| Total | 11,614 | 11,684 | +70 |
Upgrade
Drop-in patch - no breaking changes. Existing source-code patterns from 3.13.0 continue to work; the new methods are additive. Documentation rewrites in chapters 15 / 18 / 19 / 22 / 25 / 27 / 34 redirect copy-paste examples to the real APIs the framework actually ships.
v3.13.0 (2026-06-01)
The docs-vs-code parity release. A cross-framework audit of 381 markdown files surfaced 146 hallucinations, signature drifts, and stale references across Python, PHP, Ruby, Node, and tina4-js. 3.13.0 closes the chapter-18 disaster pattern - where documentation taught a class-based API that didn't exist - by shipping the missing pieces, renaming the misnamed pieces, and rewriting the aspirational chapters.
The headline fire: Test class with HTTP helpers
Every framework's chapter 18 has long shown integration tests like:
class UserApiTest(Test): # tina4_python.test.Test
def test_health(self):
resp = self.get("/health")
assert_equal(resp.status, 200)Until 3.13.0, only Test (the bare assertion base) existed - calling self.get(...) crashed with AttributeError. The HTTP test client lived in a separate TestClient class that the docs never mentioned.
This release mixes TestClient.get / post / put / patch / delete into the Test base across every framework:
- Python -
tina4_python.test.Test(extendsunittest.TestCase, pytest auto-discovers) - PHP -
Tina4\Test(extendsPHPUnit\Framework\TestCase) - Ruby -
Tina4::Test(zero-dep; built-inrun_allrunner) - Node.js -
Tina4Test(zero-dep; built-inTina4Test.runAll()runner)
Plus positional assertions on every framework - assertEqual(actual, expected, message), assertNotEqual, assertTrue, assertFalse, assertNull/assertNullValue, assertNotNull/assertNotNullValue, assertRaises - matching the documented (actual, expected, message) shape.
Auth.valid_token now returns the payload, not a bare bool - BREAKING
The most common silent-fail pattern caught by the audit. Every framework's docs claimed valid_token returned the decoded JWT payload; every framework's source returned bool and forced a second get_payload call.
| Framework | Before | After |
|---|---|---|
| Python | Auth.valid_token(token) → bool | Auth.valid_token(token) → dict | None |
| PHP | Auth::validToken(token) → bool | Auth::validToken(token) → array | null |
| Ruby | Auth.valid_token(token) → Boolean | Auth.valid_token(token) → Hash | nil |
| Node | validToken(token) → boolean | validToken(token) → Record<string, unknown> | null |
Matches PyJWT / firebase-jwt-ruby / firebase/php-jwt / jsonwebtoken conventions. Truthy/falsy contract preserved - existing if (validToken(t)) callers keep working because a non-null object is truthy and null is falsy.
Python-specific groups (mirrored to PHP/Ruby/Node in follow-up patches)
The Python framework is the reference per feedback_python_master. Six groups landed in tina4-python:
- Group A - ergonomic additions:
Database.get_connection(),db.fetch_all(),db.pool,DatabaseResult.columns,Job.error,Queue.produce(delay_until=datetime), module-levelmigrate(db)/rollback(db)/status(db), module-leveli18n.t(), dict-stylesession[key],WebSocketConnection.connection_count. All zero-risk additions - no signatures changed. - Group B - signature expansions:
Api(bearer_token=, username=, password=, headers=, verify_ssl=)kwargs,Model.find(pk)int overload (Active Record convention),@description(summary, detail=, params=, query=),@tags(str | list),@example_response(status_code, data),response.render(template, data, status_code),response.cookie(name, value, options_dict),response(data, headers={}),@get(path, description=, middleware=["ResponseCache:300"])with string-form middleware parser. - Group C - mixins + decorators: the Test HTTP mixin (covered above),
Frond.add_filter / add_global / add_testcallable as classmethod OR instance method via a_ClassOrInstanceMethoddescriptor,@GraphQL.resolve("Type", "field")decorator with class-level registry - chapter 22's pattern now works as documented. - Group D - return-type changes (BREAKING):
Container.reset()now clears singleton cache only (factories survive); newContainer.reset_all()for the old wipe-everything behaviour.queue.dead_letters()returnslist[Job]with.errorpopulated, notlist[dict].Model.where(..., with_count=True)returns(list, int)tuple for pagination UIs. - Group E - renames (BREAKING):
ai.install_all()→ai.install_context(); newai.detect_ai(),ai.detect_ai_names(),ai.status_report().queue.consume(id=)→queue.consume(job_id=).Api.send_request()→Api.send().I18n(locale=, path=)preferred overI18n(locale_dir=, default_locale=)(legacy kept).TINA4_TOKEN_EXPIRES_INpreferred overTINA4_TOKEN_LIMITfor JWT expiry (both honoured; new wins; constructor arg overrides both). - Group F - top-level re-exports + scaffolder:
from tina4_python import Api, WSDL, wsdl_operation, GraphQL, AutoCrud, Messenger, on, emit, once, off, testsnow resolve.Model.select()with no args defaults toSELECT * FROM <table>so the CRUD-list scaffolder template's emitted code actually runs.
PHP-specific: Tina4\Debug shim
Chapter 15 of the PHP logging docs taught Tina4\Debug::message($msg, TINA4_LOG_INFO, [...]). Neither the class nor the constants existed. Real logger is Tina4\Log.
This release ships a Tina4\Debug compatibility shim that forwards to Tina4\Log, plus defines the TINA4_LOG_* level constants - so the chapter's code samples run as-written. For new code, prefer \Tina4\Log::info() etc.
Documentation sweep
Aside from the source-side changes, the audit caught hundreds of stale references in docs site + book + AI skills + CLAUDE.md files. All fixed in this release:
- ~80 occurrences of
from tina4 import→from tina4_python import(the Python package istina4_python, nottina4) from tina4_python.router→from tina4_python.core.routerTINA4_SESSION_HANDLER→TINA4_SESSION_BACKEND(matches the env var the framework actually reads)DATABASE_NAME=→TINA4_DATABASE_URL=(legacy un-prefixed names get rejected by the v3.12 boot guard)@cached(True, max_age=N)→@cached(max_age=N)(bogus first arg)Template.render()→response.render()(Template class doesn't exist; renamed to Frond)Debug.error()→Log.error()in Python (Debug class doesn't exist)Producer/Consumer(removed in v3.2.0) →Queue.push / consumeEmail→Messenger,event.fire / @listener→emit / @on,gqlsingleton →GraphQL()+@GraphQL.resolve- Security fix:
Auth.check_password(hash, password)→(password, hash)in skill ref - the bcrypt comparison was returning False every time due to reversed args (silent-failure auth) request.files['content']is raw bytes - dropbase64.b64decode()from upload examples- Deployment chapter env vars all
TINA4_-prefixed (un-prefixed names brick boot under v3.12 guard)
Aspirational chapters rewritten
Two Python chapters were built on APIs that didn't exist:
- Chapter 22 (GraphQL): rewritten around the new
@GraphQL.resolve("Type", "field")decorator (the FastAPI/Strawberry pattern). The previousgql.schema.add_query("name", {dict})form still works but is no longer the primary documented path. - Chapter 25 (WSDL): rewritten around the real subclass pattern (
class Calculator(WSDL): @wsdl_operation({"Result": int}) def Add(self, ...): ...; Calculator(request).handle()). The previousWSDL(service_name=, namespace=, endpoint=)constructor +handle_wsdl/handle_requestAPI was entirely fictional.
tina4-js doc drift caught
The cross-framework audit's synthesis pass dropped tina4-js findings; the raw agent transcripts had 23 real findings:
- Every import in CLAUDE.md and
docs/js/09-graphql.mdused"tina4-js"(with hyphen). The npm package is namedtina4js. Fixed. pwa({...})was treated as callable; real API ispwa.register({...}).PWAConfig.iconis a single string, not anicons: [...]array.static props = { label: { type: String, default: "..." } }- the{ type, default }wrapper is fictional. Real shape isstatic props = { label: String }.router.navigate('/users/42')-navigateis a top-level export, not a method onrouter.- Chapter 14's
<slot>inside astatic shadow = falsecomponent - slots are a Shadow DOM feature. Chapter 14 contradicted chapter 4. Switched toshadow = true.
Test counts
Net new across the family:
| Framework | Before | After | New |
|---|---|---|---|
| Python | 2,537 | 2,654 | +117 |
| PHP | 2,742 | 2,749 | +20 |
| Ruby | 2,800 | 2,816 | +16 (1 pre-existing unrelated rack_app failure) |
| Node.js | 3,366 | 3,384 | +18 |
| Total | 11,445 | 11,603 | +171 |
Upgrade
Auth.validToken is the breakage to know about - your if Auth::validToken($t) style code keeps working unchanged because non-null arrays are truthy and null is falsy. If you do === true / === false strict comparisons, switch to !== null / === null.
Python: ai.install_all() → ai.install_context(), queue.consume(id=) → consume(job_id=), Api.send_request() → Api.send(), Container.reset() semantic change (use reset_all() for old behaviour).
Everything else is additive - new properties, new kwargs, new convenience methods that match what the docs have promised for years.
v3.12.14 (2026-06-01)
Two independent fixes ship together as 3.12.14. Python - the tina4_python.test class-based xUnit testing surface that the chapter 18 documentation has always promised but never actually existed. Reports came in of developers copy-pasting from tina4_python.test import Test, assert_equal, assert_true straight out of the book and getting ModuleNotFoundError. The fix was to build the module to match the documentation, not the other way around. PHP - :named placeholder translation for the four non-PDO adapters where ORM::save() was silently failing.
Python - tina4_python.test xUnit testing surface
The testing chapter taught a Test base class with positional assertions:
from tina4_python.test import Test, assert_equal, assert_true
class BasicTest(Test):
def test_addition(self):
assert_equal(2 + 2, 4, "Basic addition should work")
def test_string_contains(self):
greeting = "Hello, World!"
assert_true("World" in greeting, "Greeting should contain 'World'")The module did not exist. Every developer who followed the chapter hit an immediate import error. The other surface, tina4_python.Testing with the inline @tests decorator, has always existed - but the two are for different purposes and the docs only documented one of them.
The fix ships the missing module - tina4_python/test/__init__.py - with the Test base class (inherits unittest.TestCase, so pytest discovers any subclass regardless of class-name convention) and 13 positional assertions. The signatures are uniform: (actual, expected, message). The 2-arg legacy (value, message) form keeps working - a type-based dispatch detects which shape the caller used. assert_raises accepts three forms: docs form (callable, exception, message), context-manager form (with assert_raises(X):), and unittest order (exception, callable). Lifecycle hooks come in both flavours - snake_case set_up/tear_down (the Tina4 idiom) and camelCase setUp/tearDown (for users coming from unittest) - without double-calling when a subclass uses either one.
# 13 assertions, all uniform (actual, expected, message)
assert_equal(actual, expected, message="")
assert_not_equal(actual, expected, message="")
assert_true(actual, expected=True, message="")
assert_false(actual, expected=False, message="")
assert_none(actual, expected=None, message="")
assert_not_none(actual, expected="not None", message="")
assert_in(item, container, message="")
assert_not_in(item, container, message="")
assert_is_instance(value, expected_type, message="")
assert_greater(actual, expected, message="")
assert_less(actual, expected, message="")
assert_almost_equal(actual, expected, places=7, message="")
assert_raises(callable, exception_class, message="")51 new tests in tests/test_test_module.py pin the contract: BasicTest from the chapter runs verbatim, every assertion fails when it should and passes when it should, both 2-arg and 3-arg shapes work, snake_case and camelCase lifecycle hooks fire once each (never both). Full Python suite: 2,537 passing.
tina4 test continues to run pytest (subprocess.run([sys.executable, "-m", "pytest", "tests/"] + args)).
Cross-framework parity check (testing)
PHP / Ruby / Node testing chapters already teach native conventions correctly - PHPUnit, RSpec, and node:test respectively. No fake API to fix. The Python-specific gap was that Tina4 had two testing surfaces (tina4_python.Testing for inline @tests decorator, tina4_python.test for class-based suites) and only one of the two existed. The other three frameworks defer to a single native runner each, so the same trap doesn't apply.
PHP - :named placeholder translation across non-PDO adapters
The ORM's save() emits :named placeholders because PDO would accept them. Four of the five PHP database adapters do not use PDO. MySQLAdapter (mysqli), MSSQLAdapter (sqlsrv), FirebirdAdapter (ibase/fbird), and PostgresAdapter (pgsql) all bind positionally. Every INSERT/UPDATE through save() against those four engines failed silently. Reads worked because read paths typically use ? or no params.
A single helper, SqlTranslation::namedToPositional($sql, $params), translates :name to ? and reorders $params to match the SQL order. Wired into the four affected adapters at the top of their prepare/execute paths. The helper skips string literals and SQL comments, so a literal :colon inside a value stays as a value. Duplicate names bind once per occurrence, so WHERE id = :id AND parent_id = :id works as expected.
SQLite3Adapter is untouched. ext-sqlite3 natively accepts :name via SQLite3Stmt::bindValue. The other four did not, and now do.
15 unit tests pin the helper in tests/SqlTranslationNamedToPositionalTest.php: order preservation, duplicate names, quoted strings, line and block comments, unknown placeholders, null values, and the 0-as-value case. Full PHP suite: 2,290 passing.
Cross-framework parity check (:named placeholders)
Python (mysql-connector-python uses %s), Ruby (mysql2 uses ?), and Node (mysql2 uses ?) build their INSERT/UPDATE SQL with positional placeholders from the ORM down. No :named ever emitted. Audited the MySQL adapter and save() path in each before shipping; confirmed clean. PHP-only fix.
Upgrade
Drop in for both Python and PHP. No .env changes, no API changes.
Python users who followed chapter 18 and hit ModuleNotFoundError - bump to 3.12.14, the from tina4_python.test import Test, assert_equal, ... import now resolves. Existing tests written against tina4_python.Testing (the inline @tests decorator) continue to work - that surface was not touched.
PHP users - :named and ? both work, and the framework picks the right form for whichever driver is underneath. Existing ORM save() calls start succeeding on MariaDB/MySQL, PostgreSQL, MSSQL, and Firebird.
Ruby and Node users - no framework change shipped in 3.12.14. Stay on 3.12.13 or bump to 3.12.14 for version alignment. Both are functionally identical.
v3.12.13 (2026-05-29)
Consolidated parity release. PHP ran ahead through two independent patch releases (3.12.11-3.12.12) while Python / Ruby / Node stayed at 3.12.10. This release realigns all four frameworks on 3.12.13 and ships the cross-framework dev-admin parity sweep - five tiers of work that bring PHP, Ruby and Node up to Python's AI-assisted development surface.
Cross-framework dev-admin parity sweep (Tier 1-5)
The Python framework had pulled ahead on a series of dev-admin features driven by real frustration with the AI coder loop ("Applying a small patch went and messed up my whole file", "Says it is creating files but then doesn't", repeated import-error spirals). This release ports the full set to PHP, Ruby, and Node - same intent, language-idiomatic implementations.
Tier 1 - MCP defensive write layer. file_write and file_patch now refuse prose-as-filenames (the LLM occasionally emits ## FILE: I'll implement Step 1 by creating the database migration and the parser used to write a zero-byte file with that sentence as its filename), normalise bare top-level routes/ / orm/ / templates/ / seeds/ / controllers/ / middleware/ paths to their canonical src/<dir>/ form (auto-discovery only scans src/, so a file at templates/foo.twig was dead weight), back up existing files to .tina4/backups/<flat-path>.<ISO-ts>.bak before overwrite, and refuse suspicious truncations (>200B file → <30% size = almost always a truncated LLM response). Every attempt logs to .tina4/agent.log with a structured category (write.ok / write.refused / write.path_normalized / write.import_failed) - the supervisor reads that file on every turn so it sees what broke last time and can self-correct without asking the developer "what's the error?".
Tier 2 - Post-write syntax verification. PHP shells out to php -l, Ruby to ruby -c, Node to node --check (and single-file tsc --noEmit --allowJs --skipLibCheck for .ts). On parse error the tool result gets an import_error field AND a write.import_failed log entry surfaces in the next supervisor turn's failure context. Catches hallucinated framework APIs (CharField doesn't exist in tina4_python.orm.fields - should be StrField; auto_now_add keyword on Field.__init__()) at write time instead of letting them propagate to a runtime 500 the user only discovers by hitting the URL.
Tier 3 - /__dev/api/threads + /__dev/api/chat proxy. The SPA now talks to the Rust supervisor agent the same way regardless of framework. _supervisor_base_url() matches Python's 4-step ladder (TINA4_SUPERVISOR_URL → TINA4_AGENT_PORT → PORT+2000 → 9145). active_file rides through /chat POST verbatim so deictic phrases ("fix this", "explain this") bind to the editor's open file without the supervisor asking. The Node port forwards SSE chunks as they arrive; PHP and Ruby buffer (functional - EventSource parses fine - but feels less snappy until a future round of Rack/PHP-FPM streaming work).
Tier 4 - Customer feedback widget. A floating bubble for end-users of a shipped Tina4 app, gated by TINA4_ENABLE_FEEDBACK=true AND a non-empty TINA4_FEEDBACK_WHITELIST. The framework's response middleware injects <script src="/__feedback/widget.js" data-tina4-feedback></script> immediately before the LAST </body> tag on text/html responses, ONLY for whitelisted users, NEVER on /__dev or /__feedback paths (no double-bubble UX on the developer dashboard). One conversational turn at a time POSTs to /__feedback/api/turn → server-side identity stamp from the verified JWT (clients cannot fake sender) → forward to the Rust agent's intake-only agent (zero tools, JSON-only output). Finalised tickets land in the dev admin sidebar with kind:"feedback". Rate-limited at 5 turns/hour per user.
Tier 5 - Stale-source overlay badge + list_plans() merge. The error overlay now stamps captured_at on render and tags each stack frame whose source file has been modified since: "FILE MODIFIED @ HH:MM:SS UTC - source may not match what failed". Stops the user from chasing ghosts when the AI coder rewrote the file between the error and the page reload. list_plans() reads from BOTH plan/ (user-curated canonical) AND .tina4/plans/ (AI-planner output), dedupes by filename with plan/ winning on collision, sorts newest-first, and returns a path field so the SPA can open the right file regardless of source dir.
Test counts. Per-framework deltas across the sweep:
| Framework | Before → After (full suite) |
|---|---|
| Python | 2453 → 2453 (canonical - no new tests, just released) |
| PHP | 2235 → 2714 (+479) |
| Ruby | 2747 → 2800 (+53) |
| Node | 3263 → 3368 (+105) |
PHP's larger delta reflects new tests + the 3.12.11 + 3.12.12 lineage rolling forward.
Why all four frameworks at once. Per the cross-framework parity rule: a feature that exists in only one framework is technical debt. The Python-only Tier 1-5 surface had been accumulating for two weeks while the UX was settling. With it settled, this release closes the gap in one coordinated sweep.
Folded-in from PHP 3.12.11 - file upload regression (tina4-book#139)
WebSocket::parseHttpHeaders() previously split the entire raw HTTP request on \r\n and iterated every line for a : to fill the headers map. Multipart body parts have their own Content-Type, Content-Disposition, and Content-Transfer-Encoding headers - those lines matched the parser and overwrote the real request Content-Type: multipart/form-data; boundary=... with whatever the last body part's content type was (typically application/pdf, image/png). Downstream str_contains($contentType, 'multipart/form-data') then failed, the multipart branch was skipped, $parsedFiles was never set, and $request->files came out empty. Every file upload through the stream-socket server was silently lost - the body landed in $request->body as a raw multipart string with no way to parse it.
Fix. Stop the parser at the first \r\n\r\n (RFC 9112 §2.2 boundary between headers and body) before splitting into lines. One logical change in Tina4/WebSocket.php. 9 regression tests in tests/BookIssue139Test.php cover single-part, multi-part, and mixed-header cases.
Cross-framework parity check. Python (http.server), Ruby (webrick/puma), and Node (built-in http module) all delegate header parsing to upstream stdlib HTTP parsers that already split headers from body correctly. PHP was the only framework with a hand-rolled HTTP parser in this code path. No port needed.
Folded-in from PHP 3.12.12 + Python 3.12.13 - v2 tina4_migration auto-upgrade (#115)
Projects upgrading from tina4 ^2.x to ^3.x carried a v2-shaped tina4_migration table that v3's ensureMigrationsTable() left untouched (the CREATE TABLE IF NOT EXISTS short-circuited). The v3 reader then selected columns that didn't exist, fell into the "never seen this migration, run it" branch, and re-applied already-applied migrations - typically failing on duplicate-column / table-already-exists errors when the SQL was non-idempotent. The AirOffices ~190-migration codebase tripped on this in March 2026 and needed a manual SQL backfill at the time.
| Framework | v2 schema | v3 schema |
|---|---|---|
| PHP | migration_id VARCHAR(14), description, content BLOB, passed | id INT PK, migration, batch, applied_at |
| Python | description as identifier, content, passed | migration_id, migration_name, executed_at |
Fix. ensureMigrationsTable() (PHP) and _ensure_tracking_table() (Python) now detect a v2-shaped table (v2 columns present, v3 columns absent) and call an in-place upgrade that ALTERs in the v3 columns alongside the v2 ones, then backfills v3 fields from the v2 data. v2 columns are kept in place so a manual rollback path stays open - they're simply ignored by v3 readers. The match is by file stem: a v2 row's identifier is matched against migrations/ files by basename (Python uses 000001_create_users.sql → stem 000001_create_users → v2 description create_users).
Cross-framework parity check. Ruby and Node never shipped a v2 migration table with the trapping shape - their v2 lineages used a different column layout that v3's tracker tolerated. Nothing to port.
Folded-in from PHP 3.12.11 - request URL parity
$request->url now returns the full absolute URL (https://host:port/path?query) instead of just the path. $request->queryString (raw query bytes) added for parity with request.query_string on the other frameworks. Drop-in - old code that read $request->path (untouched) keeps working.
Upgrade
Drop in. No .env changes, no API changes.
For projects upgrading from v2.x: the v2 tina4_migration auto-upgrade runs once on first boot against v3 - back up your migrations table beforehand if you're paranoid. The upgrade is non-destructive (v2 columns are kept alongside the new v3 ones).
For projects using the dev admin AI coder loop: the new MCP defensive layer will silently rewrite ## FILE: routes/foo.py to src/routes/foo.py and log a write.path_normalized entry. If you were relying on the old behaviour (writes landing wherever the LLM emitted them), this will move some files. Run tail -n 50 .tina4/agent.log | grep path_normalized after upgrading to see what got rewritten.
For shipping apps that want the customer feedback widget: set TINA4_ENABLE_FEEDBACK=true AND TINA4_FEEDBACK_WHITELIST=alice@example.com,bob@example.com in .env. The widget appears only for those users on non-/__dev pages.
v3.12.10 (2026-05-14)
Version-alignment release. PHP ran ahead through three independent patch releases (3.12.7-3.12.9) while Python / Ruby / Node stayed at 3.12.6. This release realigns all four frameworks on 3.12.10 and ships the ORM save() fix.
PHP - ORM->save() no longer swallows write failures (#114)
ORM->save() called update()/insert() but ignored their bool return - it only caught exceptions. The PHP adapter's exec() returns false on a bad statement instead of throwing, so a failed UPDATE (commonly: one referencing a public model property with no matching DB column, since getDbData() includes every public property) slipped through. The empty transaction got committed and save() returned $this - the documented success signal. Callers relying on the save(): static|false contract believed the row persisted when nothing changed. Silent data loss - no exception, no log.
Fix. save() now captures the bool return of update()/insert(), rolls back, and returns false on a falsy result.
$ok = $this->_exists || ... ? $this->update() : $this->insert();
if ($ok === false) { $this->_db->rollback(); return false; }
$this->_db->commit();Cross-framework parity check. Python, Ruby and Node don't have this exact failure mode - they build the write payload from declared fields only (not all public properties), and their DB adapters raise on bad SQL, which the existing try/except already catches. PHP was the outlier on both counts. 3 regression tests in tests/Issue114Test.php; PHP suite 2235 → 2238 passing.
Also in the PHP 3.12.7-3.12.9 patch line
These shipped to PHP between 3.12.6 and this release; folded into the consolidated 3.12.10 line:
- 3.12.7 -
Requestnow normalises caller-provided header keys to lowercase. Some upstream entry points (Apache+PHP-FPM custom mappings, certain proxies, hand-written test fixtures) hand headers in with original case. The constructor only looks them up by lowercase key, so without normalisationmultipart/form-datacontent-type detection silently missed and the body fell through as raw bytes - a follow-up to the #135 fix. - 3.12.8 / 3.12.9 - Router gained RFC 9110 HTTP method conformance: proper
HEADandOPTIONShandling,405 Method Not Allowedwith anAllowheader listing the methods a route does support.
Python / Ruby / Node
Version-only bump 3.12.6 → 3.12.10 to realign with PHP. No behavioural changes in these three since 3.12.6.
Upgrade
Drop in. No .env changes, no API changes. PHP users on 3.12.9 get the save() fix; everyone else gets a version-number realignment.
v3.12.6 (2026-05-06)
Python-only fix release. PHP / Ruby / Node ship the same version stamp for parity but carry no behavioural changes.
Python - psycopg2 % substitution no longer trips PL/pgSQL function bodies (#40)
A migration containing a PL/pgSQL function with literal % characters in a RAISE EXCEPTION (or format()) call used to fail with the misleading:
RuntimeError: Migration failed: list index out of range
The error message gave no hint that the % chars were the problem. The user-facing failure looked like a tina4 internal bug - actually psycopg2's argument-substitution system tripping on the literal percent signs.
Root cause. PostgreSQLAdapter.execute(sql, params) always called cursor.execute(sql, params or []). psycopg2 interprets % as parameter placeholders WHENEVER the params arg is supplied - even an empty list []. So a function body containing RAISE EXCEPTION 'thing % conflicts with %', a, b (perfectly valid PL/pgSQL) blew up because psycopg2 thought % was a placeholder and there were no values to substitute.
Fix. New PostgreSQLAdapter._safe_execute(cursor, sql, params) helper routes empty/None params through cursor.execute(sql) (no second arg), which makes psycopg2 skip the substitution pass entirely. Literal % chars flow through untouched. Applied at every cursor.execute(...) call site in the adapter (5 spots across execute, fetch, fetch_one).
Tests. 5 new unit tests in tests/test_postgres_percent_substitution.py pin the helper's branching. 3 live-Postgres regression tests in tests/test_postgres_plpgsql_percent.py exercise a real CREATE FUNCTION + trigger flow with literal % in the body - skipped automatically when no Postgres is reachable. Full suite: 2453 passing (was 2448).
Cross-framework parity check. PHP (pg_query vs pg_query_params) and Ruby (exec vs exec_params) already branch on params presence so they don't have this bug. Node uses $1 placeholders not %, so the same class of bug doesn't apply.
Long-standing tina4-js #37 confirmed fixed
frond.form.submit not following 3xx redirects - fixed in frond v2.1.2 back on April 11, 2026 (xhr.responseURL comparison + window.location.href navigation). All four framework public/js/frond.min.js copies carry the fix. The original issue stayed open because the reporter never confirmed against the patched build.
Upgrade
Drop in. No .env changes, no API changes.
v3.12.5 (2026-05-06)
PHP-only bug fix release. Python / Ruby / Node ship the same version stamp for parity but carry no behavioural changes.
PHP - multipart bodies with file uploads now parse correctly (#135)
Two stacked bugs in Tina4\Request::__construct made $request->body come through as the raw multipart bytes (~11 KB blobs starting with ------WebKitFormBoundary...) whenever the request included a file upload:
- The constructor called
$this->parseBody()BEFORE initialising$this->files. Inside parseBody's multipart branch, the line$this->files = array_merge($this->files, $parsed['files'])read an uninitialised typed property - fatalError. - After fixing the init order, that same line tried to mutate the
readonly$filesproperty - another fatalError.
Both errors got swallowed by the upstream error handler and the route handler received the raw multipart payload instead of the parsed associative array. Routes that worked fine for ordinary form posts broke the moment a file field came along.
Fix. Move $this->files initialisation AFTER parseBody() runs. parseBody stashes extracted multipart files on a new private mutable $multipartFiles; the constructor merges them into the readonly $files in a single assignment that respects the readonly contract.
4 new regression tests in tests/Issue135Test.php pin the constructor's contract. Full PHP suite: 2235 passing (was 2231).
Upgrade
Drop in. No .env changes, no API changes, no other framework changes.
v3.12.4 (2026-05-06)
Documentation-truth release. The audit-truth.py CI gate (introduced post-3.12.3) flagged 39 env vars referenced in docs that no framework actually read. This release closes that gap: 25 of them now exist in code, the other 14 are deleted from docs (11 hallucinations + 6 clustering vars deferred to tina4#2). Both audit gates (CLI drift + env-var drift) are now strict in CI.
25 new env vars across all 4 frameworks
Server: TINA4_HOST, TINA4_SUPPRESS, TINA4_ENV_FILE. Health: TINA4_HEALTH_PATH (default /__health, with /health kept as a legacy alias), TINA4_TRAILING_SLASH_REDIRECT. Sessions: TINA4_SESSION_HTTPONLY, TINA4_SESSION_NAME, TINA4_SESSION_SECURE. Templates: TINA4_TEMPLATE_CACHE_TTL (0 = permanent). GraphQL: TINA4_GRAPHQL_AUTO_SCHEMA, TINA4_GRAPHQL_ENDPOINT. Mail: TINA4_MAIL_IMAP_ENCRYPTION (tls/starttls/none). MCP: TINA4_MCP, TINA4_MCP_PORT. Swagger: TINA4_SWAGGER_ENABLED, TINA4_SWAGGER_CONTACT_EMAIL, TINA4_SWAGGER_LICENSE. Database: TINA4_DB_POOL (env override on the existing Database(url, pool=N) constructor argument).
Logging - env-driven file output + rotation
Six new vars give you full control over logging without touching code:
| Var | Default | What it does |
|---|---|---|
TINA4_LOG_FILE | (empty - stdout only) | Path to a log file. Empty leaves you on stdout. |
TINA4_LOG_DIR | logs | Directory for log files (joined with _LOG_FILE if relative). |
TINA4_LOG_FORMAT | text | text or json. JSON mode emits one structured record per line. |
TINA4_LOG_OUTPUT | stdout | stdout, file, or both. Strict - stdout means stdout only. |
TINA4_LOG_CRITICAL | false | Enables a Log.critical() level above error. Off = no-op. |
TINA4_LOG_ROTATE_SIZE | 10485760 (10 MB) | Rotate when the file exceeds this many bytes. 0 disables rotation. |
TINA4_LOG_ROTATE_KEEP | 5 | Number of rotated files to retain (app.log.1 ... app.log.N). Older ones are deleted. |
Implementation uses each language's stdlib - Python's logging.handlers.RotatingFileHandler, Ruby's Logger.new(path, shift_age, shift_size), and a roll-your-own atomic-rename pattern in PHP and Node. Zero new dependencies in any framework.
Documentation-truth CI gate now strict on both axes
The audit-truth.py script now blocks merges to main of tina4-documentation whenever a doc references a tina4 <command> or TINA4_* env var that doesn't exist in source. Previously CLI drift was strict; env drift was warn-only. Today both are strict.
Tests added
- Python: +53 tests in
tests/test_env_vars.py(2395 → 2448) - PHP: +59 tests in
tests/EnvVarTest.php(2172 → 2231) - Ruby: +51 examples in
spec/env_vars_spec.rb(2696 → 2747) - Node: +59 tests in
test/envVars.test.ts(3204 → 3263)
Cross-framework total: 10,689 tests passing, +222 from 3.12.3.
Upgrade path
Drop in. No breaking changes - every new env var is opt-in with a sensible default. If you were setting any of the 17 deleted vars in your .env, the boot guard will warn (then ignore) - clean them out at your leisure.
v3.12.3 (2026-05-05)
Cross-framework parity sweep. Two minor breaking changes in the Ruby and PHP public API that bring all four frameworks onto the same shape.
Breaking changes (Ruby + PHP only)
Ruby Container - predicate now uses ? suffix.
# before (3.12.2 and earlier)
Tina4::Container.has(:mailer) # outdated
# after (3.12.3)
Tina4::Container.has?(:mailer) # idiomatic Ruby predicateThis brings Ruby in line with Python (has()), PHP (has()), and Node (has()) while still respecting Ruby's ?-suffix idiom for predicates returning bool. The pre-existing resolve → get rename happened earlier; only the predicate was lagging.
ResponseCache public surface - middleware-only across all four frameworks.
The cache has always been middleware. Two of the four frameworks (PHP, Ruby) historically exposed lookup/store as public methods, which let users couple to internals. The public API is now consistent across all four: use the middleware on a route, and read stats with module-level helpers.
# Ruby - module-level helpers (parity with Python)
Tina4.cache_stats # → { hits:, misses:, size:, backend:, keys: }
Tina4.clear_cache # flush all entries
# PHP - static methods on the class
\Tina4\Middleware\ResponseCache::cacheStats();
\Tina4\Middleware\ResponseCache::clearCache();Internal methods that used to be public (get, lookup, store, cache_response) are now private. Tests that needed them retain access via _internal* test seams marked @internal.
Doc parity - CLAUDE.md and book chapter 33
- CLAUDE.md: every framework's "Key Method Stubs" section now covers the same surface area Python documents - Queue, QueryBuilder, Frond, Api, Background Tasks, ResponseCache, etc. PHP added 4 sections; Ruby added 5; Node added 13.
- Book chapter 33: env var tables are now grounded in source. Each framework's chapter 33 lists every
TINA4_*var its source actually reads. Found and fixed several gaps - Ruby was missingTINA4_CACHE_*,TINA4_QUEUE_*,TINA4_KAFKA_*,TINA4_RABBITMQ_*,TINA4_MONGO_*,TINA4_WS_BACKPLANE, and the entireTINA4_SESSION_VALKEY_*block.
Other fixes
- Ruby
lib/tina4/ai.rb- subprocess output is now force-encoded to UTF-8 beforeString#strip, fixingEncoding::CompatibilityErrorthat crashed 4 ai specs on systems with non-ASCII pip output. - Node
test/serverParity.test.ts- setsTINA4_OVERRIDE_CLIENT=truesostart()actually runs, plus emits theN passed, M failedsummary line the runner expects. The test was effectively a no-op before; now it's recorded properly.
Genuine gaps surfaced by the parity audit (follow-up, not blocking 3.12.3)
The chapter 33 audit flagged env vars Python documents that no other framework actually reads - Ruby/PHP/Node lack TINA4_OPEN_BROWSER, TINA4_DEV_POLL_INTERVAL, TINA4_PUBLIC_DIR, TINA4_TOKEN_EXPIRES_IN alias, plus a few framework-specific gaps (Ruby has no Mongo session backend; Node TINA4_CSRF defaults to false vs Python's true). Tracked for a future patch.
Upgrade path
| Symptom | Fix |
|---|---|
Ruby: NoMethodError: undefined method 'has' for Tina4::Container | Replace has(:key) with has?(:key) |
PHP: BadMethodCallException calling $cache->lookup(...) | Use the middleware: [ResponseCache::class, 'beforeCache'] / [..., 'afterCache']. Or call _internalLookup if you really need direct access (test code only - @internal). |
Ruby: NoMethodError: undefined method 'get' for ResponseCache instance | Use Tina4.cache_stats / Tina4.clear_cache for stats. Lookup goes through the middleware. |
No .env changes from 3.12.2.
v3.12.2 (2026-05-05)
Quality-of-life patch. Two related portability fixes - no breaking changes from 3.12.1.
Firebird URL auto-detect
Firebird is the awkward one in the stack. Every other engine has a server-side database name (postgres://host:port/dbname), but Firebird wants either an absolute file path on the server, a Windows drive-letter path, or an alias. The classic URI form needs a double slash to keep the leading / of an absolute path through the URL parser - unintuitive to anyone used to the way postgres / mysql / mssql encode the database name.
The framework now accepts five equivalent forms and normalises all of them transparently:
| URL path you write | Resolved Firebird identifier |
|---|---|
//abs/path/db.fdb (classic double-slash) | /abs/path/db.fdb |
/abs/path/db.fdb (single-slash, intuitive) | /abs/path/db.fdb |
/C:/Data/db.fdb (Windows drive letter) | C:/Data/db.fdb |
/C%3A/Data/db.fdb (URL-encoded colon) | C:/Data/db.fdb |
/employee (Firebird alias) | employee |
For ops setups that keep server URL and DB location in separate config layers - or for Windows backslash paths that fight URL encoding - set TINA4_DATABASE_FIREBIRD_PATH. The env override wins over whatever path is in the URL.
TINA4_DATABASE_FIREBIRD_PATH=C:\firebird\data\app.fdb
TINA4_DATABASE_URL=firebird://SYSDBA:masterkey@localhost:3050/ignoredShipped to all 4 frameworks. 11 regression tests per framework (8 unit + 3 live).
Bug fix specific to PHP - mysqli localhost+port quirk
PHP's mysqli has a long-standing quirk where host == "localhost" triggers a Unix socket lookup and IGNORES the port argument entirely. Connecting to mysql://...:53306 against a Docker container fails with "No such file or directory" - mysqli is hunting for /tmp/mysql.sock instead of opening a TCP connection. MySQLAdapter::rewriteHostForTcp() now rewrites localhost to 127.0.0.1 when a non-zero port is specified, forcing the TCP code path. Bare mysql:///db (no port) is preserved so existing socket-based setups keep working.
Other fixes
- chore(python):
pyproject.tomlhad drifted to3.10.41while__init__.pyread3.12.1. Synced both to 3.12.2 souv buildand runtime introspection now agree. - chore(claude.md, all 4): stale framework version banners in
CLAUDE.mdheaders updated.
No .env changes from 3.12.1, no migration needed. Existing 3.12.1 installs upgrade by changing one version number.
v3.12.1 (2026-05-04)
CI-only patch - no framework code changes from 3.12.0.
- fix(ci, all 4): every
publish.ymlworkflow now declarespermissions: contents: writeon the publish job. Without this,softprops/action-gh-release403'd against the defaultGITHUB_TOKENon repos whose default Workflow permissions setting was read-only (Ruby and Node hit this every release; PHP and Python worked by luck of repo settings). The explicit declaration makes the workflow self-sufficient. - chore(ci): bumped
softprops/action-gh-releasefrom@v1(unmaintained) to@v2.
No .env changes, no API changes, no migration needed. Existing 3.12.0 installs can upgrade without touching anything else.
The version-bump itself is the test: a successful 3.12.1 release proves the workflow fix works on Ruby and Node where 3.12.0 needed manual gh release create.
v3.12.0 (2026-05-04)
⚠️ Breaking change - read before upgrading. Every framework env var now uses the
TINA4_prefix. Existing.envfiles set withDATABASE_URL,SECRET,SMTP_HOST,HOST_NAME, etc. will cause the framework to refuse to boot. Runtina4 env --migrateto rewrite, or follow the rename table below.
Why this release
Tina4's env vars had grown inconsistent. Some had the TINA4_ prefix (TINA4_DEBUG, TINA4_LOCALE, TINA4_CACHE_BACKEND), others didn't (DATABASE_URL, SECRET, SMTP_HOST). Newcomers had to guess which convention applied to which feature. Existing tools and PaaS dashboards collided with un-prefixed names like SECRET and API_KEY that other libraries also read. Documentation drifted - 91 env-var names appeared in the docs that didn't exist in any framework, and 22 framework-specific env vars in the code didn't match the names users were told to set.
This release closes all three gaps with a single hard rename. No deprecation period, no fallback chain. The framework refuses to boot if it detects a legacy name in the environment, prints a list of every var to rename, and tells you which command to run.
What changed
- 22 env vars renamed to
TINA4_*form. See the migration table below. tina4 env --migrateCLI added to all four frameworks. Reads your.env, rewrites it in place, leaves a.env.bakbackup, prints a diff. Idempotent.- Boot-time guard scans
os.environ(or the language equivalent) for the 22 legacy names. If any are present, prints the rename map and exits with code 2. Bypass withTINA4_ALLOW_LEGACY_ENV=truefor migration scripts that need both names set during transition. - All 4 framework books rewritten. Chapter 33 (Environment Variables) is now a clean canonical list - every var prefixed, descriptions current, legacy names removed.
- Doc-vs-code drift closed. Of the 91 stale env vars previously documented, 61 were renames (corrected), 32 were never implemented (removed). The
audit-links.pyCI gate stays at 0 broken links / 0 broken anchors. - Frond bundle rebuilt at v2.1.3 -
frond.min.jsfooter now shows the version explicitly so users can verify what they have.
Bug fixes shipped alongside the rename
- #38 PostgreSQL UUID-PK transaction abort - the post-INSERT
lastval()probe is now wrapped in a SAVEPOINT, so UUID-PK INSERTs no longer poison the outer transaction withInFailedSqlTransaction. Live regression test against PostgreSQL 16. (Affects all 4 frameworks where the PG adapter does this probe.) - #39 Landing page + template auto-routing:
- Auto-routing now scans
src/templates/pages/only. Partials, layouts, base.twig, errors/, components/, and_*files never auto-serve from a URL. TINA4_TEMPLATE_ROUTING=offkills the feature entirely.src/public/index.htmlauto-serves at/(and/foo/servessrc/public/foo/index.html) - SPA hosting Just Works.- The framework landing page only renders when
TINA4_DEBUG=true. Production never shows it; framework version, dev-admin link, and gallery don't leak to real users. - The malformed
HTTP/1.1 404 OKstatus line is fixed - every status code now uses its canonical RFC 7231/9110 reason phrase.
- Auto-routing now scans
- #37 frond.form.submit redirect handling - verified shipped at v2.1.x;
xhr.responseURLchange triggerswindow.locationnavigation correctly. - #36 Session file handler - re-verified safeguards (lazy save, WebSocket skip, probabilistic GC, new-and-empty skip) all still in place.
Migration - every renamed var
| Legacy name | New name |
|---|---|
DATABASE_URL | TINA4_DATABASE_URL |
DATABASE_USERNAME | TINA4_DATABASE_USERNAME |
DATABASE_PASSWORD | TINA4_DATABASE_PASSWORD |
DB_URL | TINA4_DATABASE_URL (alias dropped) |
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 |
Names that stay un-prefixed (not framework config)
PORT, HOST, NODE_ENV, RACK_ENV, RUBY_ENV, ENVIRONMENT - these are runtime / PaaS conventions, not framework config. Heroku, Railway, Vercel, and friends set them; we keep reading them.
How to upgrade
- Backup your
.env:cp .env .env.bak.pre-v3.12 - Run the migration:
tina4 env --migrate- rewrites your.envin place. - Update PaaS dashboards: Heroku, Railway, Vercel, Render, Fly.io etc - rename the same vars in your provider's env-var UI.
- Restart your app. The boot guard verifies nothing legacy remains.
If your app uses SECRET, DATABASE_URL, or any other listed name in places besides .env (e.g. your CI pipeline's env: blocks), update those too - the boot guard checks os.environ, not just .env.
Parity
All 4 frameworks aligned at 3.12.0:
- tina4-python 3.11.32 → 3.12.0
- tina4-php 3.11.32 → 3.12.0
- tina4-ruby 3.11.32 → 3.12.0
- tina4-nodejs 3.11.32 → 3.12.0
Coordinated release across PyPI, Packagist, RubyGems, npm.
v3.11.32 (2026-04-25)
Critical fix - pool + transactions are now actually atomic. Plus a coordinated parity release that aligns all four frameworks at the same version after months of drift.
Before this release, creating a Database with pool > 0 silently broke transactions. The pool's round-robin checkout rotated to a different adapter on every call - so start_transaction() pinned its flag on adapter A, the executes autocommitted on adapters B and C, and the final commit() / rollback() landed on adapter D, which had nothing to commit. Result: rollback() was a no-op, writes leaked through, and no error or log surfaced the problem.
The fix pins one adapter to the calling context for the lifetime of a transaction. Each language uses its own primitive:
- Python -
threading.local()on theDatabaseinstance - Ruby -
Thread.current[:tina4_pinned_adapter_<obj_id>] - Node.js -
AsyncLocalStoragefromnode:async_hooks(async-safe across overlapping awaits) - PHP - per-instance property (PHP-FPM is one process per request; threading.local is unnecessary)
While pinned, every database call routes to the same adapter. commit() and rollback() release the pin so subsequent calls round-robin again.
- fix (database / all 4): adapter pinning across transaction scope in
Database._get_adapter()(and language equivalents). Every backend is affected - SQLite, PostgreSQL, MySQL, MSSQL, Firebird. Firebird exposed it loudest because of its honest "commit-empty-txn is a real no-op" semantics; the others mostly hid the bug behind eager autocommits but still lost rollback atomicity. - tests (all 4): new regression suite - three INSERTs followed by
rollback()underpool=4now leaves zero rows (was leaking three). Three INSERTs followed bycommit()persists exactly three. Pin-release after commit/rollback verified.pool=0regression test added so single-connection mode stays unaffected. - parity / version alignment: all 4 frameworks bumped to 3.11.32 - closes the cross-framework version drift that had built up (PHP at 3.11.31, Python at 3.11.24, Ruby and Node at 3.11.19). A single coordinated release across all four registries: PyPI, Packagist, RubyGems, npm.
No migration needed. Code using pool=0 (the default for every adapter except where explicitly raised) is unaffected. Code using pool>0 will now actually honour transactions instead of silently dropping them.
If you've been seeing intermittent "writes vanished" or "rollback didn't help" reports on a pooled Database, this release is the cause and the cure.
v3.11.13 (2026-04-16)
Issue-driven release. Everything reported in the open tina4-book issues either was fixed in this version or is already fixed in 3.11.12; this release consolidates the remaining bits and corrects documentation drift.
- feat (router / all 4): Explicit typed-parameter system shared across Python, PHP, Ruby, Node. Adds
alpha,alnum,slug,uuid, and explicitstringtypes in addition to the existingint/integer,float/number,path/.*. Unknown type names now throw at registration -{name:str},{id:inetger}, etc. raise with a clear message listing the valid types instead of silently falling through to the default matcher. Fixes tina4-book#125. +45 new tests across the four suites. - fix (gallery / python+php+ruby): Gallery Try-It / View buttons now open the deployed example in a new tab (
window.open(url, '_blank')) instead of navigating away from the gallery home. Fixes tina4-book#115. - fix (ruby gemspec):
sqlite3promoted fromadd_development_dependencytoadd_dependency. Matches the "zero-config SQLite on first run" promise. Fixes tina4-book#100. - docs (tina4-book): PHP Chapter 2 updated - correct port (7145),
->noAuth()on write-method examples, and an explicit callout explaining the secure-by-default policy for POST/PUT/PATCH/DELETE. Addresses tina4-book#87, #94, #123. - docs (tina4-book): Python
@templatedecorator ordering corrected (must sit BELOW the route decorator) in book chapters 04 and 10; Pythonrequest->queryvsrequest->paramsdistinction in PHP chapter 1. - tests (python): Session-handler tests updated to reflect the real default TTL of 3600s (were stale at 1800s).
- verified already fixed in earlier 3.11.x releases - closed comments posted on all of these:
- #79 dotted numeric index (
{{ items.0.name }}) - #80
truncatefilter - #82
{{ parent() }}/{{ super() }}across all 4 frameworks - #83 Ruby dashboard - WEBrick is runtime dep
- #89
load_dotenvrename,DatabaseResultmethods, SQLite WAL locking - #91 Ruby
request.paramssymbol + string keys viaIndifferentHash - #93 Ruby
/docs/*and bare/*wildcard routes - #97 Frond ternary operator
- parity: All 4 frameworks bumped to 3.11.13.
v3.11.12 (2026-04-16)
Breaking: sqlite:///X URLs are now relative to the project root (cwd), matching the documented convention. For absolute paths use four slashes (sqlite:////abs/path.db) or a Windows drive letter (sqlite:///C:/Users/app.db).
Before this release, TINA4_DATABASE_URL=sqlite:///data/app.db was interpreted differently by every framework. Python/Node/Ruby tried to open /data/app.db (absolute) which crashed on macOS with OSError: [Errno 30] Read-only file system: '/data'. PHP did the same under the hood. All four frameworks now agree: three slashes = relative, four slashes = absolute.
- fix (all 4):
sqlite:///Xresolves under cwd; parent directory auto-created only when inside cwd. Absolute paths are trusted and never mkdir'd at root. - fix (python):
_ensure_foldersno longer creates a bogussrc/migrations/directory. The migration runner always looks atmigrations/at the project root - there is only one correct location. - parity (php, ruby, node): Same
sqlite:///Xparsing as Python. Dedicatedresolve_path/resolveSqlitePathhelpers in each framework so adapters consistently handle:memory:,./forms, Windows drive letters. - tests: 9 new Python tests in
TestSQLiteConnectionPath+TestProjectFolders. 4 new PHP tests inDatabaseUrlTestcovering relative/absolute/Windows/bruce-regression. 6 new Ruby specs indatabase_drivers_spec.rb :: SqliteDriver.resolve_path. Node URL tests expanded indatabase.test.tswith the full relative/absolute/Windows/:memory: matrix. - parity: All 4 frameworks bumped to 3.11.12.
Migration note: If your .env has TINA4_DATABASE_URL=sqlite:///data/app.db, it will now create ./data/app.db in the project root (which is what most users actually want). If you genuinely want an absolute path, change to sqlite:////data/app.db (four slashes).
v3.11.11 (2026-04-16)
- fix (python ORM):
Field.validateno longer re-coerces values that are already the correct type. Previously, any PostgreSQL/MSSQL read of a row containing aDateTimeFieldcrashed becausedatetime(datetime_instance)raisesTypeError. The fix accepts native driver types (datetime,bytes,int,bool,float,str) without re-wrapping, and parses ISO-8601 strings intodatetimefor SQLite. Seetina4-python/plan/orm-field-validate-native-types.md. - fix (python ORM):
BooleanFieldvsIntegerFieldordering handled explicitly.BooleanField(1)still coerces toTrue,IntegerField(True)still coerces to1; no regression for either direction (bool is a subclass of int in Python). - tests (python): 10 new
TestFieldsNativeTypescases covering datetime/int/bool/float/bytes/string/ForeignKey round-trips. - tests (parity): Regression-guard "datetime round-trip on read path" tests added to PHP (
ORMV3Test), Ruby (orm_spec) and Node.js (orm.test.ts) so an equivalent bug can't creep in there later. - parity: All 4 frameworks bumped to 3.11.11.
v3.11.10 (2026-04-15)
- fix (php): Hot-reload loop - DevAdmin's polling fallback used
mt=0as the baseline, so the first poll after every page load triggeredlocation.reload(), which resetmt=0again. Loop now initialises the baseline on the first poll. - fix (php): Reload sentinel removed - PHP was the only framework recursively walking
src/and touchingsrc/.reload_sentinelon every reload POST. The sentinel lived inside the Rust CLI's watched tree and fed back into the watcher, triggering a second loop. Replaced with the same in-memory counter used by Python/Ruby/Node. - fix (php): Polling no longer starts more than once when the WebSocket reconnect retry budget is exhausted (added a
pollStartedguard). - feat (parity):
GET /__dev/api/queue/topicsandGET /__dev/api/queue/dead-lettersadded to PHP, Ruby and Node (previously only in Python). PHP queue endpoints now read from the realTina4\Queuebackend instead of returning stubs. - feat (devadmin): Refreshed
tina4-dev-admin.jsbundle (87.8 KB) across all 4 frameworks - adds the topic selector dropdown, inline payload expand/copy, and corrected version display. - tests: 4-way parity tests for hot-reload: mtime starts at 0, POST /__dev/api/reload bumps the counter, no sentinel file is written to disk, mtime is monotonic across successive reloads. Mirrored in
tina4-php/tests/DevAdminTest.php,tina4-python/tests/test_dev_admin.py,tina4-ruby/spec/dev_admin_spec.rb,tina4-nodejs/test/devAdmin.test.ts. - parity: All 4 frameworks bumped to 3.11.10.
v3.11.9 (2026-04-15)
Catch-up release covering v3.11.0 → v3.11.9 across all 4 frameworks.
- feat (websocket): Full WebSocket parity across Python/PHP/Node/Ruby -
get_client_rooms()/getClientRooms(),route()usable as decorator or direct handler registration, matching room/broadcast semantics, plus new parity tests on all 4. - feat (graphql): Input validation and field-level
@authdirectives with context threading. - feat (graphql): Auto-discovery of schemas; removed legacy DevAdmin HTML/JS in favour of the new UI.
- feat (devadmin - Python): Queue tab with topic selector, dead-letter listing and replay endpoints, inline payload expand/copy, version display.
- feat (cli): Rust CLI now owns file watching - frameworks receive
POST /__dev/api/reloadand internal watchers are disabled when launched by the Rust CLI (--managed). - fix (cli):
parseFlags/parse_flags/parseCliArgsno longer swallowhost:portor positional args after boolean flags. - fix (scss): SCSS recompilation loop fixed; output path corrected to
src/public/css/to match CLI and static serving. - fix (frond - Python): Numeric dotted index for lists (
items.0.name) now resolves correctly. - fix (router - Ruby): Bare
/*wildcard capture exposed under"*"key for parity. - fix (orm - PHP): Three data-sync bugs fixed:
load()double-fill,getPrimaryKeyValue,save()ID sync. - fix (graphql):
from_orm/fromOrmlist resolver usedselect(skip=)instead ofall(offset=). - fix (metrics): Windows backslash paths normalised to forward slashes.
- fix (app - PHP): No longer crashes on notices/deprecations in loaded files;
run()now prints the banner when starting the server directly. - chore: Example demo store ships with the repo; Windows-friendly setup;
.env.exampleand setup scripts added. - parity: All 4 frameworks bumped to 3.11.9. PHP aligned to the 3.x tag scheme on
v3.
v3.10.99 (2026-04-12)
- breaking:
auto_mapnow defaults totrue- ORM models automatically map between camelCase properties and snake_case DB columns. Setself.auto_map = falseon your model class to restore the old behaviour. - feat:
to_h(case:)parameter - passcase: 'camel'to get camelCase keys (for JSON APIs) orcase: 'snake'(default) for snake_case keys matching DB columns. All aliases (to_dict,to_hash,to_assoc,to_object) support the parameter.
- feat: Frond
replacefilter now accepts Hash args -{{ v|replace({"T": " ", "-": "/"}) }}for multiple substitutions in one call. - tests: 6 new parity tests covering
to_h(case:),auto_mapdefault,replacefilter (Hash + positional), andServiceRunnerregistration. 2,519 tests passing. - parity: All features shipped identically across Python, PHP, Ruby, Node.js.
v3.10.97 (2026-04-11)
- fix: frond.form.submit redirect handling - XHR follows 3xx redirects transparently; fixed by detecting
xhr.responseURLmismatch and navigating instead. - dep: Updated frond.min.js to v2.1.2.
- parity: All 4 frameworks bumped to 3.10.97.
v3.10.93 (2026-04-11)
- fix: Frond bracket depth tracking in
find_outside_quotes- expressions likearr[i % 2]no longer treated as top-level arithmetic. - fix: Frond subscript expression evaluation - bracket content uses
eval_expr()instead of simple variable lookup, enablingarr[loop.index0 % 2]. - fix: Frond slice with variable bounds -
items[start:end]evaluates bounds througheval_expr(). - docs: Developer skills updated - Metrics Dashboard guidance, Frond Template Parity rules,
@noauthsecurity warnings. - parity: All Frond fixes applied identically across Python, PHP, Ruby, Node.js. 2,513 tests passing.
v3.10.92 (2026-04-10)
- breaking: Rename
ErrorOverlaymethods -render→render_error_overlay,render_production→render_production_error,debug_mode?→is_debug_mode. - feat: Add
Server.handle(env)for cross-framework parity. - breaking: Rename
WebSocketBackplane.create→WebSocketBackplane.create_backplane. - feat: Add
ScssCompiler.compile,add_import_path,set_variablemethods. - feat: Add
DevAdmin.registermethod. - parity: 44/44 cross-framework features green. 2,487 tests passing.
v3.10.91 (2026-04-10)
- feat: Add parity methods -
Response.sendparams,Middleware.check/is_preflight, AI/Log aliases, MCP optional router. - breaking: Rename
from()→from_table(),error_envelope→error_response, remove aliases.
v3.10.90 (2026-04-09)
- docs: Chapter 4 (Templates) - new "Dumping Values for Debugging" section covering both
{{ x|dump }}and{{ dump(x) }}forms, their shared<pre>value.inspect</pre>output, and theTINA4_DEBUG=trueproduction gate. Filter table entry updated to reference the new section. - docs:
plan/parity/parity-template.mdupdated with a cross-framework dump helper comparison table and marks dump parity as confirmed across all 4 frameworks at v3.10.89. - chore: Version sync release - brings all 4 frameworks to the same patch version (3.10.90) so downstream users can upgrade PHP/Python/Ruby/Node.js in lockstep without hunting version mismatches.
v3.10.89 (2026-04-09)
- feat:
{{ dump(value) }}global function form added to Frond alongside the existing{{ value|dump }}filter. Both call a singleTina4::Frond.render_dumphelper and produce identical output (<pre>value.inspect</pre>HTML-escaped). - security: Dump is now gated on
TINA4_DEBUG=true. In production (env var unset orfalse) both the filter and function silently return an emptySafeString. This prevents accidental leaks of internal state, object shapes, and sensitive values into rendered HTML when a developer leaves a{{ dump(x) }}call in a template. - test: 3 new
spec/frond_spec.rbexamples covering debug-mode output, production silencing, function/filter parity, and function-form production silencing.
v3.10.86 (2026-04-09)
- feat:
foreign_key_fieldDSL auto-wires both sides of a foreign key relationship. Declaringforeign_key_field :user_id, references: Userregisters the integer column, callsbelongs_to :useron the declaring class, and callshas_many :postson the referenced class. Supportsrelated_name:for custom has-many names and deferred wiring via a module-level registry so the referenced class can be defined either before or after the declaring one. - feat: Cross-framework parity - same FK auto-wiring semantics now available in Python (
ForeignKeyField), PHP ($foreignKeys), and Node.js (type: "foreignKey") - docs: Chapter 6 (ORM) updated with a new "foreign_key_field - Auto-Wired Relationships" section
v3.10.85 (2026-04-09)
- Version bump for parity with Python and PHP releases
v3.10.84 (2026-04-09)
- fix: Router/middleware was setting
request.user/request.auth/ auth payload totrue(boolean) instead of the actual JWT payload aftervalid_token?was changed to return bool - any code readingrequest.user["sub"]etc. would have failed silently or crashed - fix: CSRF middleware was not correctly rejecting invalid tokens (nil check on bool result always passed)
- add: Headless routing auth payload integration tests to prevent regression
v3.10.83 (2026-04-08)
- feat: WebSocket rooms -
join_room,leave_room,broadcast_to_room,room_count,get_room_connections - feat: Queue signature parity - instance-scoped
push/pop/retry, no topic params on public methods - feat: Auth cleanup - canonical
getToken/validTokenmethods - Full parity across Python, PHP, Ruby, Node.js
v3.10.70 (2026-04-06)
- New: SSE (Server-Sent Events) support via
response.stream()- pass a generator, framework handles chunked transfer encoding, keep-alive, andtext/event-streamcontent type - New: Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
Tina4 Ruby follows semantic versioning. The major version (3) marks the initial Ruby launch - Tina4 Ruby is new in the v3 line, alongside Tina4 for Node.js. Minor versions (3.1, 3.2, etc.) introduce features and non-breaking API additions. Patch versions carry bug fixes and small improvements.
This chapter covers every v3 release from the initial launch through the current stable line. Each section groups releases by minor version, highlights the changes that affect your code, and shows migration steps for anything that breaks.
v3.10.68 (2026-04-03) - Full Parity Release
- 100% API parity across Python, PHP, Ruby, Node.js - 30+ issues fixed
- ORM: save() returns self/false, arrays not tuples, toDict/toAssoc, scope registers method, where()/all() on Node, count() on PHP
- Auth: expires_in minutes, PBKDF2 260k, env TINA4_SECRET fallback, API key fallback
- Session: dual-mode flash(), get_flash, cookieHeader, getSessionId
- Database: execute() bool/DatabaseResult, get_last_id/get_error, getColumns, cacheStats
- Request/Response: files dict, query, cookies, contentType, xml(), callable
- Queue: consume() poll_interval
- WebSocket: event naming, connection properties
- GraphQL: schema_sdl() + introspect() on all 4
- Events: emitAsync() on all 4
- i18n: zero-dep YAML support
v3.10.67 (2026-04-03)
- load() is now an instance method -
model.load(sql, params)calls select_one internally, populates the instance, returnstrue/false. Usefind(id)for PK lookups - api.upload() added to tina4-js - sends FormData with Bearer token auth for multipart file uploads
- ORM CLAUDE.md rewrite - all method stubs now match actual API signatures
- File upload docs -
request.filesformat documented in CLAUDE.md
v3.10.66 (2026-04-03)
- Metrics file detail fix - clicking bubbles in framework scanning mode now resolves paths correctly via scan root tracking
v3.10.65 (2026-04-03)
- Metrics 3-stage test detection - filename, path, and content matching
- Metrics framework mode - scans framework source with correct relative paths
- tina4 console - interactive REPL with framework loaded
- tina4 env - interactive environment configuration
- Brand - "TINA4 - The Intelligent Native Application 4ramework"
- Quick references - 36 sections, DotEnv API documented
- 37 chapters - 7 new (Events, Localization, Logging, API Client, WSDL/SOAP, DI Container, Service Runner)
- MongoDB + ODBC adapters across all 4 frameworks
- Pagination standardized - limit/offset primary, merged dual-key response
- Port kill-and-take-over on startup
v3.10.60 (2026-04-03)
- tina4 console - already existed, now matches Python/PHP/Node API
- tina4 env - interactive environment configuration
- Brand update - "TINA4 - The Intelligent Native Application 4ramework"
- Imperative relationships - query_has_one/many/belongs_to for ad-hoc queries
- Port kill-and-take-over - default port always reclaimed
- MongoDB adapter (mongo gem), ODBC adapter (ruby-odbc gem)
- Pagination standardized - limit/offset primary, merged dual-key response
- CORS fix - returns empty string when origin not allowed
v3.10.57 (2026-04-02)
- MongoDB adapter -
Database.new("mongodb://host:port/db"), requiresgem install mongo - ODBC adapter -
Database.new("odbc:///DSN=MyDSN"), requiresgem install ruby-odbc - Imperative relationships -
query_has_one/query_has_many/query_belongs_to - Pagination standardized - limit/offset primary, merged dual-key to_paginate response
- Test port at +1000 - user testing port (e.g. 8147) stable, no hot-reload
- CORS fix - returns empty string when origin not allowed
- ORM TINA4_DATABASE_URL discovery - auto-connect from env
- 108 features at 100% parity, 2,333 tests
v3.10.54 (2026-04-02)
- Auto AI dev port - second WEBrick on port+1 with no-reload when TINA4_DEBUG=true
- TINA4_NO_RELOAD env var + --no-reload CLI flag
- CORS fix - returns empty string when origin not allowed (not *)
- ORM TINA4_DATABASE_URL discovery - auto-connect from env
- QueryBuilder docs - added to ORM chapter
v3.10.48 - April 2, 2026
Bug Fixes
Puma requires --production flag - Puma no longer auto-selected when TINA4_DEBUG=false. Use tina4ruby serve --production to enable Puma. Added FakeData (46), Gallery (16), and DevReload (37) tests.
v3.10.46 - April 1, 2026
Test Coverage
344 new tests added across cache (56), ORM (19), Frond (28), database drivers (85), auth (21), SCSS (10), dotenv (30), queue backends (10), migration (10), session handlers (11), router (14), log (13), CSRF middleware (17). Fixed session handler DB key bug (symbol vs string). Ruby now at 2,274 tests with full parity across all 49 core areas.
v3.10.45 - April 1, 2026
Notes
Version bump for parity with PHP CLI serve fix. No Ruby-specific changes.
v3.10.44 - April 1, 2026
New Features
Database tab redesign - Split-screen layout with tables navigation on the left and query editor + results on the right. Click-to-select table highlighting.
Copy CSV / Copy JSON - Copy query results to clipboard in CSV or JSON format.
Paste data - Modal for pasting JSON arrays or CSV/tab-separated data. Auto-generates INSERT statements targeting the selected table, or prompts for a new table name with CREATE TABLE generation. SQL input passes through unchanged.
Multi-statement execution - Query runner handles batched SQL statements in a transaction.
Database badge on load - Table count shows immediately without clicking the Database tab.
Star wiggle animation - Empty star (☆) on the landing page with delayed wiggle animation at random intervals.
Bug Fixes
Default port - Ruby default port set to 7147 (PHP=7145, Python=7146, Ruby=7147, Node=7148).
SQLite LIMIT fix - Prevents double-LIMIT errors in the database browser.
browseTable quote escaping - Fixed table name click handlers.
ORM table name pluralization - Fixed default table name resolution. Table names are now pluralized by default (adding "s" suffix), only skipping when TINA4_ORM_PLURAL_TABLE_NAMES is explicitly set to false.
QueryBuilder closed-connection detection - ensure_db! now checks if the resolved database connection is still open, raising a proper error instead of crashing with ArgumentError: prepare called on a closed database.
Metrics directory validation - quick_metrics and full_analysis now check directory existence before _resolve_root fallback, so missing-directory errors are raised correctly.
Test Coverage
88 new tests added (DevMailbox 40, Static files 18, CLI scaffolding 30), plus 13 v3.10.44 feature specs and 60 pre-existing ORM/metrics bug fixes. 1,913 tests passing, 0 failures.
v3.10.40 - April 1, 2026
Bug Fixes
Dev overlay version check - Fixed misleading "You are up to date" message when running a version ahead of what's published on RubyGems. The overlay now shows a purple "ahead of RubyGems" message. Also added a breaking changes warning (red banner with changelog link) when a major or minor version update is available.
v3.10.39 - April 1, 2026
New Features
Container.singleton(name, &block) - Register a memoized factory. The block is called once on first resolve() and the same instance is returned on all subsequent calls. register() with a block is now always transient (new instance per call), matching Python's behavior.
Tina4::Container.singleton(:db) { Tina4::Database.new(ENV["TINA4_DATABASE_URL"]) }
db1 = Tina4::Container.resolve(:db) # creates instance
db2 = Tina4::Container.resolve(:db) # same instanceRouter.match(method, path) - primary route lookup (replaces find_route; consistent with Python, PHP, Node.js). Router.add(method, path, handler) - primary imperative registration (replaces add_route; all convenience methods delegate to this).
Router.get_routes and Router.list_routes - explicit listing methods (remove ambiguous routes alias).
AI installer - ai_spec.rb and smoke tests updated to reflect the menu-based API (installed?, install_selected, install_all, generate_context).
v3.10.38 -- April 1, 2026
Code Metrics & Bubble Chart
The dev dashboard (/__dev) now includes a Code Metrics tab with a PHPMetrics-style bubble chart visualization. Files appear as animated bubbles sized by LOC and colored by maintainability index. Click any bubble to drill down into per-function cyclomatic complexity.
The metrics engine uses Ripper (Ruby stdlib) for zero-dependency static analysis covering cyclomatic complexity, Halstead volume, maintainability index, coupling, and violation detection. File analysis is sorted worst-first. Results are cached for 60 seconds.
AI Context Installer
tina4ruby ai now presents a simple numbered menu instead of auto-detection. Select tools by number, comma-separated or all. Already-installed tools show green. Generated context includes the full skills table.
Dashboard Improvements
Full-width layout, sticky header/tabs, full-screen overlay.
Cleanup
Removed demo/ directory. Removed old plan/ spec documents, replaced with PARITY.md and TESTS.md. Central parity matrix added to tina4-book.
v3.10.x -- Previous Releases
Released: March 28 -- 30, 2026
The v3.10 line is the most active release series. It delivered Auto-CRUD, ORM transaction safety, Frond template engine hardening, and full cross-language parity with the Python, PHP, and Node.js implementations.
v3.10.29 -- Version Parity (March 30)
Version parity release. All four Tina4 frameworks now share the same version number and feature set.
v3.10.27 -- Frond Macro HTML Escaping Fix (March 30)
Bug fix: Macro output was HTML-escaped when used inside {{ }} expressions. Characters like <, >, and " rendered as <, >, &quot; instead of raw HTML. Nested macro calls double-escaped.
# BEFORE (broken): macro output escaped
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# Template: {{ my_macro() }}
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# Rendered: <div class="card">...</div>
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# AFTER (fixed): macro output treated as safe HTML
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# Template: {{ my_macro() }}
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# Rendered: <div class="card">...</div>
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.jsv3.10.25 -- ORM Transaction Fix (March 30)
Bug fix: ORM save and delete called commit without an active transaction on SQLite. This raised cannot commit -- no transaction is active errors.
# BEFORE (broken):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
user = User.new(name: "Alice")
user.save # => RuntimeError: cannot commit
# AFTER (fixed): save/delete wrap operations in a transaction block
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
user = User.new(name: "Alice")
user.save # => works on all database enginesv3.10.22 -- Unique Form Tokens (March 30)
Form tokens now include a nonce in the JWT payload. Each token is unique per form render, which prevents replay attacks.
# In your Frond template:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
<input type="hidden" name="formToken" value="{{ formTokenValue() }}">v3.10.18 -- Frond Ternary Parser Fix (March 29)
Bug fix: The Frond template ternary/inline-if parser failed on quoted strings containing special characters.
# BEFORE (broken):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# {{ status == "active" ? "Yes" : "No" }} => parse error
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# AFTER (fixed):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# {{ status == "active" ? "Yes" : "No" }} => "Yes"
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.jsv3.10.16 -- Template Filters: to_json, js_escape (March 28)
Three new Frond template filters for working with data in JavaScript contexts.
# Convert a Ruby hash to JSON inside a template:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
<script>
const data = {{ user|to_json }};
const name = "{{ user.name|js_escape }}";
</script>v3.10.15 -- Replace Filter Backslash Fix (March 28)
Bug fix: The |replace filter mishandled backslash characters in replacement strings.
{# Before (broken) - backslash produced corrupted output #}
{{ "hello\\world"|replace("\\\\", "/") }}
{# rendered: helo/world (ate a character) #}
{# After (fixed) - backslash escaping works correctly #}
{{ "hello\\world"|replace("\\\\", "/") }}
{# renders: hello/world #}v3.10.14 -- get_next_id() (March 28)
Pre-generate the next primary key before inserting a record. The method detects your database engine and uses the correct sequence or auto-increment mechanism.
next_id = User.get_next_id
user = User.new(id: next_id, name: "Alice")
user.savev3.10.13 -- ORM Auto-Commit on Write (March 28)
Write operations (save, delete) now auto-commit by default. No more forgotten commit calls leaving data uncommitted.
v3.10.12 -- Session GC and NATS Backplane (March 28)
- Session garbage collection runs on a configurable interval
- NATS added as a WebSocket backplane option alongside Redis
v3.10.11 -- Frond Variable Key Access (March 28)
Bug fix: Accessing a hash value with a variable key (dict[variable_key]) failed in Frond templates.
# BEFORE (broken):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# {% set key = "name" %}
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# {{ user[key] }} => empty
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# AFTER (fixed):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# {% set key = "name" %}
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# {{ user[key] }} => "Alice"
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.jsv3.10.10 -- Firebird Migration Runner Fixes (March 28)
Firebird migrations now use generators and VARCHAR instead of AUTOINCREMENT and TEXT. The migration tracking table uses a proper Firebird sequence (GEN_TINA4_MIGRATION_ID).
v3.10.6 -- WSDL/SOAP Rewrite (March 28)
Complete rewrite of the WSDL/SOAP module. Frond templates now support dotted function names in expressions.
v3.10.5 -- Frond Quote-Aware Operator Matching (March 28)
Bug fix: Operators inside quoted strings were incorrectly parsed as expression operators. The Frond engine now respects quote boundaries.
v3.10.4 -- Auto-CRUD REST Endpoint Generator (March 28)
Generate a complete CRUD interface from a single method call. The generator creates searchable, sortable, paginated HTML tables with create/edit/delete modals, plus REST API routes for POST, PUT, and DELETE.
Tina4::Router.get("/admin/users") do |request, response|
Tina4::CRUD.to_crud(request, model: User, fields: [:name, :email, :role])
endv3.10.2 -- Frond Hash Method Calls (March 28)
Frond templates can now call methods on Hash and object values inside expressions.
v3.10.1 -- autoMap and Case Conversion (March 28)
auto_mapclass attribute added to ORM for cross-language API parity (no-op in Ruby sincesnake_caseis native)Tina4.snake_to_camel("my_field")returns"myField"Tina4.camel_to_snake("myField")returns"my_field"
v3.10.0 -- Optimized For-Loops (March 28)
The Frond template engine rewrote its for-loop renderer. Templates with large iteration counts render faster.
v3.9.x
Released: March 26 -- 27, 2026
v3.9.0 -- QueryBuilder, Sessions, Path Injection (March 26)
Three features arrived together.
QueryBuilder. A fluent SQL builder that integrates with the ORM.
# Through the ORM:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
admins = User.query
.where("role = ?", ["admin"])
.order_by("name")
.limit(10)
.get
# Standalone:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
rows = Tina4::QueryBuilder.from("users")
.where("active = ?", [true])
.select("name", "email")
.getThe builder supports where, or_where, join, left_join, group_by, having, order_by, limit, first, count, exists, and to_sql.
Path parameter injection. Route handlers receive path parameters as named arguments.
Tina4::Router.get("/users/{id:int}") do |request, response, id|
user = User.find(id)
response.json(user.to_hash)
endAuto-start sessions. Every route handler has access to request.session with zero configuration. The session API includes get, set, delete, has, clear, destroy, save, regenerate, flash, get_flash, and all.
v3.9.1 -- Security Defaults (March 27)
Breaking change: POST, PUT, PATCH, and DELETE routes now require authentication by default.
# BEFORE (v3.8.x): all routes open
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
Tina4::Router.post("/api/users") do |request, response|
# anyone could call this
end
# AFTER (v3.9.1): unauthenticated requests get 401
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
# To allow public access, add .public:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
Tina4::Router.post("/api/users").public do |request, response|
# open to all
endThis release also added:
- CSRF middleware with session-bound form tokens
- Standardized environment variables for CORS headers, session TTL, token limits
- Queue parity:
pushwith priority/delay,size(status),message.retry
v3.9.2 -- NoSQL QueryBuilder, WebSocket Backplane (March 27)
- QueryBuilder works with MongoDB
- WebSocket backplane support for multi-process deployments
SameSite=Laxset as the default cookie policy
v3.8.x
Released: March 25 -- 26, 2026
v3.8.0 -- Base64 Filters, Template Cache (March 25)
base64encodeandbase64decodefilters in Frond templates- Production template cache: single filesystem scan at startup, O(1) lookups after
v3.8.1 -- Security Headers Middleware (March 25)
A built-in middleware that sets X-Frame-Options, Strict-Transport-Security, Content-Security-Policy, and X-Content-Type-Options on every response.
# In your .env:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
TINA4_MAX_UPLOAD_SIZE=10485760 # 10 MB (default)Upload size limits and input validation also landed in this release.
v3.8.2 -- Connection Pooling (March 26)
Database connections now pool. Pass pool: N to the constructor for round-robin, mutex-protected pooling.
db = Tina4::Database.new("sqlite://data.db", pool: 5)v3.8.3 -- Claude Code Commands (March 26)
Seventeen .claude/commands/ slash commands shipped for AI-assisted development.
v3.8.7 -- Benchmark and Stability (March 26)
- Keyword argument fix for
run!():port:,host:, anddebug:no longer crash the environment loader - Updated benchmarks against Roda, Sinatra, and Rails
v3.7.x
Released: March 25, 2026
v3.7.0 -- Template Auto-Serve, Firebird Migrations (March 25)
The framework serves index.html or index.twig from src/templates/ at / without a route definition. User-registered GET / routes take priority.
Firebird migrations now check RDB$RELATION_FIELDS before executing ALTER TABLE ADD. Columns that exist are skipped.
v3.7.1 -- Full Template Auto-Serve (March 25)
Any .twig or .html file in src/templates/ is now browsable by URL path. /hello serves src/templates/hello.twig. Production mode caches the lookup table at startup.
v3.6.x
Released: March 25, 2026
v3.6.0 -- Architectural Parity (March 25)
Breaking change: fetch(skip:) is replaced by fetch(offset:). No alias.
# BEFORE (v3.5.x):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
users = User.fetch(limit: 10, skip: 20)
# AFTER (v3.6.0):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
users = User.fetch(limit: 10, offset: 20)Other changes:
- Source directories follow the
src/prefix convention across all languages TINA4_LOCALEis the only supported locale environment variable (other names removed)- Migration file paths standardized to
src/migrations/
v3.5.x
Released: March 24, 2026
v3.5.0 -- Bundled Frontend, Swagger CRUD, Middleware (March 24)
tina4js.min.js(13.6 KB) ships inside the gem. The reactive frontend library loads without a CDN or npm install- Auto-CRUD routes now include Swagger metadata
- Middleware standardized to
before_*andafter_*naming with three built-in middlewares
v3.4.x
Released: March 24, 2026
v3.4.0 -- Auth, WebSocket, DatabaseResult (March 24)
Breaking change: Auth method names changed. The old names remain as aliases.
# BEFORE (v3.3.x):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
token = auth.create_token(payload)
valid = auth.validate_token(token)
# AFTER (v3.4.0 -- preferred):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
token = auth.get_token(payload)
valid = auth.valid_token(token)
# Old names still work but are deprecated.
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.jsHS256 authentication. Set TINA4_SECRET in your .env and auth uses HS256. Provide RSA key files and it uses RS256. The framework picks the right algorithm.
# .env for HS256:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
TINA4_SECRET=my-secret-key
# .env for RS256:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.jsBug fix: Base64url padding in HS256 tokens caused validation failures. Fixed.
WebSocket improvements:
Router.websocket("/ws/chat")for route-based WebSocket handlers- Path-scoped broadcast: messages sent to
/ws/chatreach only clients connected to that path send_textrenamed tosendonWebSocketConnection(send_textkept as alias)
DatabaseResult enhancements:
columnsreturns column namescolumn_infoprovides schema metadata (type, nullable, default) on demandto_paginateformats results for paginated responses
Frond template additions:
- Ternary-with-filter:
{{ value ? value|upper : "default" }} data_urifilter for inline file display in templates
v3.3.x
Released: March 24, 2026
v3.3.0 -- Queue API, Route Chaining (March 24)
Breaking change: Producer and Consumer classes removed. Use queue.produce() and queue.consume() directly.
# BEFORE (v3.2.x):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
producer = Tina4::Producer.new(queue)
producer.send(message)
consumer = Tina4::Consumer.new(queue)
consumer.listen { |msg| handle(msg) }
# AFTER (v3.3.0):
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
queue.produce("channel", { data: "payload" })
queue.consume("channel") do |job|
handle(job)
job.complete
endRoute chaining. Mark routes as authenticated or cached with chainable modifiers.
Tina4::Router.get("/dashboard").secure do |request, response|
response.html("<h1>Dashboard</h1>")
end
Tina4::Router.get("/static-page").cache(ttl: 3600) do |request, response|
response.html("<h1>Cached for one hour</h1>")
endOther additions:
- MongoDB queue backend
- Database session handler for full backend parity
- Valkey added to session handler options
- Migration parity: advanced SQL splitting, status tracking, rollback via CLI
- Auto-increment port if the default is in use; browser opens on startup
v3.2.x
Released: March 23, 2026
v3.2.0 -- Flexible Route Handlers (March 23)
Route handlers now accept zero, one, or two parameters. The framework detects what your block expects and provides the right objects.
# Zero params -- just return a response:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
Tina4::Router.get("/health") { "OK" }
# One param -- response only:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
Tina4::Router.get("/hello") { |response| response.html("Hello") }
# Two params -- request and response:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
Tina4::Router.get("/echo") do |request, response|
response.json({ body: request.body })
end
# Named :request or :req -- single param receives the request:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
Tina4::Router.post("/submit") { |request| process(request.body) }Bug fix: The 500 error overlay crashed because it did not receive the Rack environment. Fixed.
v3.1.x
Released: March 21 -- 22, 2026
v3.1.0 -- ORM Relationships, Caching, Queues (March 22)
The largest feature release after the initial launch. Fourteen capabilities landed in one version.
ORM relationships. Define has_many, has_one, and belongs_to with eager loading.
class User < Tina4::ORM
has_many :posts
has_one :profile
end
class Post < Tina4::ORM
belongs_to :user
end
user = User.find(1)
user.posts # => eager-loaded array of Post objectsCaching. Switch between memory, Redis, and file cache by setting one environment variable.
# .env:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
TINA4_CACHE_BACKEND=redis
TINA4_CACHE_URL=redis://localhost:6379
# Code stays the same:
## v3.10.70 (2026-04-06)
- **New:** SSE (Server-Sent Events) support via `response.stream()` - pass a generator, framework handles chunked transfer encoding, keep-alive, and `text/event-stream` content type
- **New:** Chapter 24 added to documentation: Server-Sent Events
- Feature count: 45 (was 44)
- Full parity across Python, PHP, Ruby, Node.js
Tina4::Cache.set("key", "value", ttl: 300)
Tina4::Cache.get("key")Database query caching. Set TINA4_DB_CACHE=true for transparent query result caching.
Queue system. Switch between SQLite, RabbitMQ, and Kafka via .env without changing code.
Messenger. Unified messaging driven by environment configuration.
Scaffolding. tina4 generate model User, tina4 generate route api/users, tina4 generate migration create_users, tina4 generate middleware auth.
Frond template engine. raw/endraw blocks and from imports.
Performance. Frond pre-compilation caches parsed tokens. File rendering runs faster.
Other additions:
- Production server auto-detection (Puma, cluster mode)
- GitHub Actions CI/CD
- Error pages: clean 404/500/403 without branding
numeric_fieldtype in ORMtruthy?()helper method- Log rotation
v3.1.1 -- DevMailbox Fix (March 22)
Bug fix: DevMailbox timestamp precision was insufficient for reliable sort ordering.
v3.1.2 -- Documentation Fixes (March 22)
README code examples updated to match the actual v3 API. Quick start guide added.
v3.0.x
Released: March 21, 2026
v3.0.0 -- Initial Release (March 21)
The initial Ruby release. Zero gem dependencies. Everything the framework needs -- HTTP server, template engine, ORM, migrations, auth, queue, GraphQL, WebSocket, WSDL -- ships inside a single gem.
Core features:
- Rack-based HTTP server (compatible with Puma, Thin, WEBrick)
- Frond template engine (Twig-compatible syntax)
- ORM with support for SQLite, PostgreSQL, MySQL, MSSQL, and Firebird
- JWT authentication (RS256)
- Queue system
- GraphQL endpoint
- WebSocket server
- WSDL/SOAP service generation
- DevAdmin dashboard with developer tooling
- AI coding tool integration (auto-detect and install context for seven tools)
- Full test suite passing
Quick start:
require "tina4"
Tina4::Router.get("/") { |request, response| response.html("<h1>Hello Tina4!</h1>") }
Tina4.run!gem install tina4rubyThe server starts on port 7147 by default. Set host: "0.0.0.0" for Docker deployments.
Pre-Release (v0.x)
Released: March 18, 2026
Versions v0.4.0 through v0.5.2 were development previews. They established the gem structure and basic routing but lacked the ORM, template engine, and queue system. If you used a v0.x release, upgrade directly to v3.0.0 -- there is no migration path from v0.x.