@zerotal/arch 1.14.2 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api-surface.md CHANGED
@@ -3,6 +3,9 @@
3
3
  <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
4
  Run `bun run api:surface` to regenerate after an intentional API change. -->
5
5
 
6
+ > Not a guide — this is a flat, alphabetical snapshot used to detect unintended
7
+ > API changes. For how to use these, read the [documentation](https://zerotal.dev/docs).
8
+
6
9
  ## . `(./src/index.ts)`
7
10
 
8
11
  class ArchError = {
package/docs/about.md CHANGED
@@ -84,7 +84,7 @@ bun create zerotal my-app # choose: API, Flow, React, Vue, or Minimal
84
84
  cd my-app
85
85
  cp .env.example .env # APP_KEY is pre-generated for you
86
86
  bun zt migrate # create the database schema (API template)
87
- bun run dev # dev server + hot reload at http://localhost:3000
87
+ bun zt dev # dev server + hot reload at http://localhost:3000
88
88
  ```
89
89
 
90
90
  The scaffolder writes a fresh `APP_KEY` into `.env.example` and installs
@@ -95,7 +95,7 @@ dependencies. Pick a template at the prompt:
95
95
  - **React** / **Vue** — Inertia SPA (see [Inertia](#inertia-react-vue-spa)).
96
96
  - **Minimal** — one page with JSX views on the bare framework.
97
97
 
98
- There's **no build step**: `bun run dev` starts the server, `bun test` runs the suite,
98
+ There's **no build step**: `bun zt dev` starts the server, `bun test` runs the suite,
99
99
  `bun run typecheck` type-checks. More in [Getting Started](/docs/getting-started).
100
100
 
101
101
  ## The shape of an app
@@ -236,6 +236,7 @@ decorators; the table name is derived by convention (so `@table` is optional):
236
236
  ```ts fragment
237
237
  // app/models/Post.ts
238
238
  import { Model, table, column, hasMany, belongsTo } from "@zerotal/orm";
239
+ import type { Carbon } from "@zerotal/core/carbon";
239
240
 
240
241
  @table("posts")
241
242
  export default class Post extends Model {
@@ -243,7 +244,7 @@ export default class Post extends Model {
243
244
  @column() declare title: string;
244
245
  @column("text") declare body: string;
245
246
  @column("boolean") declare published: boolean;
246
- @column("datetime") declare createdAt: Date;
247
+ @column("datetime") declare createdAt: Carbon;
247
248
 
248
249
  @hasMany(() => Comment) declare comments: Comment[];
249
250
  @belongsTo(() => User) declare author: User;
@@ -631,7 +632,7 @@ bun zt migrate:rollback # roll back last batch
631
632
  bun zt migrate:fresh # drop all + re-migrate
632
633
  bun zt key:generate # fresh APP_KEY
633
634
  bun zt queue:work # start the queue worker
634
- bun zt serve --dev # dev server (aliased by `bun run dev`)
635
+ bun zt dev # dev server, hot reload (also `bun run dev`)
635
636
  bun zt list # every available command
636
637
  ```
637
638
 
@@ -658,7 +659,7 @@ The [README](../README.md) has a package-by-package table with links.
658
659
 
659
660
  - **The `zt` CLI is your control panel.** `bun zt list` shows everything —
660
661
  scaffolding, migrations, the dev server, the worker, and tests all run through it.
661
- - **No build, ever.** `bun run dev` / `bun test` / `bun run typecheck`. No compile
662
+ - **No build, ever.** `bun zt dev` / `bun test` / `bun run typecheck`. No compile
662
663
  step to remember.
663
664
  - **Starters are the fastest way in.** `bun create zerotal my-app` scaffolds a working
664
665
  app from one of six starters — `api`, `admin`, `flow`, `react`, `vue`, or `minimal`.
package/docs/assets.md CHANGED
@@ -12,20 +12,20 @@ View, Inertia) has its own pipeline and hot-reload behaviour; all three use
12
12
 
13
13
  Asset compilation is built into the framework — there's no package to install or
14
14
  provider to register for the pipeline itself. You only add Tailwind and wire up a
15
- static route, then `bun zt serve --dev` handles build-and-reload for you.
15
+ static route, then `bun zt dev` handles build-and-reload for you.
16
16
 
17
17
  > **Note** — Projects ship a `zt.ts` entry and a `zt` package script,
18
- > so commands are written `bun zt <command>` (e.g. `bun zt serve --dev`).
18
+ > so commands are written `bun zt <command>` (e.g. `bun zt dev`).
19
19
 
20
20
  ## Getting Started
21
21
 
22
22
  Asset building is part of `@zerotal/core` — nothing to install and no provider
23
- to register. `bun zt serve --dev` builds on change, and `bun zt css:build`
23
+ to register. `bun zt dev` builds on change, and `bun zt css:build`
24
24
  produces a production bundle:
25
25
 
26
26
  ```bash
27
27
  # in your project root
28
- bun zt serve --dev # watch and rebuild
28
+ bun zt dev # watch and rebuild
29
29
  bun zt css:build # one production build
30
30
  ```
31
31
 
@@ -39,7 +39,7 @@ Pick by stack — the dev server runs the right pipeline automatically:
39
39
  production. Dev rebuilds are wired up by `FlowProvider`.
40
40
  - **Inertia** (React or Vue SPA, JS + CSS bundle): `bun zt inertia:build` for
41
41
  production. Dev rebuilds are wired up by `InertiaProvider`.
42
- - **Any stack, development**: `bun zt serve --dev` — initial build, file watch,
42
+ - **Any stack, development**: `bun zt dev` — initial build, file watch,
43
43
  rebuild on save, and a browser reload over WebSocket.
44
44
 
45
45
  ## Tailwind CSS
@@ -113,7 +113,7 @@ returned HTML:
113
113
 
114
114
  ```bash
115
115
  # in your project root
116
- bun zt serve --dev
116
+ bun zt dev
117
117
  ```
118
118
 
119
119
  When `resources/css/app.css` exists, `FlowProvider.onBooted()` registers a
@@ -296,7 +296,7 @@ Inertia Vue plugin so `.vue` single-file components compile.
296
296
 
297
297
  ```bash
298
298
  # in your project root
299
- bun zt serve --dev
299
+ bun zt dev
300
300
  ```
301
301
 
302
302
  `InertiaProvider.onBooted()` registers Inertia's build routine under its own
@@ -409,14 +409,14 @@ stylesheet stays small and the fonts load in parallel. The other accepted values
409
409
 
410
410
  ## Hot reload
411
411
 
412
- Both pipelines above are driven by the same dev process. `bun zt serve --dev`
412
+ Both pipelines above are driven by the same dev process. `bun zt dev`
413
413
  builds once, spawns the server as a child process, and watches the filesystem;
414
414
  what happens on a change depends on which directory the change landed in:
415
415
 
416
416
  ```text
417
- # bun zt serve --dev process model
417
+ # bun zt dev process model
418
418
  ┌──────────────────────────────────────────────────────┐
419
- │ bun zt serve --dev │
419
+ │ bun zt dev │
420
420
  │ │
421
421
  │ Process 1: DevOrchestrator │
422
422
  │ ├─ Initial build (CSS or JS+CSS) │
@@ -699,7 +699,7 @@ test("a stale bundle is told to reload", async () => {
699
699
  | Command | Effect |
700
700
  | ----------------------------------- | ----------------------------------------------- |
701
701
  | `bun zt serve` | Serve the app; assets are built once at boot |
702
- | `bun zt serve --dev` | Dev server with hot reload |
702
+ | `bun zt dev` | Dev server with hot reload |
703
703
  | `bun zt css:build` | Production CSS build (Flow/View) |
704
704
  | `bun zt inertia:build` | Development JS+CSS build (Inertia) |
705
705
  | `bun zt inertia:build --production` | Production JS+CSS build (Inertia) |
package/docs/changelog.md CHANGED
@@ -27,6 +27,148 @@ the section for every version you cross and apply its migration notes, not only
27
27
  majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
28
28
  when that carve-out ends.
29
29
 
30
+ ## 1.15.0 — 2026-09-04
31
+
32
+ A working developer building a training-provider platform on 1.14.3 sent eight findings
33
+ from doing it. All eight are here. Two of them were the same bug twice, and it was the
34
+ worst kind: a routing convention that failed open.
35
+
36
+ ### Changed — BREAKING
37
+
38
+ - **`createdAt`, `updatedAt` and `deletedAt` are `Carbon`, not `Date`** (`@zerotal/orm`,
39
+ `@zerotal/audit`).
40
+
41
+ They were declared `Date` and never held one. Every `datetime` column hydrates as
42
+ `Carbon`, these three included, so the declaration was wrong on any freshly loaded row —
43
+ and `order.createdAt.getTime()` type-checked and threw, because `Carbon` has
44
+ `toISOString()` and `valueOf()` but no `getTime()`.
45
+
46
+ Worse, the runtime class depended on how you reached the property: `Carbon` after a load,
47
+ a raw `Date` after `save()`, `touch()` or a soft `delete()` wrote one straight back onto
48
+ the instance. The same property on the same model, matching its declared type in neither
49
+ case, and with no `@column` declaration to check the truth against — which is exactly why
50
+ the declaration had to be the thing that is right.
51
+
52
+ ```ts fragment
53
+ order.createdAt.getTime(); // compiled, threw at runtime
54
+ order.createdAt.valueOf(); // epoch milliseconds
55
+ order.createdAt.toISOString(); // "2026-09-04T09:12:33.000Z"
56
+ order.createdAt.toDate(); // a native Date, when something needs one
57
+ ```
58
+
59
+ **Compile and follow the errors.** Every one is a line that would have thrown against a
60
+ loaded row, so the break is the fix arriving early. See the
61
+ [Upgrade Guide](/docs/upgrade#114-to-115).
62
+
63
+ ### Fixed
64
+
65
+ - **A `_middleware.ts` that could not apply left its subtree serving, unguarded**
66
+ (`@zerotal/core`). Two ways in, both silent.
67
+
68
+ `export default [Mw]` was ignored: the loader read only the named export. It is the
69
+ natural guess — every route file in the same directory default-exports its handler — and
70
+ getting it wrong produced no warning, no boot error, and nothing in `route:list`. This was
71
+ found on a host-employer portal scoped to one company's learners, where shipping it
72
+ unguarded would have been a data breach rather than a bug report.
73
+
74
+ The second was one `catch {}` covering both "there is no middleware here" and "the
75
+ middleware threw on import". A typo, a bad import path, a circular import or a
76
+ `SyntaxError` all read as absence — and on a hot-reload, that is a guard which was there
77
+ a second earlier.
78
+
79
+ Absence stays silent, because that is the convention working. Everything else is loud: a
80
+ file that throws stops the boot with its path and the original error; a file that exports
81
+ nothing appliable names the mistake and the line to write instead. Nothing rejected here
82
+ has ever applied, so no working app changes behaviour.
83
+
84
+ - **`zt doctor`'s secure-headers check false-positived behind a reverse proxy**
85
+ (`@zerotal/core`). It read `app.secureHeaders.secure` and inferred the response from it,
86
+ so an app where Caddy or nginx terminates TLS and sets HSTS — the most common production
87
+ topology for a Bun app — was reported as downgradeable while `curl -I` showed the header
88
+ present.
89
+
90
+ The cost is not the wrong answer. `doctor` is reliable enough that people run it after
91
+ every deploy, and one line they have to remember to ignore is a line nobody reads on the
92
+ day it is right. It now makes one request to `app.url` on a deployment and reports what
93
+ came back. When it cannot reach the site it says so, rather than reporting a header as
94
+ missing.
95
+
96
+ - **A test that stubbed `globalThis.fetch` also answered the test client's own requests**
97
+ (`@zerotal/testing`). Faking a third-party integration — the ordinary way to test one —
98
+ made unrelated route tests fail with `connection refused`. The failure presents as "my
99
+ test client cannot reach my app", which sends you looking at the server. `TestApp` now
100
+ binds `fetch` at module load, before any test can replace it.
101
+
102
+ ### Added
103
+
104
+ - **`bun zt route:types` generates every type file, not only the routes** (`@zerotal/core`,
105
+ `@zerotal/inertia`). Adding a page and rendering it failed with `TS2345: … not assignable
106
+ to 'PageName'` — accurate and unhelpful, since it reads as a mistyped page name when the
107
+ registry simply had not been rebuilt. The command whose name says it generates types
108
+ regenerated half of them.
109
+
110
+ `registerTypeGenerator` lets a view package add its codegen without core importing it.
111
+ One command refreshes both, and `--check` gates on both in CI.
112
+
113
+ - **`bun zt test` no longer inherits `.env`'s outbound drivers** (`@zerotal/core`). Bun
114
+ loads `.env` into every process, so a developer with `MAIL_DRIVER=smtp` pointed at a
115
+ local Postfix got a suite where every path that sends mail opened a real SMTP connection
116
+ — one test going from milliseconds to a five-second timeout, failing only when run with
117
+ its siblings, and invisible to anyone without a mail server configured.
118
+
119
+ Mail, queue, session and cache now get their in-process defaults, which is what the app's
120
+ own config already defaults to. A value set in the shell still wins, `.env.test` is read
121
+ and merged last, and `--keep-env` turns it off.
122
+
123
+ ### Documented
124
+
125
+ - **The query builder is findable.** A team wrote whole-table loads with JavaScript
126
+ filtering throughout an app because they never found `whereIn`. The page documenting it
127
+ exists and is thorough; the README's Data table sent anyone looking for `Post.query()` to
128
+ the `DB.table()` page, and `api-surface.md` is a flat alphabetical snapshot where
129
+ `whereIn` sits among two hundred siblings. Both now point where they should, and
130
+ [Queries](/docs/orm/queries) warns against the shape that degrades in proportion to the
131
+ table rather than the result.
132
+
133
+ - **`_middleware.ts` takes a named export**, and stubbing `globalThis.fetch` in a test has
134
+ a documented pattern next to `Http.fake()`.
135
+
136
+ ## 1.14.3 — 2026-09-01
137
+
138
+ Consistency pass on the documentation, and two bugs it uncovered.
139
+
140
+ Everything is written `bun zt <command>` now. The scaffold still adds a `dev` script, so
141
+ `bun run dev` keeps working — the docs simply lead with the form that is the same in every
142
+ project, including one that never ran the scaffolder.
143
+
144
+ ### Fixed
145
+
146
+ - **Twenty pages taught a retired command.** `serve --dev` was retired in
147
+ [1.13.0](#1130--2026-08-31), and that release updated the runner, the codemod,
148
+ `docs/commands.md` and `docs/upgrade.md` — leaving the form in the getting-started guide,
149
+ the assets guide, two Flow pages, the logger page, and both READMEs. For three weeks the
150
+ front door told newcomers to run a command that exits 1.
151
+
152
+ A gate now scans every page against the upgrade codemods' own list of retired forms, so
153
+ what the docs teach cannot drift from what the framework accepts. Only _shipped_
154
+ retirements count: a codemod for 2.0 describes a rename that has not happened, and
155
+ flagging correct prose is how a gate becomes something people add exclusions to rather
156
+ than read.
157
+
158
+ - **`zt upgrade` never offered the 1.13.0 migrations.** The `deprecated-aliases` codemod
159
+ bundled `serve --dev` and `routes:types` — retired in 1.13.0 — with the `BaseModel`
160
+ rename, which has not shipped and is scheduled for 2.0. A codemod carries one version, so
161
+ it took the later one: `zt upgrade --to 1.13.0` selected **nothing**, and the migration
162
+ for the release that broke `serve --dev` was unreachable by every app crossing it.
163
+
164
+ Split into `deprecated-aliases` at 1.13.0 and `base-model-rename` at 2.0.0. An app moving
165
+ to any 1.13+ release gets the command rewrites; one crossing to 2.0 no longer has
166
+ `BaseModel` renamed as a side effect of a command alias.
167
+
168
+ Found by the new gate, which flagged `extends BaseModel` in the ORM docs — correct prose,
169
+ and the reason it was reported was that the codemod claimed a rename that had not
170
+ happened.
171
+
30
172
  ## 1.14.2 — 2026-09-01
31
173
 
32
174
  **`bun run dev` was broken in every app scaffolded since 1.13.0.** Take this one.
package/docs/commands.md CHANGED
@@ -181,7 +181,7 @@ that cannot cost you anything.
181
181
  Nothing prompts when there is no terminal to answer, which covers CI, containers,
182
182
  and anything reading `serve`'s output from a pipe. There it fails with the same
183
183
  explanation, so use `--force` or `--auto-port` to say up front which way you want
184
- it decided. Both flags work with a plain `serve` and with `serve --dev`.
184
+ it decided. Both flags work with `serve` and with `dev`.
185
185
 
186
186
  The dev server is a special case worth knowing about: on every restart it waits a
187
187
  few seconds for its own previous process to let go of the socket rather than
@@ -192,8 +192,13 @@ is about to free itself anyway.
192
192
 
193
193
  `bun zt dev` starts the server, the file watcher, and every process a provider or
194
194
  your app registered — a queue worker, a type-checker, a Stripe listener — in one
195
- terminal, each in its own tab. It is `serve --dev` with those extra tabs and the
196
- keys to drive them, so anything true of one is true of the other.
195
+ terminal, each in its own tab. It subclasses `serve`, so it is the same supervisor,
196
+ the same port resolution and the same restart behaviour, plus the flags that only
197
+ make sense with a deck on screen — anything true of `serve` is true of it.
198
+
199
+ `serve --dev` was the old spelling and was retired in 1.13.0; it now fails with a
200
+ message rather than starting a plain server. The scaffold's `dev` script runs this,
201
+ so `bun run dev` and `bun zt dev` are the same thing.
197
202
 
198
203
  An app with a queue no longer needs a second terminal:
199
204
 
@@ -46,7 +46,7 @@ Registering the provider switches on the following (in lifecycle order):
46
46
 
47
47
  - `onRegister` — registers the `Router.flow()` macro and the file-route resolver so Flow pages can be declared and auto-discovered.
48
48
  - `onBooting` — builds the client runtime bundle (Alpine + the Flow bridge) and mounts the routes under `/__flow` (the WebSocket endpoint, `runtime.js`, the upload endpoint, and the session-relay endpoint).
49
- - `onBooted` — wires `serve --dev` rebuild hooks for `resources/css/app.css` and `resources/js/app.js` (a no-op when those entry points are absent).
49
+ - `onBooted` — wires `zt dev` rebuild hooks for `resources/css/app.css` and `resources/js/app.js` (a no-op when those entry points are absent).
50
50
  - `onStarting` — AOT-compiles and validates every registered page's `render()` method.
51
51
 
52
52
  ### Configuration
@@ -186,7 +186,7 @@ The cache is small (a handful of recent pages) and short-lived, same-origin only
186
186
 
187
187
  ### Fast refresh in dev
188
188
 
189
- When you edit a component under `serve --dev`, the server restarts (quickly) and the client **re-renders each mounted component from its held snapshot with the new code — keeping its state**, instead of a full reload that would reset everything. Because component state is a signed snapshot and your `APP_KEY` doesn't change across a restart, that snapshot still verifies against the fresh server; the browser also refetches the stylesheet, so a newly-used Tailwind class shows up too. Increment a counter, tweak its template, save — the counter keeps its value and the change appears in place.
189
+ When you edit a component under `zt dev`, the server restarts (quickly) and the client **re-renders each mounted component from its held snapshot with the new code — keeping its state**, instead of a full reload that would reset everything. Because component state is a signed snapshot and your `APP_KEY` doesn't change across a restart, that snapshot still verifies against the fresh server; the browser also refetches the stylesheet, so a newly-used Tailwind class shows up too. Increment a counter, tweak its template, save — the counter keeps its value and the change appears in place.
190
190
 
191
191
  Two things to know:
192
192
 
@@ -199,7 +199,7 @@ Two things to know:
199
199
 
200
200
  ### Time-travel devtools
201
201
 
202
- Because the engine signs and delta-encodes the **full component state** on every round-trip, the client already holds an exact, verifiable history of everything each component has been. Under `serve --dev`, Flow records that stream: one frame per applied patch,
202
+ Because the engine signs and delta-encodes the **full component state** on every round-trip, the client already holds an exact, verifiable history of everything each component has been. Under `zt dev`, Flow records that stream: one frame per applied patch,
203
203
  plus the initial mount. You can then **scrub back to any frame**.
204
204
 
205
205
  Jumping re-applies that frame's snapshot _and_ its HTML to the live component, so
@@ -225,7 +225,7 @@ use. Recording is capped to the most recent frames and runs **only** under the d
225
225
 
226
226
  ### Dev error overlay
227
227
 
228
- When an **unexpected** error is thrown, `serve --dev` shows a **full-screen
228
+ When an **unexpected** error is thrown, `zt dev` shows a **full-screen
229
229
  overlay** carrying the error class, message, and stack. It covers both a server
230
230
  action and the initial `GET` render (`onMount` / `render`).
231
231
 
@@ -193,11 +193,14 @@ bun zt migrate
193
193
 
194
194
  ```bash
195
195
  # in your project root
196
- bun dev # → bun zt serve --dev
196
+ bun zt dev
197
197
  ```
198
198
 
199
+ Every command in these docs is written `bun zt <command>`. The scaffold also adds a
200
+ `dev` script, so `bun run dev` does the same thing — reach for whichever you prefer.
201
+
199
202
  Your app is now running at **http://localhost:3000** with hot reload enabled —
200
- `--dev` watches your files and hot-swaps routes without dropping connections. The
203
+ `dev` watches your files and hot-swaps routes without dropping connections. The
201
204
  generated `package.json` also gives you `bun start` (production serve) and
202
205
  `bun test`.
203
206
 
package/docs/logger.md CHANGED
@@ -292,7 +292,7 @@ level you can filter and in a file you can ship. Nothing writes to the terminal
292
292
  behind the logger's back, which means a `daily` channel captures the framework's
293
293
  own account of a boot, not just your application's.
294
294
 
295
- The one deliberate exception is the CLI itself. `serve --dev`'s banner and its
295
+ The one deliberate exception is the CLI itself. `zt dev`'s banner and its
296
296
  `[zerotal:dev]` build/restart lines come from the supervisor process that watches
297
297
  your files, not from the application, and they are interactive terminal output
298
298
  rather than a record of anything. They stay on stdout.
@@ -184,6 +184,15 @@ export const middleware = [AuthMiddleware, AdminMiddleware];
184
184
  Middleware stacks from outer directories are prepended automatically —
185
185
  you get `root/_middleware → admin/_middleware → route handler` in one pipeline.
186
186
 
187
+ > **It is `export const middleware`, an array — not `export default`.** Route
188
+ > files in the same directory default-export their handler, so `export default
189
+ [AdminMiddleware]` is the natural guess and it is the wrong one. The app refuses
190
+ > to boot and tells you which file, rather than serving the subtree unguarded and
191
+ > looking exactly like a guarded one. The same applies to a `_middleware.ts` that
192
+ > fails to import: a typo or a bad import path is a boot error, not a subtree that
193
+ > quietly loses its guard. A directory with no `_middleware` file at all is the
194
+ > convention working, and stays silent.
195
+
187
196
  ## Global middleware
188
197
 
189
198
  Register middleware that runs on every request in `bootstrap/app.ts`:
@@ -159,6 +159,14 @@ const user = await User.findOrNew(1);
159
159
  `Model.query()` returns a `ModelQueryBuilder`. Most methods are also available on
160
160
  `DB.table()` for unmodelled raw queries — see [Query builder](/docs/query-builder).
161
161
 
162
+ > **Filter in SQL, not in JavaScript.** If you find yourself writing
163
+ > `(await Placement.all()).filter((p) => p.employerId === id)`, the builder has
164
+ > what you want — `.where("employerId", id)`. That shape works fine on a table
165
+ > with hundreds of rows and gets slower in proportion to the table, not to the
166
+ > result, so it fails long after it was written and nowhere near the code that
167
+ > caused it. The next four sections are the whole filtering surface; skimming
168
+ > them once is the cheapest thing on this page.
169
+
162
170
  ### Filtering
163
171
 
164
172
  ```typescript fragment
@@ -75,14 +75,16 @@ dependency order, from CI. Never mix versions across packages.
75
75
  tilde if you would rather cross a minor deliberately.
76
76
  - **A break is never silent.** Every one is called out in the release notes as
77
77
  **BREAKING**, with the reason and the migration steps, and the version gets its
78
- own section in the Upgrade Guide. Eight have shipped so far — the
78
+ own section in the Upgrade Guide. Nine have shipped so far — the
79
79
  `ComponentWith` / `BaseModelWith` removal in 1.3.0, Flow's `socket:` listener
80
80
  prefix in 1.7.2, the removal of Flow's `this.title(…)` in 1.7.3, SQLite
81
81
  foreign-key enforcement in 1.11.0, `countTokens` returning `number | null` in
82
82
  1.11.2, the refusal to write a boolean into a text column in 1.12.0, and the removal of
83
83
  Flow's `Component.client(…)` alongside two retired aliases in 1.13.0, and
84
- `inertia.ssr` no longer registering `POST /__ssr` in 1.14.0.
85
- - **One of those five is in the wrong place, and it stays on the record.** 1.11.2
84
+ `inertia.ssr` no longer registering `POST /__ssr` in 1.14.0, and the model
85
+ timestamps `createdAt` / `updatedAt` / `deletedAt` being typed `Carbon` rather
86
+ than `Date` in 1.15.0.
87
+ - **One of those nine is in the wrong place, and it stays on the record.** 1.11.2
86
88
  is a patch, and by the rule above a patch cannot carry a break. It did: the
87
89
  `countTokens` signature changed in the same release that promoted `@zerotal/ai`
88
90
  to `stable`, and the reasoning that allowed it — the package was still
@@ -251,14 +251,45 @@ your users find first.
251
251
 
252
252
  ## `bun test` vs `bun zt test`
253
253
 
254
- Both run the same files. `bun zt test` is a wrapper that sets up three things Bun's
254
+ Both run the same files. `bun zt test` is a wrapper that sets up four things Bun's
255
255
  runner does not, and each of them has cost somebody a day:
256
256
 
257
- | | `bun test` | `bun zt test` |
258
- | ---------------- | --------------------- | ---------------------------------------------------------- |
259
- | Per-test timeout | Bun's default, 5000ms | 30000ms (`--timeout`, override with `--timeout=`) |
260
- | Runtime check | none | refuses a Bun below the project's `engines.bun` |
261
- | DB wiring | none | preloads `@zerotal/testing/preload` and passes `ZT_DB_URL` |
257
+ | | `bun test` | `bun zt test` |
258
+ | ---------------- | --------------------- | ------------------------------------------------------------------ |
259
+ | Per-test timeout | Bun's default, 5000ms | 30000ms (`--timeout`, override with `--timeout=`) |
260
+ | Runtime check | none | refuses a Bun below the project's `engines.bun` |
261
+ | DB wiring | none | preloads `@zerotal/testing/preload` and passes `ZT_DB_URL` |
262
+ | Drivers | inherits `.env` | resets mail, queue, session and cache to their in-process defaults |
263
+
264
+ ### The drivers
265
+
266
+ Bun loads `.env` into every process it starts, so a test run inherits your local
267
+ setup — including the keys that decide whether a code path talks to something
268
+ real. A developer with `MAIL_DRIVER=smtp` pointed at a local Postfix gets a suite
269
+ where every path that sends mail opens an SMTP connection: one test that confirms
270
+ a payment and issues an invitation goes from milliseconds to a five-second
271
+ timeout, and fails only when run alongside its siblings. A teammate with no mail
272
+ server configured never sees it.
273
+
274
+ So `bun zt test` resets these for the child process:
275
+
276
+ | Key | Test value |
277
+ | ---------------- | ---------- |
278
+ | `MAIL_DRIVER` | `log` |
279
+ | `QUEUE_DRIVER` | `sync` |
280
+ | `SESSION_DRIVER` | `cookie` |
281
+ | `CACHE_DRIVER` | `memory` |
282
+
283
+ Each is already the app config's default; only `.env` was overriding it. Two
284
+ things still win, and the command prints a line saying what it changed:
285
+
286
+ - **A value set in the shell** — `MAIL_DRIVER=smtp bun zt test` is someone
287
+ testing that path deliberately, and is left alone.
288
+ - **`.env.test`** — read by `bun zt test` and merged last, for anything a test
289
+ run should configure for itself. (Bun only loads that file when `NODE_ENV=test`,
290
+ so `bun test` on its own does not see it.)
291
+
292
+ `--keep-env` skips the reset entirely and inherits `.env` as-is.
262
293
 
263
294
  ### The timeout
264
295
 
@@ -225,6 +225,34 @@ it("charges the card and records the reference", async () => {
225
225
  A catch-all stub at the end is worth adding: without it an unstubbed URL falls
226
226
  through, and a test can quietly start depending on a service being reachable.
227
227
 
228
+ ### Stubbing `globalThis.fetch` directly
229
+
230
+ `Http.fake()` only sees requests made through the `Http` client. An integration
231
+ written against `fetch` directly — a third-party SDK, or a client you wrote
232
+ before reaching for `Http` — needs the global replaced:
233
+
234
+ ```typescript fragment
235
+ // src/tests/MoodleTest.ts
236
+ const originalFetch = globalThis.fetch;
237
+
238
+ beforeEach(() => {
239
+ globalThis.fetch = (async (input, init) => {
240
+ const url = String(typeof input === "string" ? input : (input as Request).url);
241
+ if (!url.startsWith("https://learn.example.test")) return originalFetch(input, init);
242
+ return Response.json({ courses: [] });
243
+ }) as typeof fetch;
244
+ });
245
+ afterEach(() => {
246
+ globalThis.fetch = originalFetch;
247
+ });
248
+ ```
249
+
250
+ The passthrough is there for **everything else your code fetches**, not for the
251
+ test client: `TestApp` holds the real `fetch` from before any test ran, so a stub
252
+ installed here can never answer `app.get('/orders')`. Restoring in `afterEach` is
253
+ what matters — a stub left installed makes the next file's outbound calls return
254
+ the previous file's fixture.
255
+
228
256
  ## Time
229
257
 
230
258
  Behaviour that depends on the passage of time — a token that expires in seven
package/docs/upgrade.md CHANGED
@@ -296,6 +296,93 @@ doing something quiet.
296
296
  If it really is a new migration, give it a name that does not collide once the
297
297
  leading digits are removed.
298
298
 
299
+ ## 1.14 to 1.15
300
+
301
+ Two breaking changes. Both replace something that was quietly wrong with something that
302
+ says so.
303
+
304
+ ### `createdAt`, `updatedAt` and `deletedAt` are `Carbon`, not `Date`
305
+
306
+ They always hydrated as `Carbon` — every `datetime` column does — but were declared `Date`,
307
+ so the type was wrong on any row loaded from the database. Worse, the runtime class
308
+ depended on how you got there: a `Carbon` after a load, a raw `Date` after a `save()`, a
309
+ `touch()` or a soft `delete()`. The same property on the same model, matching its declared
310
+ type in neither case.
311
+
312
+ ```ts fragment
313
+ // before — compiled, and threw at runtime on a loaded row
314
+ const ms = order.createdAt.getTime();
315
+
316
+ // after
317
+ const ms = order.createdAt?.valueOf(); // epoch milliseconds
318
+ const iso = order.createdAt?.toISOString(); // "2026-09-04T08:15:00.000Z"
319
+ const native = order.createdAt?.toDate(); // a real Date, if something needs one
320
+ ```
321
+
322
+ **What to change.** Compile and follow the errors: every one is code that would have thrown
323
+ if that line had run against a loaded row. `Carbon` has `toISOString()`, `valueOf()`,
324
+ `toJSON()` and `toDate()`, plus its own comparison and arithmetic — it does not have
325
+ `getTime()`, `getFullYear()` or the rest of the `Date` accessors.
326
+
327
+ If you were working around the old inconsistency, the workaround can go:
328
+
329
+ ```ts fragment
330
+ // before — needed because the class varied by path
331
+ const ms = ((value as any).toDate?.() ?? value).getTime();
332
+
333
+ // after
334
+ const ms = value.valueOf();
335
+ ```
336
+
337
+ A cast that hid the mismatch (`order.createdAt as unknown as string`) now hides a different
338
+ one — `.slice(0, 10)` on a `Carbon` still throws. Use `.toISOString().slice(0, 10)`.
339
+
340
+ ### A broken `_middleware.ts` stops the boot
341
+
342
+ Two ways a `_middleware.ts` could fail to apply used to be silent, leaving the routes it
343
+ was written to guard live and unguarded:
344
+
345
+ - **`export default [Middleware]`** — the loader only ever read `export const middleware`.
346
+ `export default` is the natural guess, because every route file in the same directory
347
+ default-exports its handler.
348
+ - **A file that fails to import** — a typo, a bad import path, a circular import, a
349
+ `SyntaxError`. All shared a `catch` with the missing-file case, so all read as "this
350
+ directory has no middleware".
351
+
352
+ Both now fail the boot, naming the file and the fix. A directory with no `_middleware` file
353
+ is unchanged: absence is the convention working, and stays silent.
354
+
355
+ ```ts fragment
356
+ // app/routes/employer/_middleware.ts
357
+
358
+ // before — silently ignored, subtree served unguarded
359
+ export default [EmployerMiddleware];
360
+
361
+ // after
362
+ export const middleware = [EmployerMiddleware];
363
+ ```
364
+
365
+ **What to change.** Boot the app once — `bun zt route:list` is enough. If nothing changes,
366
+ you had none of this. If a `_middleware.ts` file was never applying, you will be told which
367
+ one, and the routes under it were open until now.
368
+
369
+ Under `zt dev` a reload that hits this aborts and keeps serving the previously compiled
370
+ routes, so a guard never drops mid-session.
371
+
372
+ ### Also worth knowing (not breaking)
373
+
374
+ - **`bun zt route:types` regenerates the Inertia page registry too.** Adding a page and
375
+ getting `TS2345: … not assignable to parameter of type 'PageName'` is now fixed by the
376
+ command whose name says it generates types. `--check` gates on both files.
377
+ - **`bun zt test` no longer inherits `.env`'s mail, queue, session and cache drivers.**
378
+ They get their in-process defaults (`log`, `sync`, `cookie`, `memory`) so a suite cannot
379
+ open a real SMTP or Redis connection because of how one machine is configured. A value
380
+ set in the shell still wins, `.env.test` is read and merged last, and `--keep-env` turns
381
+ it off. See [Testing](/docs/testing#bun-test-vs-bun-zt-test).
382
+ - **`zt doctor`'s secure-headers check probes the deployed URL** instead of inferring HSTS
383
+ from config, so an app behind a TLS-terminating proxy is no longer reported as
384
+ downgradeable when the header is being sent.
385
+
299
386
  ## 1.13 to 1.14
300
387
 
301
388
  One breaking change, and it is small in practice.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.14.2"
38
+ "@zerotal/core": "1.15.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.14.2"
42
+ "@zerotal/orm": "1.15.0"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [