@zerotal/arch 1.14.3 → 1.15.1
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 +3 -0
- package/docs/about.md +2 -1
- package/docs/changelog.md +178 -0
- package/docs/contributing.md +35 -0
- package/docs/middleware.md +13 -0
- package/docs/orm/index.md +39 -0
- package/docs/orm/queries.md +8 -0
- package/docs/support-policy.md +5 -3
- package/docs/testing/index.md +37 -6
- package/docs/testing/mocking.md +28 -0
- package/docs/upgrade.md +93 -0
- package/docs/view.md +56 -3
- package/package.json +3 -3
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
|
@@ -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:
|
|
247
|
+
@column("datetime") declare createdAt: Carbon;
|
|
247
248
|
|
|
248
249
|
@hasMany(() => Comment) declare comments: Comment[];
|
|
249
250
|
@belongsTo(() => User) declare author: User;
|
package/docs/changelog.md
CHANGED
|
@@ -27,6 +27,184 @@ 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.1 — 2026-09-13
|
|
31
|
+
|
|
32
|
+
A patch, and its headline is an ORM bug as old as the option it breaks: a model keyed on
|
|
33
|
+
anything but `id` could not be updated, deleted or reloaded, and said nothing about it.
|
|
34
|
+
|
|
35
|
+
Nothing here breaks. Safe to take directly.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`registerViewFileRouteResolver` is public** (`@zerotal/core`). It is what turns on
|
|
40
|
+
server-rendered file-route pages — a route file's default export rendered to HTML and
|
|
41
|
+
wrapped in the nearest `_layout`. Both halves were implemented, tested, and described in
|
|
42
|
+
[View](/docs/view) as automatic, while the call that enables them carried an internal
|
|
43
|
+
marker and could not be reached from an app. It stays opt-in, because it claims every
|
|
44
|
+
`.tsx` route file's default export; the docs now say so and document the `_layout`
|
|
45
|
+
convention beside it.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **A model with a primary key other than `id` could not be updated, deleted or reloaded**
|
|
50
|
+
(`@zerotal/orm`). `static primaryKey = "uuid"` configured the column name everywhere the
|
|
51
|
+
key was written into SQL, while the value bound against it came from `this.id` — a
|
|
52
|
+
property such a model never has. So every statement went out as `WHERE uuid = NULL`:
|
|
53
|
+
`save()` on a loaded instance and `delete()` each matched zero rows and returned as
|
|
54
|
+
though they had written, `refresh()` and `fresh()` raised `ModelNotFoundError` for a row
|
|
55
|
+
sitting in the table, and `increment()`, `loadCount()` and the relation aggregates
|
|
56
|
+
no-opped or reported zero.
|
|
57
|
+
|
|
58
|
+
The insert was worse than silent. It wrote the row, then read it back by
|
|
59
|
+
`last_insert_rowid()`, which answers with a rowid no `TEXT` key ever equals — so the
|
|
60
|
+
instance came back with no timestamps and not marked as resident, and the next `save()`
|
|
61
|
+
on it was a second INSERT and a duplicate-key error rather than an UPDATE.
|
|
62
|
+
|
|
63
|
+
Every one of those paths now reads the key by its declared name. A write against an
|
|
64
|
+
instance whose key is genuinely absent, such as a row hydrated by `select("title")`,
|
|
65
|
+
raises `E_NO_PRIMARY_KEY` instead of binding NULL and looking like a success.
|
|
66
|
+
[The ORM guide](/docs/orm) now has a section on keying a model on something other than
|
|
67
|
+
`id`, including the part that bit: `localKey` on a relation defaults to `"id"`, so a
|
|
68
|
+
named key has to be named there too.
|
|
69
|
+
|
|
70
|
+
- **A primary key the app mints was stripped from the INSERT** (`@zerotal/orm`). `id` was
|
|
71
|
+
treated as database-generated unconditionally, so a table whose key is a `TEXT id` the
|
|
72
|
+
application supplies took the value off the instance, dropped it from the statement, and
|
|
73
|
+
stored NULL — while the in-memory model went on holding the value it thought it had
|
|
74
|
+
written. The key is now omitted only when it has no value, which is what leaves
|
|
75
|
+
AUTOINCREMENT to the database.
|
|
76
|
+
|
|
77
|
+
- **A `_layout` file that could not apply rendered its pages without one**
|
|
78
|
+
(`@zerotal/core`). The same fail-open the `_middleware` loader had in
|
|
79
|
+
[1.15.0](#1150--2026-09-04), one function up in the same file: an import error was
|
|
80
|
+
swallowed together with the not-found case. A `_layout.tsx` with a typo rendered every
|
|
81
|
+
page beneath it stripped of its chrome, with no error and no log. Absence stays silent;
|
|
82
|
+
a file that exists and threw now stops the boot with its path and the original error.
|
|
83
|
+
|
|
84
|
+
- **A test file that booted the app with a `setup` callback left every later file without a
|
|
85
|
+
database** (`@zerotal/testing`). `createTestApp(bootstrap, setup)` opts out of sharing,
|
|
86
|
+
which was implemented as opting out of the cache — so nothing knew that app was in use,
|
|
87
|
+
its `close()` ran the full provider teardown, and `DatabaseProvider.onStopping` cleared
|
|
88
|
+
the process-global connection resolver for everyone after it. Suites that had done
|
|
89
|
+
nothing wrong failed with `No database connection. Is DatabaseProvider registered?`. It
|
|
90
|
+
read as a CI-only flake for months, because whether it bites depends on test-file order.
|
|
91
|
+
|
|
92
|
+
- **`zt doctor` says when its outside-in probes did not run** (`@zerotal/core`). The
|
|
93
|
+
response-header and WebSocket-transport checks need `--url`, and they are the two that
|
|
94
|
+
see what no in-process check can. Skipping them silently meant the people who most needed
|
|
95
|
+
them had no way to learn they existed.
|
|
96
|
+
|
|
97
|
+
Three more are internal, and in the notes for each package: a MySQL adapter that offers
|
|
98
|
+
connection reservation may still refuse it, a dialect-coverage test left the ORM in MySQL
|
|
99
|
+
mode for the rest of the process, and four test suites left their fixture trees on disk
|
|
100
|
+
because a recursive `rm()` of a `"./"`-prefixed path silently deletes nothing on Windows.
|
|
101
|
+
|
|
102
|
+
## 1.15.0 — 2026-09-04
|
|
103
|
+
|
|
104
|
+
A working developer building a training-provider platform on 1.14.3 sent eight findings
|
|
105
|
+
from doing it. All eight are here. Two of them were the same bug twice, and it was the
|
|
106
|
+
worst kind: a routing convention that failed open.
|
|
107
|
+
|
|
108
|
+
### Changed — BREAKING
|
|
109
|
+
|
|
110
|
+
- **`createdAt`, `updatedAt` and `deletedAt` are `Carbon`, not `Date`** (`@zerotal/orm`,
|
|
111
|
+
`@zerotal/audit`).
|
|
112
|
+
|
|
113
|
+
They were declared `Date` and never held one. Every `datetime` column hydrates as
|
|
114
|
+
`Carbon`, these three included, so the declaration was wrong on any freshly loaded row —
|
|
115
|
+
and `order.createdAt.getTime()` type-checked and threw, because `Carbon` has
|
|
116
|
+
`toISOString()` and `valueOf()` but no `getTime()`.
|
|
117
|
+
|
|
118
|
+
Worse, the runtime class depended on how you reached the property: `Carbon` after a load,
|
|
119
|
+
a raw `Date` after `save()`, `touch()` or a soft `delete()` wrote one straight back onto
|
|
120
|
+
the instance. The same property on the same model, matching its declared type in neither
|
|
121
|
+
case, and with no `@column` declaration to check the truth against — which is exactly why
|
|
122
|
+
the declaration had to be the thing that is right.
|
|
123
|
+
|
|
124
|
+
```ts fragment
|
|
125
|
+
order.createdAt.getTime(); // compiled, threw at runtime
|
|
126
|
+
order.createdAt.valueOf(); // epoch milliseconds
|
|
127
|
+
order.createdAt.toISOString(); // "2026-09-04T09:12:33.000Z"
|
|
128
|
+
order.createdAt.toDate(); // a native Date, when something needs one
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Compile and follow the errors.** Every one is a line that would have thrown against a
|
|
132
|
+
loaded row, so the break is the fix arriving early. See the
|
|
133
|
+
[Upgrade Guide](/docs/upgrade#114-to-115).
|
|
134
|
+
|
|
135
|
+
### Fixed
|
|
136
|
+
|
|
137
|
+
- **A `_middleware.ts` that could not apply left its subtree serving, unguarded**
|
|
138
|
+
(`@zerotal/core`). Two ways in, both silent.
|
|
139
|
+
|
|
140
|
+
`export default [Mw]` was ignored: the loader read only the named export. It is the
|
|
141
|
+
natural guess — every route file in the same directory default-exports its handler — and
|
|
142
|
+
getting it wrong produced no warning, no boot error, and nothing in `route:list`. This was
|
|
143
|
+
found on a host-employer portal scoped to one company's learners, where shipping it
|
|
144
|
+
unguarded would have been a data breach rather than a bug report.
|
|
145
|
+
|
|
146
|
+
The second was one `catch {}` covering both "there is no middleware here" and "the
|
|
147
|
+
middleware threw on import". A typo, a bad import path, a circular import or a
|
|
148
|
+
`SyntaxError` all read as absence — and on a hot-reload, that is a guard which was there
|
|
149
|
+
a second earlier.
|
|
150
|
+
|
|
151
|
+
Absence stays silent, because that is the convention working. Everything else is loud: a
|
|
152
|
+
file that throws stops the boot with its path and the original error; a file that exports
|
|
153
|
+
nothing appliable names the mistake and the line to write instead. Nothing rejected here
|
|
154
|
+
has ever applied, so no working app changes behaviour.
|
|
155
|
+
|
|
156
|
+
- **`zt doctor`'s secure-headers check false-positived behind a reverse proxy**
|
|
157
|
+
(`@zerotal/core`). It read `app.secureHeaders.secure` and inferred the response from it,
|
|
158
|
+
so an app where Caddy or nginx terminates TLS and sets HSTS — the most common production
|
|
159
|
+
topology for a Bun app — was reported as downgradeable while `curl -I` showed the header
|
|
160
|
+
present.
|
|
161
|
+
|
|
162
|
+
The cost is not the wrong answer. `doctor` is reliable enough that people run it after
|
|
163
|
+
every deploy, and one line they have to remember to ignore is a line nobody reads on the
|
|
164
|
+
day it is right. It now makes one request to `app.url` on a deployment and reports what
|
|
165
|
+
came back. When it cannot reach the site it says so, rather than reporting a header as
|
|
166
|
+
missing.
|
|
167
|
+
|
|
168
|
+
- **A test that stubbed `globalThis.fetch` also answered the test client's own requests**
|
|
169
|
+
(`@zerotal/testing`). Faking a third-party integration — the ordinary way to test one —
|
|
170
|
+
made unrelated route tests fail with `connection refused`. The failure presents as "my
|
|
171
|
+
test client cannot reach my app", which sends you looking at the server. `TestApp` now
|
|
172
|
+
binds `fetch` at module load, before any test can replace it.
|
|
173
|
+
|
|
174
|
+
### Added
|
|
175
|
+
|
|
176
|
+
- **`bun zt route:types` generates every type file, not only the routes** (`@zerotal/core`,
|
|
177
|
+
`@zerotal/inertia`). Adding a page and rendering it failed with `TS2345: … not assignable
|
|
178
|
+
to 'PageName'` — accurate and unhelpful, since it reads as a mistyped page name when the
|
|
179
|
+
registry simply had not been rebuilt. The command whose name says it generates types
|
|
180
|
+
regenerated half of them.
|
|
181
|
+
|
|
182
|
+
`registerTypeGenerator` lets a view package add its codegen without core importing it.
|
|
183
|
+
One command refreshes both, and `--check` gates on both in CI.
|
|
184
|
+
|
|
185
|
+
- **`bun zt test` no longer inherits `.env`'s outbound drivers** (`@zerotal/core`). Bun
|
|
186
|
+
loads `.env` into every process, so a developer with `MAIL_DRIVER=smtp` pointed at a
|
|
187
|
+
local Postfix got a suite where every path that sends mail opened a real SMTP connection
|
|
188
|
+
— one test going from milliseconds to a five-second timeout, failing only when run with
|
|
189
|
+
its siblings, and invisible to anyone without a mail server configured.
|
|
190
|
+
|
|
191
|
+
Mail, queue, session and cache now get their in-process defaults, which is what the app's
|
|
192
|
+
own config already defaults to. A value set in the shell still wins, `.env.test` is read
|
|
193
|
+
and merged last, and `--keep-env` turns it off.
|
|
194
|
+
|
|
195
|
+
### Documented
|
|
196
|
+
|
|
197
|
+
- **The query builder is findable.** A team wrote whole-table loads with JavaScript
|
|
198
|
+
filtering throughout an app because they never found `whereIn`. The page documenting it
|
|
199
|
+
exists and is thorough; the README's Data table sent anyone looking for `Post.query()` to
|
|
200
|
+
the `DB.table()` page, and `api-surface.md` is a flat alphabetical snapshot where
|
|
201
|
+
`whereIn` sits among two hundred siblings. Both now point where they should, and
|
|
202
|
+
[Queries](/docs/orm/queries) warns against the shape that degrades in proportion to the
|
|
203
|
+
table rather than the result.
|
|
204
|
+
|
|
205
|
+
- **`_middleware.ts` takes a named export**, and stubbing `globalThis.fetch` in a test has
|
|
206
|
+
a documented pattern next to `Http.fake()`.
|
|
207
|
+
|
|
30
208
|
## 1.14.3 — 2026-09-01
|
|
31
209
|
|
|
32
210
|
Consistency pass on the documentation, and two bugs it uncovered.
|
package/docs/contributing.md
CHANGED
|
@@ -114,6 +114,41 @@ bun test src/db/QueryBuilder.test.ts # a single file
|
|
|
114
114
|
5. **Update the docs.** If you change or add public API, update the relevant page
|
|
115
115
|
under `docs/` (and the nav in `apps/docs/app/routes/_layout.ts` if you add a page).
|
|
116
116
|
|
|
117
|
+
### Tests restore anything process-global
|
|
118
|
+
|
|
119
|
+
Bun runs a package's test files in **one process**, and the preload is evaluated
|
|
120
|
+
once per worker, not once per file. So a global a test writes is still there for
|
|
121
|
+
every file that runs after it: `Bun.env` keys, `globalThis.fetch` or
|
|
122
|
+
`globalThis.document`, and framework module state set through the `_set*` helpers.
|
|
123
|
+
|
|
124
|
+
Put it back. `beforeEach` captures, `afterEach` restores — and restore in a
|
|
125
|
+
`finally` or an `afterEach`, never at the end of the test body, or a failing
|
|
126
|
+
assertion skips the restore and takes the rest of the suite with it.
|
|
127
|
+
|
|
128
|
+
```ts fragment
|
|
129
|
+
let saved: string | undefined;
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
saved = Bun.env["APP_ENV"];
|
|
132
|
+
Bun.env["APP_ENV"] = "production";
|
|
133
|
+
});
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
if (saved === undefined) delete Bun.env["APP_ENV"];
|
|
136
|
+
else Bun.env["APP_ENV"] = saved;
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
This is worth its own rule because **the damage never lands where the mistake is**.
|
|
141
|
+
The file that fails is whichever ran next, and which file that is depends on
|
|
142
|
+
directory order — alphabetical on Windows, arbitrary readdir order on Linux. So a
|
|
143
|
+
leak passes locally for months and reddens CI on an unrelated commit. Five of
|
|
144
|
+
these were found in one week, each written by someone who had tested their own
|
|
145
|
+
file in isolation and seen it pass.
|
|
146
|
+
|
|
147
|
+
If a CI-only failure lands in a package your change did not touch, suspect a
|
|
148
|
+
leaked global before suspecting your diff. To reproduce the order locally, rename
|
|
149
|
+
the file you think runs second so it sorts last — passing paths to `bun test`
|
|
150
|
+
explicitly does not work, because Bun re-sorts them.
|
|
151
|
+
|
|
117
152
|
## Documentation changes
|
|
118
153
|
|
|
119
154
|
The docs are markdown files in `docs/`, served by the docs app in `apps/docs`. A page's
|
package/docs/middleware.md
CHANGED
|
@@ -184,6 +184,19 @@ 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 a default export is
|
|
189
|
+
> the natural guess here and it is the wrong one. The app refuses to boot and tells
|
|
190
|
+
> you which file, rather than serving the subtree unguarded and looking exactly
|
|
191
|
+
> 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
|
+
>
|
|
196
|
+
> Note the asymmetry with its sibling: a `_layout` file is read by its **default**
|
|
197
|
+
> export, a `_middleware` file by a **named** one. Both now refuse to boot on the
|
|
198
|
+
> other's spelling rather than applying nothing.
|
|
199
|
+
|
|
187
200
|
## Global middleware
|
|
188
201
|
|
|
189
202
|
Register middleware that runs on every request in `bootstrap/app.ts`:
|
package/docs/orm/index.md
CHANGED
|
@@ -171,6 +171,45 @@ export class LedgerEntry extends Model {
|
|
|
171
171
|
> `forceDelete()`, `trashed()`, and the `withTrashed()` / `onlyTrashed()` scopes. See
|
|
172
172
|
> [Lifecycle & Events](/docs/orm/lifecycle).
|
|
173
173
|
|
|
174
|
+
#### A primary key that isn't `id`
|
|
175
|
+
|
|
176
|
+
Name the key column and every read and write keys on it — `find()`, `save()`, `delete()`,
|
|
177
|
+
`refresh()`, `increment()`, relation counts. Declare it like any other column and set the
|
|
178
|
+
value yourself, because a `TEXT` key has no auto-increment to fall back on:
|
|
179
|
+
|
|
180
|
+
```typescript fragment
|
|
181
|
+
@(table("documents").primaryKey("uuid"))
|
|
182
|
+
export class Document extends Model {
|
|
183
|
+
@column("string") uuid!: string;
|
|
184
|
+
@column("string") title!: string;
|
|
185
|
+
|
|
186
|
+
// `localKey` defaults to "id" on every relation, so a named key has to be named here too
|
|
187
|
+
@hasMany(() => Revision, { foreignKey: "documentUuid", localKey: "uuid" })
|
|
188
|
+
revisions!: HasMany<Revision>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const doc = new Document();
|
|
192
|
+
doc.uuid = crypto.randomUUID(); // nothing fills this in for you
|
|
193
|
+
doc.title = "Roadmap";
|
|
194
|
+
await doc.save();
|
|
195
|
+
|
|
196
|
+
doc.createdAt; // the insert writes your key, then reads the row back by it
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Three consequences worth holding onto:
|
|
200
|
+
|
|
201
|
+
- **Set the key before the first `save()`.** With no value to write, the insert hands the
|
|
202
|
+
column to the database, which for a `TEXT` key means whatever its schema says — a `NOT
|
|
203
|
+
NULL` violation, or a silent `NULL`.
|
|
204
|
+
- **`localKey` on every relation.** It defaults to `"id"`, so a relation left at the
|
|
205
|
+
default reads a property the model does not have and quietly matches nothing.
|
|
206
|
+
- **A string key wants its own name.** `Model` types the inherited `id` as a `number`, so
|
|
207
|
+
`primaryKey("uuid")` (or `"ticket_ref"`, or whatever it is) type-checks where a `TEXT`
|
|
208
|
+
column called `id` does not.
|
|
209
|
+
|
|
210
|
+
Keep the key out of `fillable` unless request data really is allowed to choose it —
|
|
211
|
+
[mass assignment](#mass-assignment) is what decides that, the same as any other column.
|
|
212
|
+
|
|
174
213
|
### @column decorator
|
|
175
214
|
|
|
176
215
|
Declare typed columns. Accepts a shorthand cast string or a full options object:
|
package/docs/orm/queries.md
CHANGED
|
@@ -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
|
package/docs/support-policy.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
package/docs/testing/index.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/testing/mocking.md
CHANGED
|
@@ -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,99 @@ 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
|
+
- **A save or delete on a model with no primary-key value now raises an error** (1.15.1).
|
|
375
|
+
It used to bind NULL, match no row, and resolve as though it had written. The one way to
|
|
376
|
+
reach it is a partially hydrated instance — `Post.query().select("title").get()`, then
|
|
377
|
+
`save()` on a result — and in that state the write was already being thrown away. What
|
|
378
|
+
changes is that you hear about it: `E_NO_PRIMARY_KEY`, naming the model and the key.
|
|
379
|
+
Select the key column alongside whatever else you need, and the write works as intended.
|
|
380
|
+
- **`bun zt route:types` regenerates the Inertia page registry too.** Adding a page and
|
|
381
|
+
getting `TS2345: … not assignable to parameter of type 'PageName'` is now fixed by the
|
|
382
|
+
command whose name says it generates types. `--check` gates on both files.
|
|
383
|
+
- **`bun zt test` no longer inherits `.env`'s mail, queue, session and cache drivers.**
|
|
384
|
+
They get their in-process defaults (`log`, `sync`, `cookie`, `memory`) so a suite cannot
|
|
385
|
+
open a real SMTP or Redis connection because of how one machine is configured. A value
|
|
386
|
+
set in the shell still wins, `.env.test` is read and merged last, and `--keep-env` turns
|
|
387
|
+
it off. See [Testing](/docs/testing#bun-test-vs-bun-zt-test).
|
|
388
|
+
- **`zt doctor`'s secure-headers check probes the deployed URL** instead of inferring HSTS
|
|
389
|
+
from config, so an app behind a TLS-terminating proxy is no longer reported as
|
|
390
|
+
downgradeable when the header is being sent.
|
|
391
|
+
|
|
299
392
|
## 1.13 to 1.14
|
|
300
393
|
|
|
301
394
|
One breaking change, and it is small in practice.
|
package/docs/view.md
CHANGED
|
@@ -272,9 +272,27 @@ Router.view("/terms", TermsPage)
|
|
|
272
272
|
|
|
273
273
|
## File-based pages
|
|
274
274
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
Server-rendered file-route pages are **opt-in**. Register the resolver before
|
|
276
|
+
your routes are scanned — in `bootstrap/app.ts`, or a provider's `onRegister()`:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
// bootstrap/app.ts
|
|
280
|
+
import { registerViewFileRouteResolver } from "@zerotal/core/view";
|
|
281
|
+
|
|
282
|
+
registerViewFileRouteResolver();
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
That is what makes the two conventions below work: a page's default export is
|
|
286
|
+
rendered to HTML rather than treated as a plain handler, and `_layout` files are
|
|
287
|
+
discovered. Without it neither applies — the default export is registered as an
|
|
288
|
+
ordinary file handler and `_layout` discovery is switched off.
|
|
289
|
+
|
|
290
|
+
It is opt-in because it claims every `.tsx` route file's default export, which an
|
|
291
|
+
app using file routes for plain handlers has not asked for, and because `_layout`
|
|
292
|
+
discovery walks the directory tree for every scanned route.
|
|
293
|
+
|
|
294
|
+
With it registered, a page file's **default export** becomes a `GET` route. The
|
|
295
|
+
page is a function of `(http, params)` and may be async:
|
|
278
296
|
|
|
279
297
|
```tsx fragment
|
|
280
298
|
// app/views/posts/[slug].tsx → GET /posts/:slug
|
|
@@ -306,6 +324,41 @@ import { definePage } from "zerotal/view";
|
|
|
306
324
|
export default definePage((http, params) => `<h1>Hello ${params.name}</h1>`);
|
|
307
325
|
```
|
|
308
326
|
|
|
327
|
+
### Directory layouts — `_layout.tsx`
|
|
328
|
+
|
|
329
|
+
With the resolver registered, a `_layout.tsx` beside your pages wraps every page
|
|
330
|
+
in that directory and below. The nearest one wins, so a section can override the
|
|
331
|
+
root:
|
|
332
|
+
|
|
333
|
+
```tsx fragment
|
|
334
|
+
// app/views/_layout.tsx → wraps every page under app/views
|
|
335
|
+
export default (http: HttpContext, { children }: { children?: unknown }) => (
|
|
336
|
+
<html>
|
|
337
|
+
<body>
|
|
338
|
+
<nav>…</nav>
|
|
339
|
+
{children}
|
|
340
|
+
</body>
|
|
341
|
+
</html>
|
|
342
|
+
);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
```
|
|
346
|
+
app/views/
|
|
347
|
+
_layout.tsx ← wraps everything below
|
|
348
|
+
index.tsx
|
|
349
|
+
admin/
|
|
350
|
+
_layout.tsx ← wraps only the admin pages
|
|
351
|
+
users.tsx
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
It is read by its **default export** — the opposite of `_middleware.ts`, which is
|
|
355
|
+
read by a named `middleware` export. Both refuse to boot on the other's spelling
|
|
356
|
+
rather than silently applying nothing, so a mistake here is a startup error
|
|
357
|
+
naming the file, not a page that quietly renders without its chrome.
|
|
358
|
+
|
|
359
|
+
A page can opt out with `export const layout = null`, or override with
|
|
360
|
+
`export { SomeLayout as layout }`.
|
|
361
|
+
|
|
309
362
|
## Embedding raw HTML
|
|
310
363
|
|
|
311
364
|
When you have pre-rendered markup (e.g. from a Markdown renderer) use `safe()` or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/arch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.1",
|
|
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.
|
|
38
|
+
"@zerotal/core": "1.15.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.
|
|
42
|
+
"@zerotal/orm": "1.15.1"
|
|
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": [
|