Release Notes
Releases are recorded below, newest first. The @zerotal/* packages share a
single version line and follow semantic versioning.
Each package also keeps a detailed CHANGELOG.md of its own; this page is the
summary across the suite.
Tip — For the mechanics of moving between versions — bumping packages, running migrations, and re-checking config — see the Upgrade Guide.
How to read these notes
Each version lists changes under three headings:
- Added — new features and APIs (safe to adopt incrementally).
- Changed — behavior changes; breaking ones are called out explicitly, in bold, as BREAKING.
- Fixed — bug fixes.
Breaking changes belong in major releases, and while the 1.x line is young they may also land in a minor or a patch — always labelled, always with migration steps. Read the section for every version you cross and apply its migration notes, not only the majors. Releases and versioning explains when that carve-out ends.
1.15.1 — 2026-09-13
A patch, and its headline is an ORM bug as old as the option it breaks: a model keyed on
anything but id could not be updated, deleted or reloaded, and said nothing about it.
Nothing here breaks. Safe to take directly.
Added
registerViewFileRouteResolveris public (@zerotal/core). It is what turns on server-rendered file-route pages — a route file's default export rendered to HTML and wrapped in the nearest_layout. Both halves were implemented, tested, and described in View as automatic, while the call that enables them carried an internal marker and could not be reached from an app. It stays opt-in, because it claims every.tsxroute file's default export; the docs now say so and document the_layoutconvention beside it.
Fixed
-
A model with a primary key other than
idcould not be updated, deleted or reloaded (@zerotal/orm).static primaryKey = "uuid"configured the column name everywhere the key was written into SQL, while the value bound against it came fromthis.id— a property such a model never has. So every statement went out asWHERE uuid = NULL:save()on a loaded instance anddelete()each matched zero rows and returned as though they had written,refresh()andfresh()raisedModelNotFoundErrorfor a row sitting in the table, andincrement(),loadCount()and the relation aggregates no-opped or reported zero.The insert was worse than silent. It wrote the row, then read it back by
last_insert_rowid(), which answers with a rowid noTEXTkey ever equals — so the instance came back with no timestamps and not marked as resident, and the nextsave()on it was a second INSERT and a duplicate-key error rather than an UPDATE.Every one of those paths now reads the key by its declared name. A write against an instance whose key is genuinely absent, such as a row hydrated by
select("title"), raisesE_NO_PRIMARY_KEYinstead of binding NULL and looking like a success. The ORM guide now has a section on keying a model on something other thanid, including the part that bit:localKeyon a relation defaults to"id", so a named key has to be named there too. -
A primary key the app mints was stripped from the INSERT (
@zerotal/orm).idwas treated as database-generated unconditionally, so a table whose key is aTEXT idthe application supplies took the value off the instance, dropped it from the statement, and stored NULL — while the in-memory model went on holding the value it thought it had written. The key is now omitted only when it has no value, which is what leaves AUTOINCREMENT to the database. -
A
_layoutfile that could not apply rendered its pages without one (@zerotal/core). The same fail-open the_middlewareloader had in 1.15.0, one function up in the same file: an import error was swallowed together with the not-found case. A_layout.tsxwith a typo rendered every page beneath it stripped of its chrome, with no error and no log. Absence stays silent; a file that exists and threw now stops the boot with its path and the original error. -
A test file that booted the app with a
setupcallback left every later file without a database (@zerotal/testing).createTestApp(bootstrap, setup)opts out of sharing, which was implemented as opting out of the cache — so nothing knew that app was in use, itsclose()ran the full provider teardown, andDatabaseProvider.onStoppingcleared the process-global connection resolver for everyone after it. Suites that had done nothing wrong failed withNo database connection. Is DatabaseProvider registered?. It read as a CI-only flake for months, because whether it bites depends on test-file order. -
zt doctorsays when its outside-in probes did not run (@zerotal/core). The response-header and WebSocket-transport checks need--url, and they are the two that see what no in-process check can. Skipping them silently meant the people who most needed them had no way to learn they existed.
Three more are internal, and in the notes for each package: a MySQL adapter that offers
connection reservation may still refuse it, a dialect-coverage test left the ORM in MySQL
mode for the rest of the process, and four test suites left their fixture trees on disk
because a recursive rm() of a "./"-prefixed path silently deletes nothing on Windows.
1.15.0 — 2026-09-04
A working developer building a training-provider platform on 1.14.3 sent eight findings from doing it. All eight are here. Two of them were the same bug twice, and it was the worst kind: a routing convention that failed open.
Changed — BREAKING
-
createdAt,updatedAtanddeletedAtareCarbon, notDate(@zerotal/orm,@zerotal/audit).They were declared
Dateand never held one. Everydatetimecolumn hydrates asCarbon, these three included, so the declaration was wrong on any freshly loaded row — andorder.createdAt.getTime()type-checked and threw, becauseCarbonhastoISOString()andvalueOf()but nogetTime().Worse, the runtime class depended on how you reached the property:
Carbonafter a load, a rawDateaftersave(),touch()or a softdelete()wrote one straight back onto the instance. The same property on the same model, matching its declared type in neither case, and with no@columndeclaration to check the truth against — which is exactly why the declaration had to be the thing that is right.order.createdAt.getTime(); // compiled, threw at runtime order.createdAt.valueOf(); // epoch milliseconds order.createdAt.toISOString(); // "2026-09-04T09:12:33.000Z" order.createdAt.toDate(); // a native Date, when something needs oneCompile and follow the errors. Every one is a line that would have thrown against a loaded row, so the break is the fix arriving early. See the Upgrade Guide.
Fixed
-
A
_middleware.tsthat could not apply left its subtree serving, unguarded (@zerotal/core). Two ways in, both silent.export default [Mw]was ignored: the loader read only the named export. It is the natural guess — every route file in the same directory default-exports its handler — and getting it wrong produced no warning, no boot error, and nothing inroute:list. This was found on a host-employer portal scoped to one company's learners, where shipping it unguarded would have been a data breach rather than a bug report.The second was one
catch {}covering both "there is no middleware here" and "the middleware threw on import". A typo, a bad import path, a circular import or aSyntaxErrorall read as absence — and on a hot-reload, that is a guard which was there a second earlier.Absence stays silent, because that is the convention working. Everything else is loud: a file that throws stops the boot with its path and the original error; a file that exports nothing appliable names the mistake and the line to write instead. Nothing rejected here has ever applied, so no working app changes behaviour.
-
zt doctor's secure-headers check false-positived behind a reverse proxy (@zerotal/core). It readapp.secureHeaders.secureand inferred the response from it, so an app where Caddy or nginx terminates TLS and sets HSTS — the most common production topology for a Bun app — was reported as downgradeable whilecurl -Ishowed the header present.The cost is not the wrong answer.
doctoris reliable enough that people run it after every deploy, and one line they have to remember to ignore is a line nobody reads on the day it is right. It now makes one request toapp.urlon a deployment and reports what came back. When it cannot reach the site it says so, rather than reporting a header as missing. -
A test that stubbed
globalThis.fetchalso answered the test client's own requests (@zerotal/testing). Faking a third-party integration — the ordinary way to test one — made unrelated route tests fail withconnection refused. The failure presents as "my test client cannot reach my app", which sends you looking at the server.TestAppnow bindsfetchat module load, before any test can replace it.
Added
-
bun zt route:typesgenerates every type file, not only the routes (@zerotal/core,@zerotal/inertia). Adding a page and rendering it failed withTS2345: … not assignable to 'PageName'— accurate and unhelpful, since it reads as a mistyped page name when the registry simply had not been rebuilt. The command whose name says it generates types regenerated half of them.registerTypeGeneratorlets a view package add its codegen without core importing it. One command refreshes both, and--checkgates on both in CI. -
bun zt testno longer inherits.env's outbound drivers (@zerotal/core). Bun loads.envinto every process, so a developer withMAIL_DRIVER=smtppointed at a local Postfix got a suite where every path that sends mail opened a real SMTP connection — one test going from milliseconds to a five-second timeout, failing only when run with its siblings, and invisible to anyone without a mail server configured.Mail, queue, session and cache now get their in-process defaults, which is what the app's own config already defaults to. A value set in the shell still wins,
.env.testis read and merged last, and--keep-envturns it off.
Documented
-
The query builder is findable. A team wrote whole-table loads with JavaScript filtering throughout an app because they never found
whereIn. The page documenting it exists and is thorough; the README's Data table sent anyone looking forPost.query()to theDB.table()page, andapi-surface.mdis a flat alphabetical snapshot wherewhereInsits among two hundred siblings. Both now point where they should, and Queries warns against the shape that degrades in proportion to the table rather than the result. -
_middleware.tstakes a named export, and stubbingglobalThis.fetchin a test has a documented pattern next toHttp.fake().
1.14.3 — 2026-09-01
Consistency pass on the documentation, and two bugs it uncovered.
Everything is written bun zt <command> now. The scaffold still adds a dev script, so
bun run dev keeps working — the docs simply lead with the form that is the same in every
project, including one that never ran the scaffolder.
Fixed
-
Twenty pages taught a retired command.
serve --devwas retired in 1.13.0, and that release updated the runner, the codemod,docs/commands.mdanddocs/upgrade.md— leaving the form in the getting-started guide, the assets guide, two Flow pages, the logger page, and both READMEs. For three weeks the front door told newcomers to run a command that exits 1.A gate now scans every page against the upgrade codemods' own list of retired forms, so what the docs teach cannot drift from what the framework accepts. Only shipped retirements count: a codemod for 2.0 describes a rename that has not happened, and flagging correct prose is how a gate becomes something people add exclusions to rather than read.
-
zt upgradenever offered the 1.13.0 migrations. Thedeprecated-aliasescodemod bundledserve --devandroutes:types— retired in 1.13.0 — with theBaseModelrename, which has not shipped and is scheduled for 2.0. A codemod carries one version, so it took the later one:zt upgrade --to 1.13.0selected nothing, and the migration for the release that brokeserve --devwas unreachable by every app crossing it.Split into
deprecated-aliasesat 1.13.0 andbase-model-renameat 2.0.0. An app moving to any 1.13+ release gets the command rewrites; one crossing to 2.0 no longer hasBaseModelrenamed as a side effect of a command alias.Found by the new gate, which flagged
extends BaseModelin the ORM docs — correct prose, and the reason it was reported was that the codemod claimed a rename that had not happened.
1.14.2 — 2026-09-01
bun run dev was broken in every app scaffolded since 1.13.0. Take this one.
Found by starting the adoption release the obvious way — scaffolding a fresh app and walking the first-run path as a newcomer would, rather than reasoning about it.
Fixed
-
The scaffold templates shipped a retired command. Every template's
package.jsoncarried"dev": "bun zt.ts serve --dev", andserve --devwas retired in 1.13.0 — deliberately failing loudly rather than silently starting a plain server with no watcher. So the first command a new user runs exited 1 and told them to use something else.Retiring an alias meant updating the runner, the documentation and the
zt upgradecodemod. It did not occur to me to update the thing that writes new apps, which is the one place a retired form is guaranteed to keep appearing.A test now checks the scaffolder against the codemod's own list of retired forms, so what generates apps cannot fall behind what migrates them. It is driven by the codemod rather than a second list, because two lists disagree.
-
zt upgradedid not rewritezt.ts serve --deveither. The codemod anchored onztfollowed by whitespace, matchingzt serve --devand missingbun zt.ts serve --dev— the form in every scaffoldedpackage.json. So an app runningzt upgradeto migrate off the alias was told there was nothing to do, on the file that needed it most.Worth naming as its own failure: the first version of the new scaffolder test passed with the bug deliberately planted, because it was driven by that same pattern. A gate is only worth what it catches, and the way to know is to break the thing on purpose and watch it fail.
1.14.1 — 2026-09-01
Clearing the deferred list. Three of the four are fixed; the other two were sized and moved to 2.0 with the reasoning recorded, because deciding is also addressing.
Added
-
Prompt-cache breakpoints on messages. Caching reached exactly one place — the system prompt — so a long stable document in the message history could not be cached, which is the case caching is most worth having for.
await Ai.generate({ messages: [ { role: "user", content: policyDocument, cache: true }, { role: "user", content: question }, ], });The marker goes on the last message of the prefix, because a breakpoint caches everything before it — marking each message spends four breakpoints describing one boundary. Anthropic allows four, and a fifth is refused by name rather than surfacing as a provider 400 about a field you never wrote.
Fixed
-
A 1-hour cache write is priced at 2×, not 1.25×. Every write was multiplied by the 5-minute rate, underestimating a 1-hour write by 37.5% — in the unsafe direction for a ceiling, since a limit that under-counts lets spend through rather than blocking it.
AiUsage.cacheWrite1hTokenscarries the split, read from thecache_creationbreakdown Anthropic returns when a 1-hour cache was used. Optional on the type, because a custom driver constructsAiUsageand making it required would break every one for an accounting detail their provider probably does not report. -
A command that declares
needsApp = falseno longer boots the application.zt version,key:generate, everymake:*scaffolder and the gate commands paid for providers, a database connection and a schedule registry they never touch — and could not run when booting was the thing that was broken. A CLI you cannot reach when the app is down is missing exactly when it is wanted.Config is still bound, through the new
Application.bindConfig(). "Does not need the app" and "does not need config" are different claims, and only the first is true of a scaffolder writing to a configured path.It also makes
zt version --jsonpipeable, which previously needed the--versionform to dodge the boot log on stdout.
Decided, not done
Two entries on the 2.0 ledger were filed as alias retirements and are mass renames:
BaseModel→Modelis 617 references acrossapi-surface.mdfiles and 354 in non-test source.BaseModelis the class's declared name, so TypeScript prints it into every signature mentioning a model — retiring it means renaming the class and regenerating every snapshot, which the breaking-change gate reads as hundreds of removals. The "trivial" codemod in the ledger is the app-side rewrite, which is genuinely trivial and already written; the framework side was never sized.- Prefixing the 269
@internalexports with_is the same shape, minus the classes that cannot be renamed at all.
Both move to 2.0 on the ledger's own rule — a break worth taking once, wanting one
migration and one zt upgrade run rather than hundreds of app-visible type positions
churned so there is one name instead of two. They should cross together, so an app pays for
one rename pass rather than two.
1.14.0 — 2026-09-01
Rendering and the SSR endpoint become separate decisions. Small in practice — most apps change nothing — but it is a change to what a config flag does, so it takes a minor and says so.
Changed — BREAKING
-
inertia.ssr: trueno longer registersPOST /__ssr.// config/inertia.ts export default InertiaConfig({ ssr: true, // server-render every first page load. Renders nothing else. ssrEndpoint: true, // expose POST /__ssr, for a renderer outside this process });If you set
ssr: truefor server rendering, you need no change. You keep exactly that and stop exposing a route you were not using. AddssrEndpoint: trueonly when something outside the web process calls/__ssr— a separate renderer, a second host.The endpoint exists because upstream Inertia runs on hosts with no JavaScript runtime: PHP cannot import a
.tsx, so it posts{ component, props, url }to a Node process and gets{ body, head }back. That hop is forced by the host language, not chosen. Bun is a JavaScript runtime, so 1.13.5 madessrimport the component and render it inline — no serialisation, no second process, no network.So the endpoint solves a problem this framework does not have, and it stays only for the case that is still real: deliberately moving render CPU off the web process. That is a deployment choice, and it now has to be made rather than inherited.
Turning rendering on should not open a route that renders arbitrary components from POST input, however well guarded. One switch, one thing — the same reasoning that separated
secureHeaders: falsefrom the site gate in 1.13.3.
A note on what this costs
Worth stating plainly, because it is easy to expect the opposite: turning ssr on adds
CPU to the web process. Before 1.13.5 the flag rendered nothing, so there was no round
trip to remove — a page load did no rendering at all. Now it renders every first load.
The comparison where in-process rendering is cheaper is against the way other frameworks do SSR: no HTTP hop, no JSON round trip of the whole page object, no second process to run and supervise. Against the framework's own previous behaviour, it is new work in exchange for HTML a crawler and a link preview can read.
1.13.5 — 2026-09-01
inertia.ssr: true server-renders now. One config line, every page, no controller
changes — which is what the option is named for and what it did not do.
Fixed
-
The
ssrflag rendered nothing. It registeredPOST /__ssrand nothing in the request path consulted it. So an app that setssr: trueand read the SSR guide — which stated that the server renders the component into the template — got exactly the empty root it had before, and the documentation was the reason nobody suspected otherwise.// config/inertia.ts — this is now the whole of it export default InertiaConfig({ ssr: true });Inertia.render()renders the component into the root, injects the page's<Head>into the served<head>, and marks the rootdata-server-rendered. The scaffoldedapp.tsxalready hydrated on that attribute, so the client half needed nothing: turning SSR on is one line, and there is no second step.Server rendering was previously reachable only by rewriting each route to
Inertia.stream(), one call site at a time. Two teams did that. They can go back torender()— and should, unless they wanted the streaming.A component that fails to render falls back to the client-rendered document with a warning rather than failing the route. The page still works in a browser, and taking a route down because an optimisation failed would make
ssr: truea liability rather than an improvement. -
Streaming and SSR are separated in the docs.
inertiaStream()is not how you turn server rendering on; it decides whether the bytes are buffered or streamed. Both render the component and both hydrate. Conflating them is what made a per-route rewrite look like the supported answer, so the comparison table and the "what a crawler sees" remedies now lead with the config flag.POST /__ssris unchanged and documented for what it is: the contract for an external renderer, not the in-process switch.
1.13.4 — 2026-09-01
Take this one if you are on 1.13.3. The site gate shipped a staff bypass that failed open, and a type error that landed on apps not using it. Both found by a team upgrading, within a day.
Fixed
-
The gate's staff bypass was a denylist, and let the public in. 1.13.3 admitted any authenticated user whose role was not literally
"customer". In an app whose roles areuserandadmin— which is most of them — that is every signed-in visitor, so a private preview showed the site to anyone with an account. A gate that fails open is worse than no gate, because it reports success while doing nothing.It is an allowlist now:
gate.staffRoles, defaulting to["admin"]. An app whose staff role is named something else gets no bypass and notices, which is the safe direction to be wrong in. The token path is unchanged.Worth naming the mistake, because this release cycle already contained its lesson: the 1.11.0 notes describe an app that wrote its own "is this error permanent" check as a denylist and found it was a latent outage, and the fix was an allowlist. The same shape went into the gate three releases later.
-
The same line broke
tscfor apps that do not use the gate.role !== "customer"is a type error when an app's role union has no such member (TS2367), and the framework ships TypeScript source, so the error arrived on a feature the app never touched — failing its build. The role is read asstringnow, because the framework cannot know an app's role names and must not narrow to them.
Documented
- A tilde still crosses a patch, and under this scheme a patch carries features.
~1.13.2is>=1.13.2 <1.14.0, so it takes 1.13.3 without asking — which is exactly how 1.13.3 reached the app that found the bugs above. That is the right default for most apps, and it is weaker protection than the same range gives under strict semver, where a patch is only ever a bug fix. The upgrade guide now says so, and says to pin the exact version when you need it to hold.
1.13.3 — 2026-08-31
Two things an app cannot see about itself, from two field reports. Both are the same shape as most of this month's work: state that is real, consequential, and invisible from inside the process that would want to know it.
Added
-
A site gate — maintenance, and private preview. Guide ·
zt down·zt preview·zt up·zt gate:statusProposed by a team running a hand-edited
basic_authblock in their reverse proxy, deliberately kept out of version control so it could not be deployed and forgotten into a live shop. That precaution is the feature request: the gate belongs where the app can reason about it.Two states that look alike and are not. Maintenance means the site is down — everyone refused, staff included, because the usual reason a site is down is that its database is being changed underneath it. Private preview means the site is up and working, for the people invited to it, for weeks.
The details that make it framework work rather than app work:
- Maintenance is always
503withRetry-After, and is not configurable. A maintenance page served at200tells a search engine the apology is your homepage, and it will index it as such. - A preview token is stripped from the URL on first use, by redirecting to a
cookie. Left in the address bar it travels into
Refereron every outbound link, into analytics, and into screenshots. - Webhook paths must be declared in
gate.allow. A payment provider posting a settlement into a maintenance window otherwise gets a 503 — a retry, a dropped callback, or a payment your books never learn about. - The state is a file, and the token is stored hashed. A flag in the database is unreadable exactly when the database is what you are working on; a token in a file is a credential in something every backup copies.
- It covers
Router.raw()routes. Found by running it: this framework's own docs site serves every/docs/*page from a raw route, so an early build gated the front page — which is what a person checks — and left all the content public. - The state file is gitignored by the scaffold, which is the entire point.
- Maintenance is always
-
Worker liveness —
zt doctorcan tell whether anything is running your background work. Schedules · QueueAn app could say what it registered and nothing could say whether any of it ever ran. The reported failure: a team shipped to production with no worker process, and every scheduled task silently did not execute for weeks. No hold was released, no reminder was sent, nothing logged — from the web process's point of view nothing was wrong, and they found it by going looking.
✖ Scheduler — 3 schedule(s) registered, and no worker has ever checked in. Nothing is running them. fix: Start the worker process: `bun zt worker`.The beat lives in the cache, because the process reading
doctoris not the process running the work and often not the same machine — and your cache driver already decides what shared state can see. Onmemory, which is private to each process, the check says it cannot tell rather than reporting a missing worker: a check that cried wolf on every app using that driver is one people would learn to skip, and then it would not be there for the case it exists for.@zerotal/core/heartbeatexposes the primitive if you want the same signal on an ops page.
Fixed
secureHeaders: falseno longer empties the kernel middleware. It set the layer to[], which was the same thing as removing the headers right up until the site gate joined it — at which point opting out of security headers would silently have taken the gate with it. One feature's opt-out disabling another's is precisely what the gate is otherwise about.
Documented
- Minting
APP_KEYwithout the code.key:generateis part of the application, so it exists only once a release is installed — awkward when preparing.envfirst, sincemigratewants the file and the file wants a key.openssl rand -base64 32produces exactly whatkey:generatewrites; the deployment guide now says so.
1.13.2 — 2026-08-31
From a production field report at 1.12.0 — an Inertia + React app on SQLite, 117 routes, 1256 tests, live behind Caddy. Six items, of which one was still open. The other five had been closed between 1.10.0 and 1.13.0 and are listed at the end, because a report that carries items forward is worth answering precisely rather than generally.
Fixed
-
Inertia.stream()honoursX-Inertia. It answered every request withtext/html, including the XHR a running Inertia client sends. So the obvious way to adopt server rendering — point a route atstream— broke client-side navigation to that route.The shape of the failure is the reason it survived: the first load looks perfect, which is what a person checks. The second click does nothing. It fails only for somebody already in the app, silently, and only once the route otherwise works.
renderandstreamnow share the branch that writes the page object rather than one of them having it, which is precisely how they came apart. An app that wrote a header check in front of the call can delete it; it does no harm either way.
Verified, not changed
Five of the six items were already closed. Each was re-checked against this release rather than taken on trust, because the report carried them forward from 1.9.0:
- STARTTLS on port 587 — fixed in 1.11.0, with
SmtpStartTls.test.tscovering it. The cause was that a write issued before the handshake completes is dropped. intended_urlacrossAuth.attempt()— fixed in 1.11.0.attemptdelegates tologin,regenerate()deliberately carries the data bag, and only privilege markers are swept.intendedFlow.test.tsruns the three steps in order against a real session, including the ID rotation.bun install --ignore-scripts— documented in the deployment guide.- A default test timeout —
zt testhas passed--timeout=30000for some time, and the testing guide already names the two alternatives that do not work:bunfig.toml's[test] timeout, andsetDefaultTimeout()in a preload. postFormrefusing aFile— it has thrown, namingmultipart(), since 1.0.2.
The report's other upload concern — that http.file() consumes the multipart stream, so a
later body() reads empty — does not reproduce: _parseFormData() caches, and reading
the file first is safe. A test now pins that, since nothing had covered that order.
1.13.1 — 2026-08-31
One addition, found by sizing a job rather than doing it.
The 2.0 ledger carries an entry to prefix every @internal export with _ — 270 symbols,
filed as mechanical. Three of them are Job classes, and a job class name is not a source
symbol: JobRegistry keys on it and that string is written into the persisted queue payload,
so a job enqueued yesterday is resolved by today's process. Renaming one invalidates every job
already in the queue, at deploy time rather than at change time, and no test sees it because a
test enqueues and runs in the same process.
The rename is re-scoped. The hazard it exposed is fixed here.
Added
-
Job.jobName— a declared name for the queue payload, so renaming a job class is free:export class SendWelcomeEmail extends Job { static override jobName = "SendWelcomeEmail"; // survives a class rename async handle(): Promise<void> {} }It defaults to the class name, so a job that declares nothing behaves exactly as before. This is the same tool
Migration.idis for a migration's filename, shipped in 1.11.0 for the same reason: an identity the framework derived from a name someone was free to change, with no way to say otherwise.It also decouples the queue from a build that mangles names.
zt compiledoes not minify today, so that is not a live hazard — but nothing in the registry said it depended on that, and an assumption worth relying on is worth writing down.
1.13.0 — 2026-08-31
Three retirements, taken together on purpose. Each is a small migration, and three minors
each asking an app to move costs more than one that asks properly — so this is one crossing
and one zt upgrade run.
bun zt upgrade --to 1.13.0
Removed — BREAKING
-
Flow's
Component.client(…). Use thethis.$`…`tagged template.This is a security fix wearing an ergonomics change's clothes, which is why it did not wait for 2.0.
client()took a string and queued it to be evaluated in the browser, so the caller owned the escaping — and its own docblock had to warn never interpolate unescaped user input. A method whose documentation has to tell you not to hold it that way is a footgun with a label on.$is a tagged template, so every${…}is encoded as a JS literal before it reaches the page.// before — escaping was yours to remember this.client(`toast(${JSON.stringify(this.search)})`); // after — encoded for you this.$`toast(${this.search})`;The codemod rewrites a call whose argument is a single literal. One whose argument is a variable or a concatenation is reported rather than rewritten: those are precisely the ones the warning was about, and wrapping a finished string as
$`${expr}`would encode it as a string literal and stop running it as code. A codemod that quietly did that would leave an app compiling, running, and no longer doing anything where it used to run a script.Removing it also frees
clientas a property name on a component — the same benefit removingtitlegave in 1.7.3.
Changed — BREAKING
-
LockDriver.extend()is required. Only affects a custom lock driver; all three built-in drivers already implement it.It shipped optional in 1.5.0 with
acquire(key, owner, ttl)as the fallback, and that fallback was correct only by coincidence.acquirehappens to be an owner-guarded refresh on every built-in driver, and nothing in the interface ever said it had to be — so a third-party driver whoseacquiretakes a free lock, which is the ordinary reading of the word, would have hadrefresh()silently take a lock another holder owned. That is the one thing a lock exists to prevent. Requiring the method turns an assumption the contract never stated into something a driver has to answer. -
routes:typesandserve --devare retired, in favour ofroute:typesanddev. Both are rewritten by the codemod.serve --devfails with a message rather than being ignored, and the flag is still declared for that reason alone. Flag parsing runs non-strict, so simply deleting it would have leftserve --devstarting a plain server — no watcher, no rebuild, no explanation. A retired flag that silently changes what a command does is worse than one that is still there.
Added
- The
client-tagged-templatecodemod, which is what makes the first item above a migration rather than a search.
1.12.0 — 2026-08-31
One change, deliberately alone: the minor exists to carry it.
A field report from an app running in production found a feature flag reading as enabled for every record that had it turned off. Nothing errored, nothing logged, and the database was doing exactly what it had been asked to.
Changed — BREAKING
-
A boolean written to a column declared to hold text is refused.
A bare
@column()resolves to{ type: "string" }— the right default for the common case, and the wrong one for a boolean. A text column has text affinity, sofalsewas stored as the string"0", and"0"is truthy in JavaScript. Everyif (model.flag)on such a column took the wrong branch for a storedfalse, on every row, silently.There is no correct coercion.
0becomes"0";"false"is truthy too. The value cannot survive the round trip, so the only honest options were to refuse the write or to keep letting a storedfalseread back astrue. It now raisesColumnTypeError, naming the property and the fix:[Zerotal ORM] Widget.active is declared as a `string` column and was given a boolean. A text column stores that as "0"/"1", and "0" is truthy in JavaScript — so a stored `false` would read back as true and every `if (…)` on it would take the wrong branch. Declare the column's type instead: `@column("boolean")`.The decorator cannot pick for you:
declare active: booleanerases the TypeScript type at runtime, so the property looks identical to a decorator whether it holds a boolean or a string. Declaring the type is the only signal there is — which is why the mistake is worth refusing loudly rather than guessing at.An explicit
@column({ type: "string", cast: "boolean" })is still honoured. That is someone stating what they meant; the guard is for the column that says nothing.
Before you upgrade
- Find the boolean properties whose
@column()declares no type. Nothing can find them for you, for the reason above — a search of your models for a bare@column(), read against the property types beside them, is the reliable way. - The rows you already wrote are still text. This stops new bad writes; it does not migrate old ones. Those rows keep reading truthy until they are converted. The upgrade guide has the statement.
Added
ColumnTypeError— exported, so an app can catch it by class.
1.11.2 — 2026-08-31
@zerotal/ai is stable, and the release that promotes it is the one that fixes five
bugs its first production users found. That ordering is the point: a stable promise
about an API nothing has pushed against is a promise nobody has tested.
Also here: two gates that were not doing their job, one of which had let two releases publish over a red build.
A patch. Nothing here breaks — @zerotal/ai's surface was narrowed before the label,
while narrowing was still free.
@zerotal/ai — the review, answered
The package shipped experimental with a stated precondition — it graduates in the
release after its first real users — and a review date of 1.11.0 enforced by the
package linter rather than by a promise. Its first production users, running it against
Anthropic, sent a field review of the driver. So the precondition was met rather than
waived, and the answer is promote.
Fixed, all from that review:
-
Sonnet 5 was priced as Sonnet 4.6 — 3/15 rather than 2/10, 50% high. The same table feeds
limits.perRequestUsdandperDayUsd, so an app on that model was refused requests comfortably inside its budget by an error that said "spend limit" and sent it to its config rather than to the row.AiSpendLimitErrornow quotes the rate it priced with and namesregisterModelPrice(), so a wrong table is legible from the refusal and correctable without waiting for a release. -
effortandthinkingare model-aware. Both went on every call.effortis a 400 on the 4.5 generation and those models want an explicit thinking budget rather than the adaptive form — so the package listedclaude-haiku-4-5in its pricing table while the driver could not successfully call it.modelCapabilities()answers what a model takes, and the driver builds the request that model accepts. -
temperaturenever reached the API, on any model. Not in the review — it turned up while testing the item below. The driver warned about droppingtemperatureand had no branch that set it, so the configured default andAiRequest.temperaturewere both inert everywhere, including on the models that accept them. The old predicate warned for almost every model, which is exactly what made the silence look deliberate on the few it did not. -
The streamed
thinkingchunk was always empty. The API omits thinking text by default on the current generation, so a documented chunk type fired forever withtext: ""and no error — and a "thinking…" view built against the 4.6 models, where it defaulted on, stopped working when users moved to 5 with nothing to say so.drivers.anthropic.thinkingDisplaydefaults to"summarized". -
An app with no AI configured now boots.
AiConfigthrew when no driver was declared and threw again on an emptyapiKey, so a deployment with no key could not express itself either way. One app declared an Ollama server it did not run purely to satisfy the validator, with a comment explaining that the config was lying. "AI is off" is a coherent deployment and is now expressible; the first call raisesAiDriverUnavailableError, whosetransientis alreadyfalse. -
countTokensreturnsnullwhere a provider cannot count, rather than0. Only Anthropic has a counting endpoint, and0is also a real count for an empty prompt.
How it was promoted, because the order is the part that matters:
The surface was narrowed first — narrowing after stable is itself a breaking
change. toSchema, strippedConstraints, resetSpend and resetStats are @internal
now: still exported, so nothing breaks at runtime, but no longer promised.
translateSchema stayed public despite having no caller outside the package, for the
same reason AiDriver is public — the point of a driver contract is that someone else
implements it, and implementing structured output means translating a schema.
AiDelivery stayed too, being the element type of recentGenerations().
Then the two modules it would have been embarrassing to freeze untested: the SSE parser, which reads a remote provider's framing off the network, and prompt redaction, which is the only thing between a user's prompt and a log that outlives the request. Both hold up — the parser reassembles a frame whose terminator is split across chunks and a UTF-8 sequence cut mid-character.
BREAKING — countTokens can return null
Ai.countTokens() and AiDriver.countTokens() return number | null rather than
number. Only Anthropic has a counting endpoint; the other drivers returned 0, which
is also a real count for an empty prompt, so the old value was a number you could divide
by and budget against without ever being told it meant "unsupported".
// before
const tokens = await Ai.countTokens(prompt);
if (tokens > 1000) shorten();
// after
const tokens = await Ai.countTokens(prompt);
if (tokens !== null && tokens > 1000) shorten();
A custom AiDriver implementation compiles unchanged — returning number still
satisfies Promise<number | null>. It is callers who need the check.
This should have been a minor. It shipped in a patch, which the versioning scheme says cannot carry a break; see the note in the upgrade guide.
INTERNAL — four @zerotal/ai exports left the promised surface
toSchema, strippedConstraints, resetSpend and resetStats are @internal. They
are still exported and still work, so nothing breaks — they are simply no longer
covered by the compatibility promise, which is the narrowing that had to happen before
the package could be promoted at all. Reach for AiFake where a test used resetSpend
or resetStats; it is the seam built for that.
Fixed — the gates
-
The release workflow ran three checks; the pull-request workflow ran fifteen. So every convention, surface and documentation gate guarded the cheap, reversible action and not the permanent one. 1.11.0 and 1.11.1 both published over a CI that had been red since the first of them, and nothing in the release objected, because nothing in the release looked.
release.ymlnow runs the same set. -
One failing check hid eleven others. When the
@zerotal/aireview fell due, the package-conventions step failed and every later step in that job was skipped — reported as "skipped", which reads like "not applicable" rather than "never ran". Each check is now guarded so it reports its own result.
1.11.1 — 2026-08-31
Two things the framework could not do, both reported by teams who had already worked around them.
A patch, not a minor: nothing here breaks. Under the versioning scheme a minor is reserved for a breaking change and a patch carries everything else, features included — so this is safe to take from any 1.11.x.
Added
-
zt version— which Zerotal, which Bun, which app.Zerotal 1.11.1 Bun 1.3.14 App my-app 0.1.0It was an unknown command, so the version had to be dug out of
package.jsonornode_modules— both of which report what is installed rather than what is running, and those differ for any process that has been up since before an upgrade. It reports the running one.--versionand-vanswer earlier still, ahead of the runtime check, the config load and the app import, because those are the things someone is asking the version about: a config that no longer validates and an app that will not boot are the two moments the question stops being idle. A version flag that only works when everything else already works answers a question nobody has.Add
--jsonfor a script, and preferzt --version --jsonoverzt version --jsonthere — the application's boot log is written to stdout, so the second form puts a log line ahead of the JSON while the first never boots at all. The output carries no colour, unlike every other command's, because it gets pasted into bug reports and piped into parsers more than it is read on a terminal. -
MailMessage.header()andMailPayload.headers— set a header the mail driver does not build itself.new MailMessage() .subject("Your weekly digest") .header("List-Unsubscribe", `<https://app.test/unsubscribe/${token}>`) .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");MailPayloadhadto,from,subject,text,html,cc,bcc,replyToandattachments, and no way to add anything else — so a team that wantedList-Unsubscribehad to patch a vendored copy of the package, and shipped a footer link instead.Those are not substitutes for one another. Gmail and Yahoo draw their native unsubscribe control from the header, and a recipient who cannot find a control marks the message as spam instead — a judgement that attaches to the sending domain and degrades delivery of everything else it sends, including the mail people asked for. Send
List-Unsubscribe-Postalongside it: alone, the first leaves a link to follow, and only the pair produces the one-click control both providers now expect.Wired through all three drivers. SMTP writes them into the message, Resend sends them as the API's
headersobject, and the log driver prints them — that last one deliberately, because the reason to set a header is that a mail client does something with it, and the log driver is where that gets checked before anything is sent for real.Names the drivers build themselves are refused rather than sent twice: a second
Subjectis an ambiguous message, not an override, and which copy a client believes is its own business. The list is exported asRESERVED_MAIL_HEADERS, withresolveHeaders()beside it for anyone writing a custom transport. CR and LF in a value are folded to a space — left raw they end the header and let the remainder be read as further headers, which is how aBccarrives courtesy of whoever supplied a tracking ID.
1.11.0 — 2026-08-30
Two production reports, from teams taking apps live on 1.9.0 — one shipping a household-finance app to a VPS, one migrating a webmail platform from Flow to Inertia and cutting it over to live traffic. Between them, nineteen findings.
The character of the list is the thing worth naming. Almost none of it is a crash.
Most of it fails silently or fails open: a release gate that always passes, a
cascadeOnDelete that deletes nothing, an .env.example carrying the key the project
actually runs with, a fake that agrees with whatever it is handed. Building an app
finds loud bugs quickly because somebody is watching. Deploying one finds the quiet
ones, months later, when nobody is.
This is the first release under the versioning scheme in
the upgrade guide: a minor carries breaking changes, a
patch never does, and majors are annual. So a ^1.10.0 range will pull this in.
Read the two items below before you take it.
Two things to do before upgrading
- SQLite now enforces foreign keys. Run
bun zt db:check-foreign-keysfirst. It lists any row whose parent is missing — legal before, a constraint violation now — and exits non-zero, so a release script can gate on it. - If you have ever renamed a migration file,
migratewill now stop rather than re-run it. That is the intended behaviour and the message says what to do; see the upgrade guide.
Changed — BREAKING
-
SQLite enforces foreign keys.
database.sqlite.foreignKeysdefaults totrue. SQLite ignores foreign keys unless the connection asks it not to, and it is the only supported dialect that does — soconstrained()andcascadeOnDelete()in a migration described behaviour the database would not perform. Deleting a parent left its children, silently, and every child had to be removed by hand in the right order by application code that remembered to. An app's data-erasure path swept fifteen tables and missed three, two of them holding uploaded files, so an account erasure left the paperwork on disk.zt db:check-foreign-keysandzt doctorboth report the rows that enforcement would now reject;sqlite: { foreignKeys: false }takes the old behaviour back while you fix them. -
A renumbered migration is refused rather than re-run. A migration is recorded under its filename, so renaming one made an applied migration look pending — the runner tried it again and failed on
table already exists, a failed boot whose error named a table rather than the rename. An app renumbered001_to0001_to match this framework's own scaffold convention and would have made all nine of its production migrations look unrun.migratenow recognises that shape, refuses, and prints both spellings and the fix.
Fixed
-
.env.exampleno longer ships the key the project runs with. Both files got the same rendered content, so every scaffolded project committed a live, workingAPP_KEY—.gitignorecovers.envand not.env.example. Andcp .env.example .envis the first line of every deployment guide, so the published key went on to sign production sessions. No strength check can catch it: as a string the value is perfectly strong. -
.gitignorecovers the SQLite sidecars.*.sqlitedoes not matchdb.sqlite-walordb.sqlite-shm, and WAL mode is on by default, so both exist in every project and the write-ahead log holds rows not yet checkpointed. An app found both in its first commit on a public host. -
A command can fail without throwing.
CommandRunnerranprocess.exit(0)the momentrun()returned and never readprocess.exitCode— the idiomatic way to fail a CLI without an exception. A release gate printed six blockers, set the code, and exited0.zt deploygates on the same value, so its own preflight had the hole too: a gate that could not fail, failing open. -
A Bun the project never asked for is a warning, not a refusal.
bun-plugin-tailwinddeclaresbunas a required peer, sobun installfetches a second runtime and the guard refused to boot. An app took two outages on it. The guard now asks whether the project declaredbun; if not, it warns and names both the fix that works and the one that cannot. -
SMTP submission and TLS verification. STARTTLS on 587 completed its handshake and sent nothing — a write issued before the handshake finishes is dropped. And
rejectUnauthorizedis not enforced by the runtime on either transport, so TLS was encrypted and would have accepted that encryption from anyone in the path. -
Migration names no longer carry the platform that recorded them.
Bun.Globyields native separators, so on Windows the whole joined path went into themigrationstable. A database moved between platforms re-ran every migration. -
React SSR emits the page's
<Head>tags, andctx.session.intended()reads the URLAuthMiddlewarestored — the two APIs used different session keys, so an app that mixed them was silently sent to/after every sign-in. -
An empty string is an answer.
requiredtreats""as absent, which is right for a form and wrong for structured model output, where""is how a prompt asks a model to say "this does not apply". A whole feature returned nothing because of it — and shipped green, becauseAiFakenever checked its canned object against the schema. One half made the mistake; the other made it invisible. -
MonitorStoreno longer overwrites its own defaults withundefined, andzt inertia:buildfails when it produces no files rather than serving a page with no script.
Added
zt db:check-foreign-keys— the rows enforcement would reject, by table and rowid, exiting non-zero.Migration.id— a declared identity, so renaming a migration file is free.@zerotal/inertia/testing'srenderPage(), and a page-render test in the React scaffold. An app shipped a blank page with 614 passing tests: every one asserted a value or a status code, so a page could throw on its first paint and the suite stayed green.AiError.transient—truefor this call failed,falsefor this machine cannot do this, so a service can latch itself off without classifying eleven error classes by hand.assertRedirectContains(), andassertRedirect()now compares paths exactly — it usedincludes(), soassertRedirect("/login")passed on/login-as-someone-else.database.sqlite.foreignKeys, a doctor check for anotificationstable that is not the framework's, and a doctor check for a productionmail.driveroflog.
Changed
config/session.tsis scaffolded environment-aware, so the first production deploy no longer fails on the config validator's (correct) refusal.- Tailwind and its plugin move to
dependenciesand the plugin is pinned — a--productioninstall that then builds on the server had neither. - The notification database channel is built on first use, so an app that never routes there never touches the table.
@column({ type: "integer" })compiles. The object form took six type names while the string form took twelve.--successmeets WCAG AA at the contrast it is actually drawn at.
Documented
- Persistent layouts, which failed only
in a browser and were documented nowhere;
which Inertia redirects are covered;
pages render; the middleware names the framework
occupies; why
X-Forwarded-Foris counted from the right; and how to authenticate a test when identity is not a row.
1.10.0 — 2026-08-30
A second report from the team building on Zerotal, and the things it found. Most of this release is failures that were silent by construction — mail delivered nowhere, a page shared as a grey rectangle, a schedule that never fired, a rate limiter with one bucket for everybody. None of them logged anything.
Three things to know before upgrading.
- React apps using SSR now need
@inertiajs/reactinstalled. It is the same adapter your browser entry point already uses; the server renders through its<App>so<Head>works. A missing one is a named error rather than a silent omission. scheduler.timezonedoes something now. It was documented as informational and read by nothing. Its default moved from the literal"UTC"to the system zone, so an app that never set the key keeps doing exactly what it did — but an app that set it now gets what it asked for. If you set it to"UTC"on a server that is not on UTC, your schedules will move. See the upgrade guide.- Named rate limiters need
.trustedProxies(n)behind a proxy..byIp(),.byUser()and.byApiKey()now ignoreX-Forwarded-Forunless told how many proxies sit in front, which is the same ruleThrottleMiddlewarealready followed.zt doctorreports any that need it.
Added
-
zt assets:prune— removes the chunks an earlier release left behind, on the machine that never ran a build.assets:build --cleancleans the directory it builds into, which does nothing for the usual release shape: build here, tar the output, extract it overpublic/there. Extracting merges, so every deploy adds another set of content-hashed chunks and none are ever removed. One app reached 225 chunk files for the 49 its entry point references. Ship.zerotal/with the release and this removes what the build record does not claim. See Deployment. -
zt deploy:<env> --check— the preflight gate on its own, for the point in a release script where the new code is on disk and the service has not restarted. Exit 0 and restart; exit non-zero and keep serving the previous release. Everything that can refuse already runs by the end of preflight and none of it mutates, so stopping there is a complete answer rather than half a deploy. -
RateLimiter.trustedProxies(n)on the fluent builder, andres.assertInertiaRedirect(url)in@zerotal/testing— the assertion that checks what actually breaks on an Inertia redirect, which is theX-Inertiamarker rather than the status andLocationa normal redirect assertion already covers. -
@zerotal/core/runtime(zerotal/runtime) — the runtime checks as exports, so a script or a test can make the same assertionztmakes:runtimeBelowFloor,declaredBunFloor,runtimeMismatch,bunBinaryand the messages that go with them. -
definedOnly()andResolved<T>on@zerotal/core/helpers, for merging an options bag over defaults without an explicitundefinedoverwriting one. -
Scheduler timezone helpers —
wallClockIn,isValidTimeZone,CronExpression.matchesInandCronExpression.nextRunAfterIn, plusSchedulerErrorandUnknownTimeZoneError. -
A boot line when a convention is skipped in this environment. An env-restricted concern is skipped by not looking, which is correct and completely silent: an app ran for weeks in production with
app/schedulesfull and no worker process, and nothing logged anything because from a web process's point of view nothing existed.
Changed
-
Optional properties in public option shapes are declared
?: T | undefined. The generatedtsconfig.jsonenablesexactOptionalPropertyTypes, under whichimage?: stringrefuses a key that is present and holdsundefined— so{ image: candidate ?? undefined }, the most ordinary thing there is, did not compile and every conditionally-absent field had to be spelled...(x ? { x } : {}). 438 properties across 115 files. Nothing changes for a reader: an absent optional property already read asundefined. -
scheduler.timezoneis honoured, and its default is the system zone rather than the literal"UTC". See the note above. -
mail.driver: "log"failszt doctorin production whenmail.from.addresshas been configured, and warns when it is still the placeholder. Mail written to a log file is delivered to nobody and says so nowhere.
Fixed
-
React SSR emitted no
<Head>tags at all. The React branch rendered the page component directly, and<Head>renders nothing — it reports its children to a head manager it reads from context, and rendering the component alone puts none there. So every page served the template's<head>: no title, no description, no card. Nothing failed and nothing logged, because the page is perfect in a browser, where React has run. Only the readers that do not run JavaScript saw it — which is every link-preview scraper and everycurl. -
SMTP submission on port 587 sent nothing. The STARTTLS handshake completed and then the client's
EHLOwas dropped:upgradeTLS()returns the new socket while the handshake is still in flight, and a write issued in that window is lost — not buffered, not an error, gone. Port 465 was unaffected, so mail worked on the port nobody documents and the 587 every provider does document produced silence: no error, no bounce, no log line, and password resets that never arrived. -
TLS certificates were not actually verified, on either SMTP transport.
rejectUnauthorizedis not enforced by the runtime — it reports a self-signed certificate as authorized and puts the real reason beside it — so the connection was encrypted and would have accepted that encryption from anyone in the network path. The driver reads the handshake result itself now and fails closed. -
A scheduled task with a
timezonetook the whole scheduler down.Bun.cron's options form throws, and it throws during registration, so the worker died on boot and restart-looped: one task with a timezone stopped every task in the app. Zerotal evaluates the zone itself now, and a task that cannot register takes only itself out. -
Named rate limiters ignored
trustedProxies, andzt doctorwas told not to look..byIp(),.byUser()and.byApiKey()used a resolver that read the socket address and fell back to the leftmostX-Forwarded-Forentry with no proxy count. Behind a reverse proxy every visitor keyed on the proxy's own address and shared one bucket, so aloginlimiter of five attempts a minute was five attempts a minute for the entire user base and one attacker locked everybody out. The doctor check written to catch this exempted any customkeyResolver, which is what all three are. -
ctx.session.intended()could not read whatAuthMiddlewarestored. It used the keyintendedwhile the middleware andredirect().intended()usedintended_url. Each pair was internally consistent and separately tested, so every test passed — and an app that mixed them, which the documentation invited, was silently sent to/after every sign-in. -
MonitorStoreoverwrote its own defaults withundefined. It applied?? …defaults and then spread...optsafter them, and spread copies own properties even when they holdundefined— so an unset config putundefinedback over the retention window andprune()computed aNaNcutoff, pruning nothing and reporting nothing. -
engines.bunis enforced. Every generated app writes a floor and nothing read it.Intloutput moves between Bun releases, so a suite with currency or date assertions goes red on a runtime that is otherwise fine and the failures name the code they touch rather than the binary. -
The asset build record is portable. Its filename was hashed from the output directory's absolute path, so a record shipped with a release matched nothing at the other end and moving a checkout silently orphaned it.
-
The React SSR root is marked
data-server-rendered, so the client hydrates the markup instead of discarding it and rendering the page a second time.POST /__ssrreturns the same body shape as the Vue branch.
Documented
-
"What a crawler sees" —
inertia()does not server-render the component, which is the normal Inertia arrangement and worth saying out loud: the served document is a<title>and a JSON blob. Which readers run JavaScript, which do not, and the three ways to give the second group something to read. -
Which Inertia redirects are covered — all of them, because
useOnce()registers the middleware globally. Written down because the opposite belief is what keeps an app's own workaround on every request forever. -
bun testvsbun zt test— the 30-second timeout (thebunfig.tomlkey is ignored by Bun andsetDefaultTimeout()in a preload reaches only the first file, so the flag is the only mechanism that works), the runtime check, and the fact that configuration resolves once per process — so a test that mutates the environment inbeforeAllis testing whichever file booted first. -
Timezones in the scheduler, the middleware names the framework occupies, and why
X-Forwarded-Foris counted from the right.
1.9.0 — 2026-08-29
The gaps an app was filling in for itself: one Bun per project, a database backup that is not
cp, a release gate the pipeline will actually call, a boundary between a model and a page
prop, and helpers that work on both sides of the wire.
Two things to know before upgrading. Both are new refusals or new noise, and both are quiet if they do not apply to you.
ztnow refuses to run when a project has two Bun runtimes in it — the shell'sbunand a different one innode_modules. If it fires, pick one:bun update bunmoves the installed copy to match your shell, or run everything throughnode_modules/.bin/bun. To boot anyway while you sort it out, setZT_ALLOW_RUNTIME_MISMATCH=1. Most projects never see this, because most have nobuninnode_modulesto disagree with.- Passing an ORM model straight into an Inertia page prop now warns in development, once per
model class, if that model declares neither
hiddennorvisible. Declaring either silences it — and is the fix, not the silencer. Production is unaffected.
Added
-
One project, one Bun.
engines.bunis a floor and nothing enforced it, so an app could be served by one runtime and tested on another — the shell'sbunand anode_modules/bunput there by a transitive peer dependency nobody declared. A green suite is then evidence about a binary the app is not served by.startZerotal()refuses on a mismatch, andzt testspawns the binary running it rather than a namePATHresolves. -
zt db:backup— a verified snapshot of the SQLite database, usingVACUUM INTOrather thancp. Copying a live SQLite file can capture a half-written page and produce a backup that restores as a corrupt database, months later, from the one file you were relying on. Every snapshot is opened and integrity-checked as it is written,--require-rowsfails a backup whose business tables are empty,--rehearseperforms the actual restore, and every failure path exits non-zero — a backup timer that reports success while writing nothing is worse than no timer at all. See Deployment. -
DeployTarget.preflight— a slot for the app's own release gate, run after the config validators anddoctorand before anything is built or migrated. A command namedrelease:checkis found by convention, with nothing to wire up. A declared name that is not registered fails the deploy rather than being skipped: a gate nothing calls is a comment. -
zerotal/shared— the helpers with no server in them, importable from a browser bundle:pluralize,Str, and newformatMoney/formatNumber/formatDate. A total that readsR 39 147on screen andR39,147.00on the invoice looks like two different numbers to the person paying it, and maintaining that in two files is how it happens. See Helpers. -
<form data-enhance>— a plain server-rendered page, with no Flow component on it, can submit without the page flashing. It posts throughfetchand the matching form in the response replaces it in place, so a validation error lands where the person is looking. Its own dependency-free bundle at/__flow/enhance.js, added withflowEnhanceTag()in the layout. Every path degrades: a network failure re-submits natively, a redirect is followed andpushStated, and no JavaScript at all is an ordinary form post. -
Three new
doctorchecks. A rate limiter that cannot tell two people apart behind a proxy — where the socket address is the proxy's for every request, so one attacker can lock out everybody. Auth columns missing from a table a migration built without them, which otherwise surfaces asno such columnin tests that have nothing to do with email. And migrations that have not run, named, before a request finds out.
Changed
-
A model reaching Inertia page props says what it is safe to publish. Page props are page source, and
return inertia("Trips/Show", { trip })ships every column of the row — the internal cost, the margin, the note about the customer, on the customer's own screen. The ORM'shidden/visiblelists were already honoured and nothing said so. See the upgrade note above and Inertia props. -
A bound field the model will not accept says so.
flow:modelon a column missing fromfillablewas dropped in silence: the form submitted, nothing was written, nothing failed. The drop stays — the same path receives whatever a browser sends — but a developer's typo no longer produces the same silence as a hostile payload. Development only, once per field. -
INTERNAL: 116 exports leave the recorded API surface across
core,orm,flow,admin,flow-uiandmonitor. Nothing is removed and nothing breaks — they are still exported and still work; what changes is the promise. The dev orchestrator, the ORM's connection wiring and dialect layer, the admin panel's page machinery and Flow's wire-protocol frame types are not things an app constructs, and naming them in astablesurface implied a guarantee about a protocol that is free to change. Each package's own changelog lists its share. -
A minor breaks nothing that can wait. The roadmap used to say a minor never breaks anything, which was false when written — three breaks had already shipped in minors, each deliberately, each with a note, exactly as the support policy has always described. An absolute rule the project knowingly broke is worse than an honest one.
Documented
-
Every promised export is documented — 100%, up from 60%.
maturity: stablemeans an export keeps its shape for the rest of the 1.x line, and the gate measuring how much of that promise was written down stood at 798 gaps. It is zero.Four features turned out to have shipped and been invisible. Passkeys —
PasskeyServicehas been here since 1.7.0 with no page at all, including thatrequireUserVerificationdefaults totruebecause that is what makes a passkey a second factor rather than one.@zerotal/core/env, a typed environment schema that reports every bad variable at once rather than one per restart. The outboundHttpclient, which the testing guide had been linking to a page that did not describe it. And@zerotal/monitor's Export JSON, where the button was documented and the forty-odd row types it hands you were not.Also named for the first time:
@zerotal/flow-ui's sixty-one component prop types, which a wrapper component cannot be written without.The gate itself could not see
.tsxfiles: withjsxunset, TypeScript declines to pull such a module into the program rather than failing to parse it, so every symbol in one was invisible. It had been inflating exactly the TSX-heavy packages.
Fixed
-
A rebuilt Inertia bundle no longer 404s on a chunk the browser asks for.
resources/js/app.tsxbuilds to/assets/app.jsunder that name every time, whilesplitting: truenames each chunk after its content. A rebuild therefore rewritesapp.jsto importchunk-NEW.jsand pruneschunk-OLD.js— and a browser holding a cachedapp.jsasks for the pruned one:GET /assets/chunk-hrnspqda.js status=404from a page that renders and a server that is healthy, with nothing in that line leading back to the template.
The template hardcodes
/assets/app.jsrather than callingasset(), so the version token the rest of the framework appends never reached it — and cache-busting had only ever been implemented forserve --dev. It now applies in every environment: the file's mtime in dev, where a rebuild happens without a restart, and the boot-derived asset version otherwise. An unchanged asset keeps a stable URL and stays cached, which is why the token is derived rather than random.
1.8.1 — 2026-08-26
DevTools showed you the wrong request, accurately.
Fixed
-
A page keeps the DevTools panel while its own assets load. Opening
/loginselected/login, then/favicon.icoa few milliseconds later, then/css/app.css. Live mode selected every trace as it arrived and a page's sub-resources arrive right behind it, so the bar named a request nobody asked about, the detail below described that request's headers and its empty session, and the page you were inspecting had scrolled into the list. Nothing shown was wrong; it was all about the wrong request.Traces are now classified into three kinds rather than two, because "not the document" would have suppressed the form post and the Inertia visit — the requests most worth watching. What gets skipped over is narrower: a sub-resource the browser fetched on its own initiative. The browser is asked rather than the URL, since an app may serve an API from a
.jsroute and a build that hashes its asset names has no extension to read; what was actually served is the fallback, so a page fetched bycurlstill reads as a page. Anything unclassifiable counts as app traffic, never as an asset — being wrong there decides whether a request is skipped, and skipping the wrong one is how the panel stops showing what you came to see.An asset still takes the selection when nothing else has it, so a panel opened mid-load shows a request rather than an empty pane. A paused panel still counts assets toward its pending badge.
Added
- A
kindfacet on the DevTools All tab, beside method and status. Assets were never the problem, only their claim on the selection, so they are not hidden: pickpageandapifor a list without fifty stylesheet fetches in it, orassetalone for what the browser pulled in, what it cost and which of it 404'd — which was not visible anywhere before.
1.8.0 — 2026-08-24
The first render mode, the codemod runner 2.0 depends on, and five failures that each looked like something other than what they were.
Added
-
static interactive = false— the first rung of Flow's render modes. Every component until now was maximally interactive: rendered on the server, dehydrated into a snapshot, tracked by the client, reachable over a socket. Right for a counter, wasteful for a nav rail. A static component is rendered in full by its parent and nothing else — noonDehydrate, no snapshot, no<script type="application/json">, no entry in the client's registry, and nodata-flow-root, which would freeze it at its first render since its only route to an update is the parent re-rendering it. It takes no place in_childIdsand does not shift its interactive siblings' ids, so no sibling remounts and loses its state when a static one appears above it.lazy,deferandstreamthrow rather than being ignored: each waits for the client to ask for the real render, and a static child never registers to do the asking.Opt-in — nothing existing changes.
this.isInteractivereports the mode from inside the component, and being a new public member it takes that name away from applications; it is on the documented reserved list. See Static children. -
zt upgrade— the codemod runner. The 2.0 ledger's rule is that every entry that can have a codemod has one before 2.0 ships, and until now nothing had been built, which made the ledger a list of changes nobody could afford to make.Dry by default, which is backwards from most tools and deliberate: it rewrites source across a whole project, and the first run should be something you can read and disagree with.
--writeapplies it. Nothing is written until the whole plan is known, so a run that fails halfway leaves no half-upgraded tree, and--dryexercises the same code path as the real thing rather than a parallel one that can drift from it. Codemods see each other's output, because two of them touching one file across a version range is ordinary.What it could not do is the headline. A codemod that walks past what it does not understand is worse than none, since the changes it did make imply the job is finished. So every codemod returns two lists and the runner prints the second last and loudest, with file, line and a reason. The first codemod covers the deprecated aliases —
BaseModel→Model,routes:types→route:types,serve --dev→dev. See Commands. -
--cleanonassets:buildandinertia:build. Pruning is conservative by default: chunk-shaped filenames, plus whatever the last build on this machine recorded in.zerotal/. That cannot recognise output some other naming produced.--cleanneeds no record — the output directory belongs to the build, and what the build did not write does not belong in it. It refusespublic/and the project root, where deleting what was not rebuilt takes the app's images and favicon with it, which is the one failure here that building again cannot undo. Pruning stays the default; only you can say the directory holds nothing else. -
Agent skills, from
@zerotal/arch.AGENTS.mdis short because every prompt it lands in pays for its whole length, so it points rather than teaches. That has a cost: an agent gets a map and no detail, and the detail is where the expensive mistakes live. A skill is a file with a one-line description that costs nothing until an agent decides it is relevant, so a procedure can be written out in full. Two ship — one on changing the schema (who owns it in your app, the mixin columns nothing declares, and why an unguardedALTER TABLEcollides during a release'smigrate) and one on shipping a release (naming your own deploy steps, replacing the asset directory rather than merging into it,trustedProxiesbehind a proxy, and the pipe that hides a test suite's exit status).Written to
.agents/skills, plus.claude/skillswhen that agent is detected. To replace one this ships, edit it and delete its marker line — aSKILL.mdwithout the marker is yours and is never rewritten.ArchConfig({ skills: false })turns the feature off. Runzt arch:updateto install them. -
zt doctorreports agent instructions that no longer describe your project. Every fact in the generated block moves without anyone thinking about the file: add a migrations directory, turnsynchronizeoff, install a package. It goes on reading as current while describing the app you used to have, and guidance that is confidently out of date gets followed rather than questioned. Skills rot the same way and are easier to miss, since nothing reads one until an agent decides it is relevant — by which point it is being acted on. The check regenerates both and compares, and nameszt arch:updateas the fix. A warning, not a failure: it misleads a reader, it does not stop the app working.
Changed
-
A Flow page with nothing interactive on it opens no socket. Every page connected at boot, unconditionally — so a marketing page, a docs article or a rendered report held a WebSocket per visitor, open on both ends for the life of the visit, to carry nothing. Both paths that write to the socket take a
FlowComponent, so with none registered there was not a frame that could be sent. The connection is made when something needs it now: after the initial scan, after an SPA navigation, after a patch registers a child.<Link navigate>fetches over HTTP, so a static page with links stays disconnected. A routed page honours the same static, which is the half that matters — a page is a component, and one whose children are static but which is interactive itself still connects. -
The
@zerotal/archagent block describes how your app is set up, not only what it installed. A package list answers "what is available here", which is not the question that decides what an agent should write: the framework's contracts are not uniform across projects, and the places they differ are the places where guessing wrong compiles cleanly and fails at runtime.AGENTS.mdnow states the four facts that change an instruction — who owns the schema, whether route names are typed, whetherexactOptionalPropertyTypesornoUncheckedIndexedAccessare on, and whether there are tests to run. Read off disk rather than from a booted app, because a project that will not boot is often why the agent surface is being installed..envis deliberately not among the files read: this output is committed and pasted into prompts, and a detector that reads secrets is one refactor away from emitting them. Re-runzt arch:updateto pick it up.
Fixed
-
No mail could be sent over port 587. A STARTTLS upgrade hands back a new socket and leaves the old one attached, still firing its callbacks — and what that one delivers from then on is the undecrypted TLS stream. Both sets of handlers appended to a single reply buffer, so handshake records and ciphertext sat in the middle of the server's replies and no line in the buffer matched a reply any more: the driver waited out its timeout without ever parsing the
250, and the server logged a connection lost after STARTTLS. Measured, 1,737 bytes of ciphertext went into the discarded socket's handler while the TLS handler received the replies.closeanderrorwere worse thandata. The plaintext socket ending is a normal part of handing over to TLS, and it marked the live connection closed — rejecting whatever was waiting on the session that had just replaced it. Each set of callbacks now captures the generation it was installed for, and an upgrade bumps it. -
An Inertia
303redirect left the browser doing nothing at all.X-Inertia: truewas set inside the 302-to-303 conversion, so it only ever reached a redirect that arrived as a 301 or 302 from a non-GET handler. A handler returning the 303 the protocol asks for skipped the only line that marked its response — andredirect(to, 303)is what Authentication tells people to write, in eight places. The form submitted, the row was written, the mail went out, and the fields stayed filled in: a hang from both ends, which is the worst shape a failure can take. Marking now happens for every redirect status on an Inertia request, with the conversion a separate decision on top of it.307and308are marked but left alone, since preserving the method is the whole reason to choose them. -
Answering the busy-port menu killed
serve --devon the spot. The banner printed, thenexited with code 1, and nothing said why. Reading a prompt locks Bun's stdin stream, and the lock is deliberately held for the life of the command so a second prompt can still read — so the dev deck taking the terminal over threwReadableStream is locked. It died inside the alternate screen buffer, and restoring the terminal on the way out erased the error along with everything else drawn there, which is why this was reported as "it just exits" rather than as the error it was. The prompt hands stdin back where it took it; a deck that still cannot have stdin degrades to streaming rather than dying, and a dev-mode failure stops the deck before it reports. -
Two builds sharing an output directory deleted each other's files. Nothing forbids
inertia:buildandassets:buildwriting to the same place, and the defaults invite it: one writes topublic/assets,app.assets.outDiroften names the same directory, and the default release pipeline runs them one after the other. The record of what to prune was one flat list per directory, so each build read the other's files as its own previous build and removed them. The release ended with whichever ran last and nothing reported a problem — the build that lost still said "Build complete" on its way out, and the page it served then 404'd its own script. The record is keyed by entry point now: a file another build claimed is not this one's to remove, while chunks nobody claims are still swept. -
zt doctorfailed a schema configuration that works. Sync on plus migrations present read as "the schema needs exactly one source of truth", which misses the documented arrangement where sync builds the schema from the models so a fresh clone runs without a migration step, andsynchronizeis an expression that is false in production, where the deploy runsmigrate. The two never apply in the same environment. It fails in production now, where the deploy really does run both, and warns elsewhere. A check that cries wolf against a correct configuration is what stopszt doctorever being trusted to gate a deploy.
Documented
- Replace a release directory, do not merge into it. Chasing 195 orphaned chunks on a server showed the build was never the problem — ten releases of a code-split app into one directory hold steady at the build's own output, and every one of the reporting app's 68 chunks is referenced. What accumulates is the release: the archive is extracted over the running directory, so every file in it is written and every file not in it is left alone, and nothing on that machine ever runs a build. They stay publicly fetchable at their content-hashed URLs, which is how copy that was taken down went on being served. Deployment now gives the two spellings that replace the directory.
1.7.5 — 2026-08-23
Two bugs that shipped to every deployed app, a package promoted to stable, and
the gates that would have caught both.
Changed
-
@zerotal/archisstable. Reviewed ahead of its 1.9.0 date. The API follows SemVer strictly from here, and that promise covers the MCP tool contract — tool names, their arguments, and the shape of what they return. That is what an agent client is configured against, and nothing type-level can see it:archToolshas the same signature however the tools are named.mcp-surface.mdrecords all nine and CI diffs it on every change. The protocol revision the server speaks is not covered; it follows the protocol. -
INTERNAL — the writers behind
arch:installare no longer public API.detectAgents,applyMcpConfig,applyBlock,buildGuidelinesand the rest are@internal: still exported, still working, no longer promised. Their only caller is the install command, and freezing them would have committed the shape of.mcp.jsonwriting to the rest of the 1.x line on behalf of a caller who never arrived. -
INTERNAL —
api-surface.mdhonours@internalacross every package. The contract has always read "anything importable without an@internalmarker keeps its shape", and the generator did not read the tag — so symbols already marked internal were recorded as though promised. 374 entries across 13 packages are omitted now, every one verified marked. Nothing changes at runtime or in the types; the file listing the promises now lists the promises. -
A modal locks the page behind it.
<Modal>and<Drawer>trapped focus correctly while the page underneath kept scrolling, which on a phone reads as the dialog having broken the page. -
Flow marks the active nav link for everyone.
<Link navigate>setdata-current, which styles a link, and nothing that announces it. It setsaria-current="page"alongside now, so a screen reader can tell which of thirty nav items is the current page.
Fixed
-
Assets were cache-busted in development and not in production.
asset()appended?v=only when a dev version was set, so every deployed Zerotal app served the previous build's JavaScript and CSS to anyone with a warm cache — indefinitely, since the URL never changed. The version is now derived from the built files themselves, so it is stable across restarts and moves when the files do. -
DevTools mounted on production pages. The provider is gated on the environment, so the endpoints are absent outside development — and the client took that to mean it could start anyway, pinning a floating panel to the page whose tabs read
Could not read the map — HTTP 404. It now mounts only when the server half says it is there, via a<meta>the middleware writes, and makes no request at all on a public hostname. -
Browser tests drove an unstyled site.
Router.static("/", public)is registered only for thewebenvironment, and a test app is not one — so everyFlowBrowsersuite served pages without their stylesheet. Invisible to assertions that read text; fatal for anything measuring layout.
Documented
-
Every TypeScript example in the documentation is compiled against the real packages, on every pull request. 1,593 blocks. The gate found examples importing symbols that do not exist (
currentUser,Layoutfrom the wrong package), calling methods that were renamed (Cache.put), and configuring fields withenv()where the type is a literal union. Blocks deliberately written as fragments say so in their fence and are recorded by key, so a new one is a deliberate act rather than a silent exemption. -
A break cannot ship without a release note.
api:surface:checkdemands a regenerated snapshot when an export changes and then goes quiet, so the changelog was defended by remembering — and 1.7.3 shipped the removal of Flow'sthis.title(…)with no BREAKING entry. That entry is now in 1.7.3's notes, the support policy counts three breaks rather than two, andbreaking:checkreads the snapshot diff so the next one cannot pass silently. -
A maturity label falls due. The review release for a package below
stablelives in itspackage.jsonasmaturityReview, and the package-conventions gate fails once the version reaches it.
1.7.4 — 2026-08-21
A debug panel that was reaching production, a column type MySQL would not index, and 2,060 icons.
Fixed
-
DevTools no longer appears on a production page. The provider is gated on the environment, so in production its routes are absent — and the browser client took that as permission to start anyway and "connect to nothing". It did not: it mounted the panel first and discovered the absence afterwards, so an app calling
DevTools.start()unconditionally served a floating DevTools bar to every visitor, its tabs readingCould not read the map — HTTP 404.start()now probes for the routes and builds nothing unless they answer — no shell, no shadow root, noEventSource, no listeners. Any failure (404, offline, CSP) is read as absent. If your app callsDevTools.start(), take this release. -
A string column could not carry an index on MySQL.
table.string()compiled toTEXTon every engine and discarded itslength, and MySQL refuses to key a TEXT column without a prefix length — sotable.string("email").unique()failed atCREATE TABLE. MySQL now getsVARCHAR(length); SQLite and PostgreSQL keepTEXT.char()had the same bug and the same fix. Found by the new MySQL suite on its first run against a real server.
Added
-
<Icon name="inbox" />— 2,060 icons, bundled, typed by name. The set ships inside@zerotal/flow-ui, so there is nothing to install and no generator to run: a fresh app gets autocomplete over every name and a compile error on a typo. Rendered on the server as inline SVG, so there is no icon font, no sprite, no request per glyph, and nothing for a strict CSP to block. Four icons are drawn for sign-in flows the set has no name for —passkey,two-factor,otp,magic-link— and three brand marks ship for the social-login providers@zerotal/authsupports. See Icons. -
The ORM suite runs against MySQL 8 in CI, and the job blocks merges. The same smoke suite that covers PostgreSQL — schema DDL and
ALTER, identity columns, CRUD, type round-trips, unique and NOT NULL enforcement, row locks, transaction rollback. MySQL moves from experimental to supported, hardening; see the Support Policy.
Changed
- The starters link by route name. Every hard-coded
href="/about"in the React and Vue templates now goes throughroute(), and the templates ship the generated route table so a freshly scaffolded app type-checks before its firstzt dev.
Documented
-
The HTTP client guide is one page. Eight pages became one, written from where the package is used — your app calling somebody else's service — with straight URLs instead of a route map threaded through every example. See HTTP Client.
-
route()in Inertia, for links and for form submissions, including the one thing Inertia adds: a page renders in two processes, sodefineRoutes()has to run in the SSR entry too. See Building URLs. -
Every package changelog has the release headings it was missing.
[Unreleased]had accumulated four releases of shipped work —@zerotal/flow-ui's newest heading read[1.5.0]while 1.7.3 was on npm. Cutting a release now moves them.
1.7.3 — 2026-08-20
Two fields that accepted input and threw it away, a CI job that was testing nothing, and a name given back to applications.
Changed
-
BREAKING —
this.title(…)is removed from Flow components. Declarestatic titleinstead, as a string or a function of the component:// Before override async mount(): Promise<void> { this.title(`Search: ${this.query}`); } // After static title = (c: SearchPage) => (c.query ? `Search: ${c.query}` : "Search");The instance method held a name four separate components wanted for their own data — a media row, a guide, a review, an issue — for a one-line accessor that belongs on the class. The static form is also the better one: it is resolved on the server for every render and every patch, so a title that depends on state follows it without an action remembering to update it.
A call to
this.title(…)on a component that declares its owntitlefield now sets that field instead of the document title, which is silent. Search your components forthis.title(before upgrading; every hit is either a migration or was already shadowed.
Fixed
-
A boolean column could not hold a boolean on PostgreSQL.
table.boolean()compiled toINTEGERon every engine — right for SQLite, which has no boolean type, and rejected by PostgreSQL, which has a real one:column "active" is of type integer but expression is of type boolean (42804)Every insert of
truefailed, and so did everywhere("active", true). The storage type now comes from the dialect, as the auto-increment column already did. SQLite and MySQL are unchanged — MySQL'sBOOLEANis a synonym forTINYINT(1)andINTEGERtakes 0/1 either way, so there was nothing broken there to fix.Existing PostgreSQL tables keep their integer columns. New tables get
BOOLEAN; a table already created needs anALTERif you want the column converted:ALTER TABLE posts ALTER COLUMN active TYPE boolean USING active <> 0; -
A bound password field discarded every keystroke. Flow's client-writable set was
fillableminushidden, which conflates two allow-lists answering different questions:fillablegoverns what may be written,hiddengoverns what may be shown. A password is in both, so subtracting made it unwritable —<input type="password" value={this.user.password} blur />accepted typing and dropped it on arrival.hiddenis no longer subtracted. It is still never sent: the stored hash does not leave the server and the field arrives empty. A hidden value the client supplied survives until save; one the server produced is never echoed back, and a half-typed one is stripped from the durable snapshot before it is persisted.
Changed
- The PostgreSQL CI job blocks merges. It had been running the ORM suite beside a Postgres container without connecting to it, so it reported success without testing anything. A smoke suite now exercises schema DDL, identity columns, CRUD, type round-trips, row locks and transaction rollback against a real PostgreSQL 16, and a failure fails the build. The boolean defect above is what it found on its first real run.
Documented
- Flow pages take their model from the route, not from a query. The docs opened every
model example by fetching the record in
onMount(), which predates a route being able to hand a component the record.models.mdleads with the bound form;lifecycle.mdno longer presents the old id-plus-onHydrate-re-query as the correct pattern. The old shape still works — it is simply two fields and a query doing what one field now does.
1.7.2 — 2026-08-18
Realtime that works without being wired up, and three ways a socket could go quiet without saying so.
Changed
-
BREAKING — Flow's
@onbroadcast listeners use asocket:prefix.echo:,echo-private:andecho-presence:are nowsocket:,socket-private:andsocket-presence:; the browser global iswindow.Socket, notwindow.Echo. There is no alias — an unrenamed listener never matches, and never subscribes.- @on("echo-private:issues.5,CommentPosted") + @on("socket-private:issues.5,CommentPosted")Shipped in a patch release deliberately, on the judgement that the old prefix has no meaningful use in the wild. If you are on it, the upgrade is a find-and-replace of
echo:→socket:in your@onlisteners andwindow.Echo→window.Socketin any client code.
Added
- Flow bundles the socket client into its runtime. A page that declares a
socket:listener is live with no script of your own. Flow apps own no bundle entry, so the contract used to be "publishwindow.Socketyourself" — and when you didn't, the listeners were silently inert: no error, no warning, no subscription, so a live feature with no script looked exactly like a live feature that was never written. An app that needs a configured client still assignswindow.Socketbefore the runtime loads and that one is used as-is; a page with no listeners opens no connection at all.
Fixed
-
A patch no longer writes back into a file input. A file input's
valuebelongs to the user agent, and assigning anything but""throwsInvalidStateError. The write was legal while the bound property was empty and threw on the very patch carrying an upload's result — and the throw escaped the frame handler, so the DOM never updated and the action's ack never resolved. Since frames are chained per component, every later action queued behind a promise that would never settle: the page rendered correctly and ignored every click for the rest of its life. -
WebSocket connections get an explicit 120s
idleTimeout. Bun closes an idle socket after 10 seconds; the client pings every 30. A connection that was merely quiet got cut before it had reason to speak, taking its channel subscriptions with it — so anyone who had been reading a page for more than ten seconds silently stopped receiving broadcasts.
1.7.0 — 2026-08-16
The agent surface, a DevTools panel that shows the framework and not just the last request, and the repayment of four things the 1.x line had promised without delivering.
Added
-
@zerotal/arch— an MCP server that hands a coding agent the framework's own truth. Not a documentation search over prose about an API:api_surfacereturns the exact TypeScript signature of every export, read from the version installed in your project and diffed by CI on every change. Alongside it,search_docsover the documentation that shipped with that same version,routesandschemaread from the live router and the models' own metadata,logs/last_errorfrom the app's structured trail,baselines, anddoctor— the one an agent is meant to finish a task with, because every finding carries its fix.bun add -d @zerotal/arch bun zt arch:install # writes .mcp.json, AGENTS.md, and a CLAUDE.md shimRe-running is safe: every generated region is marker-fenced, so
arch:updateon your next upgrade replaces what it wrote and leaves anything you added around it alone. Shipsbeta. See Agent Surface. -
DevTools grew an App section. Every surface until now read the request stream — what one request did. Six new tabs behind a Requests | App switch answer what the app is: routes, resolved config with secrets masked, container bindings and which provider bound each, provider boot cost, event listeners, and console commands with scheduled tasks. Every location in the panel is now a link into your editor.
-
Security headers cover static files. Files under
public/are handed to Bun as pre-registered responses and served without entering JavaScript, so no middleware ever ran for them — every asset went out with noX-Content-Type-Options: nosniff, the response class sniffing protection exists for. The header set is baked into the compiled response, so Bun still serves the file natively. -
zt doctor --urlreports security headers sent twice. A header your app sets and your proxy also sets is invisible from inside the process. Conflicting values fail the check — browsers do not agree which copy applies, so the control is enforced inconsistently — and identical duplicates warn. -
DeepPartial<T>, exported from the kernel.deepMergedoes a deep merge and its parameter saidPartial<T>, which only makes the top level optional — so overriding one field of a nested config block was a type error against a merge that handles it perfectly.
Fixed
-
Migrations are now actually transactional. The runner wrapped each
up()in a transaction and the docblock promised all-or-nothing, but the wrapper governed nothing:Schemaresolved the global connection, so a migration's DDL ran on a pooled connection and committed independently. On PostgreSQL, a migration failing on its third statement left the first two behind and theROLLBACKhad nothing to undo. DDL now joins the enclosing transaction, the tracking-table row is written inside it, and rollback carries the same guarantee. MySQL has no transactional DDL, so the runner no longer opens one there andzt migratesays so before it starts. See Migrations → What happens when a migration fails. -
BaseMiddleware.with()type-checks its options. Its options type was inferred from the object literal it was handed rather than from the middleware class, so the literal was checked against itself: every callback parameter arrived implicitlyany, and a misspelled option was accepted in silence. -
SPA navigation no longer leaks the outgoing page's state script. The swap removed the first
flow-state-*element in document order, which on any page with a child island was the island's, not the page's. The orphans accumulated one per navigation for as long as the tab stayed open.
Changed
-
DDL issued inside
DB.transaction()now joins that transaction. PreviouslySchema.create()and friends resolved the global connection and committed separately. This is the fix above, and it applies to any code — not only migrations — that issues DDL inside a transaction. -
Component._skipMountis gone (@internal). It was written byhydrate()and read by nothing; mount-skipping is structural, and$refresh/$mountdeliberately re-mount a hydrated page, so honouring the flag would have broken both.hooks.test.tspins the real guarantee — mount runs exactly once per session.
1.6.3 — 2026-08-15
Two guards against the same failure: an upgrade sitting on disk while something older keeps running, with nothing on screen to say so.
Added
-
serve --devreports a framework upgrade it has not picked up. A running dev server holds the code it imported at boot, sobun add zerotal@latestin another terminal changesnode_modulesand nothing else — a save restarts only the worker, against the same in-memory framework. The upgrade therefore appears to do nothing. The supervisor now names both versions and says to restart, and the dev banner carries the version it is running:Zerotal v1.6.3 › dev. -
create-zerotalsays when it is not the published scaffolder.bun create zerotalcan serve a copy cached from an earlier run, and a stale scaffolder stamps the dependency ranges it shipped with — so a brand-new project is created against versions that are no longer current, while the install log shows today's framework resolving inside those ranges. It now checks the registry and names the fix:bunx create-zerotal@latest <name>. Advisory only — offline, firewalled and slow all mean "no answer", and no answer never stops anyone creating an app.
1.6.2 — 2026-08-15
Fixed
-
serve --devnow stops its worker on Windows instead of killing it. Restarting sentSIGTERM, which Windows has no way to deliver — there the call terminates the process where it stands, so on every save no provider drained, no open response was finished and no database handle was closed. The supervisor asks over an IPC channel now and only kills if that goes unanswered. Nothing changes on macOS or Linux beyond the mechanism. -
The devtools panel no longer fills the console with network errors. Its event stream was abandoned on shutdown rather than closed, leaving the browser with a truncated response and a
net::ERR_INCOMPLETE_CHUNKED_ENCODINGfor every reload. The stream is closed properly now, and a heartbeat keeps an idle one from being dropped with nothing written for either end to notice by.
1.6.1 — 2026-08-15
Fixed
-
The Inertia DevTools panel said the app was not in dev mode, and suggested starting a Vite dev server — advice that cannot be followed in a Zerotal app. The cause was real though: the Inertia adapter turns its client-side hooks on from a
devoption that defaults toimport.meta.env.DEV, a Vite convention that Bun's bundler leaves alone, so it survived into the bundle and evaluated tofalseon every build.Zerotal now defines
import.meta.env—DEV,PRODandMODE— for every bundled browser build. Nothing to configure and nodevoption to pass by hand; rebuild and the panel works. See Inertia DevTools.
Changed
- New React and Vue apps scaffold Inertia 3. The panel's client half — visit options,
prefetch-cache entries, and the grouping that tells a poll apart from a navigation — exists
only in the version 3 adapters, and neither template needed a single edit to build against
it. Existing apps are unaffected;
bun add @inertiajs/react@^3(or@inertiajs/vue3@^3) is the whole upgrade if you want the client half.
1.6.0 — 2026-08-15
Added
-
route()works in the browser. The typed helper now has a twin atzerotal/routes. Hand it the tablebun zt route:typesalready generates, once, at your entry point, androute("posts.show", { slug })works in a component exactly as it does in a controller.hasRoute(name)answers the conditional-link question without a try/catch.The two are one implementation, not two that agree today: param encoding, catch-all handling and every error message live in a shared builder, and only the table lookup differs — the live router on the server, the generated map in the browser. A parity test asserts they emit byte-identical URLs and identical error text. See Routing.
-
$route()in Flow's Alpine expressions —<a :href="$route('posts.show', { slug })">, with nothing to install. Inertia apps import their table;/__flow/runtime.jsis built by the framework rather than your app, so the runtime handler serialises the table onto the bundle it serves instead. Same builder as the server, so a link written in an Alpine expression and one written in JSX cannot disagree about encoding. -
Inertia DevTools. A server-side recorder for the Inertia DevTools browser extension: requests, resolved props, and which wrapper produced each one. Off unless the process already exposes dev surfaces — the same gate as the stack-trace error page — and an app that enables it without saying who may read it gets a 403 rather than an open endpoint. Redaction runs before storage, so a withheld value is never written down. See Inertia DevTools.
Fixed
-
Ten more places asked
APP_ENVa question it cannot answer, found by auditing every reader rather than waiting for the next report.APP_ENVholds the runtime mode once the app has booted, so a check comparing it against a deployment name was asking whether"web"is production. The consequences were real:- auto-
synchronizewas never hard-off in production — the only thing between a production database and boot-time schema sync was the config default; - the Flow client bundle was never minified in production, shipping ~183 KB unminified to every visitor;
forceState()did not refuse to run on live data;- environment-scoped scheduled tasks never ran —
.environments(["production"])matched nothing, silently; - the admin environment badge showed
webon every screen, so the one mistake it exists to prevent — editing production believing it is staging — was exactly what it could not prevent.
All read the deployment name now, and every one still fails closed. Reading
APP_ENVdirectly is a lint error from this release, because fourteen instances of one mistake across seven packages were each found separately. - auto-
-
useOnce()no longer demands a cast. Registering middleware from a provider requireduseOnce(Middleware as never)in all eight packages that do it — a cast the framework was asking for. Twelve of them are gone, and the casting-debt baseline came down with them.
1.5.1 — 2026-08-15
Fixed
-
Development surfaces were switching themselves off. A scaffolded app with
APP_ENV=developmentin its.envgot production error pages frombun zt serve, and DevTools never appeared at all — in any app, in any mode. The admin panel's development bypass and the monitor's open-by-default access were dead for the same reason.All of them asked
APP_ENVwhether this was a development environment, butsetAppEnv()replaces that variable with the runtime mode (web,console,worker) before the app is created — so the question being asked was whether"web"is development. They read the preserved deployment name now. Production and staging are unaffected: every one of these gates still fails closed, and an unset environment still fails closed.If you upgrade and suddenly see the DevTools panel, that is the fix, not a new feature.
1.5.0 — 2026-08-15
The largest release of the 1.x line: a new package, three features, a batch of
production-hardening work that came out of a real deployment, and the last of the
packages reaching stable. Of the 26 published packages, 25 are stable and one
is experimental (@zerotal/ai); none is beta.
Added
bun zt deploy:<env>— a release that refuses to finish when something is wrong. Four phases, ordered so that everything that can refuse runs before anything that mutates: preflight (is this really that environment, would this config refuse a production boot, doeszt doctorpass), build, migrate, verify. It exits non-zero and does not restart your service — systemd or your container runtime owns that, and this gives it a gate to restart behind. Every environment gets its own command;productionandstagingexist without configuration, andconfig/deploy.tsdeclares more. The target name is checked against the deployment the process was started as, sodeploy:productionon a staging box stops before it migrates the wrong database.--dry-run,--skip-migrationsand--probeare there. See Deployment.zt doctorchecks CORS and HSTS.app.cors.origin: "*"lets any site read your responses out of a visitor's browser;app.secureHeaders.securegates HSTS and defaults to off. Both now fail on a production-like deployment.@zerotal/ai— a typed agent loop, shippingexperimental. One loop shared by every driver, so switching models is a config change rather than a rewrite. Apause_turnis resumed rather than mistaken for an answer; a refusal is a typed outcome checked before anything reads the content; schema translation decides what a provider can express instead of hoping. Named agent runs take a refreshable lock, spend ceilings and prompt redaction are first-class, andAiFakemakes the whole thing testable without a network. It shipsexperimentaldeliberately — the surface is expected to move inside 1.x, and the support policy says what that means. See AI.- Typed route names —
bun zt route:types. The command boots the app, reads the routes it actually registered, and writestypes/routes.generated.ts. With it,route("psots.show")is a compile error androute("posts.show", {})names theslugit wants. Params come from the pattern, so adding a segment updates every call site. It boots rather than scanningroutes/because a route name comes from three places and only one of them is a file path. See Routing. - Typed Inertia pages.
Inertia.render(component, props)is checked against the page component's own props, and the prop wrappers (defer,optional,always) are generic, so a renamed or retyped prop fails at the render call rather than in the browser. See Inertia. - The development error page can say what to do, not just what broke.
no such table: assetsis exact about the failure and useless about the cause — every frame in its stack sits inside the SQL driver.registerErrorDiagnoser()lets the package that owns an error contribute a diagnosis above the stack;@zerotal/ormregisters the first one, turning a missing table into the list of migrations that have not run, with a button to run them. See Errors. bun zt dev— the server and every companion process in one terminal, with the Deck, a tabbed dev UI that adds no dependency. The queue worker runs as its own tab. A service provider contributes its own checks throughdoctorChecks(). See Devtools.- Flow:
<ErrorBoundary>,stream,<SectionContent>/<SectionOutlet>, and<Virtualize>. A failing child now costs that child rather than the page; a slow child no longer holds up the shell; a page can fill a region its layout owns; and a collection too large for the DOM gets a scrolling window over it.@zerotal/flow/browserdrives a real browser against a running app, and a compiled-versus-runtime parity suite keeps the two renderers honest. See Flow. - ORM:
migrate:refresh, and--seedonmigrate/migrate:fresh. See Migrations. - Queue: debounced jobs.
debounceon aJobcollapses repeated dispatches into one run. See Queue. - Scheduler: durable run history, so the monitor panel survives a restart. See Scheduler.
- Media:
allowEnlargementon a conversion, and@zerotal/media/testing.ImageDriveris frozen, with its growth rule written down. See Media.
Changed
Most of this section is one body of work: the response to a Flow field report, hardening the path from a local machine to a deployed box.
app.allowedOriginsis declared config and defaults to the origin ofapp.url. The common deployment no longer needs to configure it at all, and the setting is visible where the rest of the app's URL configuration lives rather than being implied.bun zt doctor --url=…probes a deployed transport from the outside. It reports what each transport path actually answers over the wire, which is the question a failing WebSocket upgrade in production actually raises.Application.declareWebSocketPath()/webSocketPaths()let a package declare its own path so the probe covers it, and Flow declares/__flow/wsat registration. See Deployment.serveno longer rebuilds assets at boot in production, and Flow no longer rebuilds its CSS/JS bundles at boot, when the output directory is read-only. A read-only tree is normal for a container image, and building at boot turned it into a crash.bun zt assets:buildis the explicit build step to run before deploying. See Assets.- The Flow client says which transport failure it hit rather than failing the same way for
every cause, and
data-flow-connectionis stamped on a page that connected normally — so "is it live?" is answerable from the DOM. route()takes query values as a third argument —route(name, params, query)— androute.dynamic(name, params?, query?)covers a name that is not known at compile time.ctx.useris typed asUserModel, the same interfaceAuth.user()returns.SessionContract.getandpulltake an optional<T>.withoutOverlapping's cross-process lock defaults to 5 minutes, not 24 hours. A worker killed mid-run used to block its own schedule for the rest of the day.app/commands/is auto-discovered, and boot warns about aroutes/directory nothing routes.- Thirteen packages reached
stable—admin,audit,broadcasting,devtools,flow,flow-ui,i18n,inertia,media,monitor,notifications,telemetryandtenancy— each after documenting its remaining exports and marking its plumbing@internal. The component reference now documents all 53flow-uicomponents and cannot drift again.
Fixed
- Flow: a decorator could be registered against the wrong component. Field decorators cannot
see their own class, so each registration is buffered and matched to a class afterwards — and
the match searched one flat buffer by field name. A component that declares a field and is never
rendered leaves its entry there for the life of the process, so an unrelated component with a
field of the same name could claim it and never receive its own. It showed up as
@reactivesilently failing to register, which remounts the child on every parent-pushed change rather than updating it in place. Matching is now per declaring class, and on the fields a class declares rather than everything on an instance. - Flow: a keyless child in a list was identified by its position. Reordering a list without keys reused the wrong DOM node, so state attached to a row followed the position rather than the row.
- Flow: a client expression that writes an
@exposeprop now syncs to the server. - ORM: a
Datein a query-builder write was silently discarded. - ORM: altering a Postgres column silently dropped its
NOT NULLandDEFAULT, and SQLite now refuses an impossibledropColumnbefore applying anything rather than partway through. - ORM: the N+1 detector reads the bindings, not just the SQL text, so it stops missing queries that differ only in their parameters.
- Cache: stampede protection survives a compute slower than 30 seconds.
- Media:
fit: "cover"works on the default driver,fit: "inside"returns the dimensions it promised, andfit: "fill"with a single dimension behaves asinside. Both shipped drivers are held to one parity suite. serve --devbuilt a Flow app's bundles three times on every start, and dev asset builds are now skipped when nothing changed.- A weak
APP_KEYnever refused a production boot, and N+1 detection ran in production. Both askedBun.env.APP_ENVwhether this was production — but that variable holds the runtime mode (web,console,worker) by the time anything reads it, so both always got "no". The deployment name is now preserved and read back throughdeployEnv(). stagingwas production for some things and not others — config validation refused an insecure staging boot, while assets went out unminified and were rebuilt at boot, which is exactly the combination that restart-loops on a hardened unit.app.secureHeadersonly allowedframeOptionsto be configured, so there was no supported way to turn HSTS on. Every option the middleware reads is now typed.setAppEnv("dev")resolved toconsolerather thanweb.
1.4.0 — 2026-08-10
Added
- ORM: encrypted columns. A column can hold ciphertext at rest and plaintext on the model,
keyed by
APP_KEYwith AES-256-GCM —@column("encrypted") idNumber?: string, orstatic encryptable = ["idNumber", "passportNumber"]for several at once. Unlikehashablethis is reversible and does not touch the instance, so the property still reads as plaintext aftersave().where()on an encrypted column throws rather than matching nothing (a fresh IV per write means the ciphertext never repeats), and a value the key cannot open fails the read rather than arriving somewhere as ciphertext. See Casts & Mutators. - Auth:
TwoFactor.getQrCodeSvg()renders the two-factor enrolment QR code as an inline<svg>, drawn in-process. Theotpauth://URI carries the TOTP secret, so the previous advice — hand it to a QR image service — posted the second factor to a third party.encodeQr()andqrSvg()are exported for drawing the symbol yourself. See Roles & 2FA. - Flow:
preserveScrollon<Link>andnavigateCurrent(), for a sort header, filter or tab strip partway down a page that should not jump to the top.
Fixed
- Flow:
flow:navigatedid not scroll. The SPA swap replaced the page under a stationary viewport, so following a link from near the bottom of a long list landed you halfway down the next page — which reads as the page having failed to load. A navigation now goes to the top (or to the URL's fragment), and Back and Forward restore where you were. - Flow:
focusOnErrordid nothing on a runtime-rendered page. The JSX runtime rewrote the hyphen inflow:focus-errorto a dot, so the attribute never matched the selector the client looks for. It worked on a compiled page and silently did not on one the compiler bailed out of.sortGroupIdwas affected the same way. - Docs: two column examples named the wrong TypeScript type.
@column("date")hydrates a nativeDate, not aCarbon, anddecimal:Nsurfaces as astring— the ORM overview typed both the other way, whichtsccannot catch because the decorator does not constrain the property type.
1.3.0 — 2026-08-09
Changed — BREAKING
- Mixin composition is now a static on the base class.
ComponentWith(...)andBaseModelWith(...)are removed; writeComponent.using(Pagination)andModel.using(Authenticatable, Roles)instead. A codemod ships in the repository (scripts/codemod-mixin-composition.ts) that rewrites call sites and imports. How mixins are authored is unchanged.usingalso composes onto intermediate bases (AdminPage.using(Pagination)) and chains (.using(a).using(b)), neither of which the old helpers could express. Modelis the canonical ORM base-class name.BaseModelremains exported as an alias for the same class, so existing code keeps working; docs and scaffolding now sayclass User extends Model.
Added
@zerotal/media— attach files to models withModel.using(Media): collections with acceptance rules and retention, image conversions onBun.Image(orsharp), responsivesrcset()ladders with inline placeholders, queued conversion jobs,MediaFaketest assertions, andmedia:clean/media:regeneratecommands. See Media Library.
Fixed
- Flow: an
@exposed action on a shared page base could vanish from the action allowlist (and be fatally rejected at runtime) whenever a subclass declared a decorated field — a Bun 1.3.x decorator defect, worked around in the framework.@expose,@task,@renderless,@onand@computedwere all affected.
1.1.0 — 2026-08-08
Changed
FlowTest.call()rethrows action errors andFlowTest.set()re-renders, so tests fail on broken actions instead of passing silently. A handler pointing at an un-@exposed method is now a build error (fatal at boot in CSP-safe mode).@column("text")maps to a realTEXTtype rather thanVARCHAR— affects newly generated tables and migrations only.
Fixed
- Radio-group binding, reactive sibling attributes suppressing
valuebindings, modifier click handlers,request().ip()inside actions, a data-corruptingjsoncast on numeric-looking strings, and an unparseablemake:modelstub.
1.0.4 — 2026-08-07
- Fixed the Flow starter rendering unstyled (stylesheet path mismatch) and its missing favicon.
1.0.3 — 2026-08-06
- Re-released so npm build provenance resolves against the renamed repository.
1.0.2 and earlier — 2026-08-06
- First published versions of Zerotal.
Next steps
- Upgrade Guide — apply the migration notes for a new release.
- Contributing — how changes land before they reach this list.