@zerotal/arch 1.15.0 → 1.15.2

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/docs/changelog.md CHANGED
@@ -27,6 +27,136 @@ 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.2 — 2026-10-23
31
+
32
+ A patch, and if you run Bun 1.4.4 or later it is the one to take: that Bun stopped accepting the
33
+ way the ORM hands it SQL, so most of what the ORM sent to the database failed. Three smaller
34
+ faults ride with it, found running a live app on Zerotal, each of which that app had been
35
+ working round in its own code.
36
+
37
+ Nothing here breaks. Safe to take directly.
38
+
39
+ ### Fixed
40
+
41
+ - **The ORM runs on Bun 1.4.4** (`@zerotal/orm`, with `@zerotal/notifications` and
42
+ `@zerotal/testing`). Bun's `sql` function refuses a template object whose `raw` property
43
+ is enumerable — "so it is not a template object" — and the ORM built every statement it
44
+ assembled from a string that way: the string form of `DB.raw()`, the query builder,
45
+ schema and migration DDL, the `SAVEPOINT` a nested transaction issues, the validator's
46
+ `unique` and `exists` rules. 1.4.4 is the first Bun known to refuse it, and on that version
47
+ and later none of those statements ran. They now come from one helper that builds the
48
+ object the way the language does. An app on Bun 1.4.2 or earlier was never affected, and
49
+ nothing about its behaviour changes.
50
+
51
+ - **A production build ships React's production build** (`@zerotal/core`). The browser
52
+ build's defines stated `import.meta.env` but not `process.env.NODE_ENV`, which is the
53
+ switch React and most of npm read — and the bundler left that expression to whatever the
54
+ shell happened to have. An app that ran `inertia:build --production` without `NODE_ENV`
55
+ in its environment shipped React's development build: a 522 KB vendor chunk where 322 KB
56
+ would do, the DevTools notice in every visitor's console, and full hydration diffs. The
57
+ define now states `NODE_ENV` from the same flag, so the build no longer depends on the
58
+ shell it ran in.
59
+
60
+ - **`back()` works behind a proxy that terminates TLS** (`@zerotal/core`). A redirect back
61
+ to the previous page accepted a `Referer` only from the request's own origin. Behind a
62
+ reverse proxy the request reaches the app as `http://…` while every browser sends
63
+ `https://…`, so every `Referer` was refused and "back" was always `/`. That is not only
64
+ `ctx.back()`: `validate()`'s redirect on failure, `FormRequest`, the intended-URL helpers
65
+ and Flow all use the same check, so a form with one mistake in it sent the user to the
66
+ home page with its errors flashed to a page that never shows them. The origin of the
67
+ configured `app.url` now counts as the app's own as well. Every other site is still
68
+ refused, and with no `app.url` set the behaviour is exactly what it was.
69
+
70
+ - **`SmtpDriver` transfer-encodes text and HTML parts** (`@zerotal/notifications`). Both
71
+ went on the wire raw, with no `Content-Transfer-Encoding`. A body line over the 998-octet
72
+ limit of RFC 5322 was cut by a relay wherever the limit fell — a single-line HTML email
73
+ reached a customer reading "sup pliers" — and a non-ASCII body was sent as undeclared
74
+ 8-bit on a connection that never negotiates `8BITMIME`. A part that is plain ASCII with
75
+ lines of 78 characters or fewer is still sent as written, now declared `7bit`; anything
76
+ else is quoted-printable, which keeps ASCII readable where base64 would not. A test that
77
+ asserts on the raw bytes the driver sent will see the new header, and for a long or
78
+ non-ASCII body, encoded text to decode before comparing.
79
+
80
+ ### Changed
81
+
82
+ - **Three runtime dependencies move to newer minimums.** `alpinejs` and its seven plugins
83
+ (`@zerotal/flow`) are now `^3.17.4`, `@simplewebauthn/server` (`@zerotal/auth`) is now
84
+ `^13.3.3`, and `tailwind-merge` (`@zerotal/flow-ui`) is now `^3.7.0`. Each stays within
85
+ the major it was already on, and nothing in Zerotal's own API moves. The rest of the
86
+ weekly dependency update is development tooling and does not reach an installed app.
87
+
88
+ ## 1.15.1 — 2026-09-13
89
+
90
+ A patch, and its headline is an ORM bug as old as the option it breaks: a model keyed on
91
+ anything but `id` could not be updated, deleted or reloaded, and said nothing about it.
92
+
93
+ Nothing here breaks. Safe to take directly.
94
+
95
+ ### Added
96
+
97
+ - **`registerViewFileRouteResolver` is public** (`@zerotal/core`). It is what turns on
98
+ server-rendered file-route pages — a route file's default export rendered to HTML and
99
+ wrapped in the nearest `_layout`. Both halves were implemented, tested, and described in
100
+ [View](/docs/view) as automatic, while the call that enables them carried an internal
101
+ marker and could not be reached from an app. It stays opt-in, because it claims every
102
+ `.tsx` route file's default export; the docs now say so and document the `_layout`
103
+ convention beside it.
104
+
105
+ ### Fixed
106
+
107
+ - **A model with a primary key other than `id` could not be updated, deleted or reloaded**
108
+ (`@zerotal/orm`). `static primaryKey = "uuid"` configured the column name everywhere the
109
+ key was written into SQL, while the value bound against it came from `this.id` — a
110
+ property such a model never has. So every statement went out as `WHERE uuid = NULL`:
111
+ `save()` on a loaded instance and `delete()` each matched zero rows and returned as
112
+ though they had written, `refresh()` and `fresh()` raised `ModelNotFoundError` for a row
113
+ sitting in the table, and `increment()`, `loadCount()` and the relation aggregates
114
+ no-opped or reported zero.
115
+
116
+ The insert was worse than silent. It wrote the row, then read it back by
117
+ `last_insert_rowid()`, which answers with a rowid no `TEXT` key ever equals — so the
118
+ instance came back with no timestamps and not marked as resident, and the next `save()`
119
+ on it was a second INSERT and a duplicate-key error rather than an UPDATE.
120
+
121
+ Every one of those paths now reads the key by its declared name. A write against an
122
+ instance whose key is genuinely absent, such as a row hydrated by `select("title")`,
123
+ raises `E_NO_PRIMARY_KEY` instead of binding NULL and looking like a success.
124
+ [The ORM guide](/docs/orm) now has a section on keying a model on something other than
125
+ `id`, including the part that bit: `localKey` on a relation defaults to `"id"`, so a
126
+ named key has to be named there too.
127
+
128
+ - **A primary key the app mints was stripped from the INSERT** (`@zerotal/orm`). `id` was
129
+ treated as database-generated unconditionally, so a table whose key is a `TEXT id` the
130
+ application supplies took the value off the instance, dropped it from the statement, and
131
+ stored NULL — while the in-memory model went on holding the value it thought it had
132
+ written. The key is now omitted only when it has no value, which is what leaves
133
+ AUTOINCREMENT to the database.
134
+
135
+ - **A `_layout` file that could not apply rendered its pages without one**
136
+ (`@zerotal/core`). The same fail-open the `_middleware` loader had in
137
+ [1.15.0](#1150--2026-09-04), one function up in the same file: an import error was
138
+ swallowed together with the not-found case. A `_layout.tsx` with a typo rendered every
139
+ page beneath it stripped of its chrome, with no error and no log. Absence stays silent;
140
+ a file that exists and threw now stops the boot with its path and the original error.
141
+
142
+ - **A test file that booted the app with a `setup` callback left every later file without a
143
+ database** (`@zerotal/testing`). `createTestApp(bootstrap, setup)` opts out of sharing,
144
+ which was implemented as opting out of the cache — so nothing knew that app was in use,
145
+ its `close()` ran the full provider teardown, and `DatabaseProvider.onStopping` cleared
146
+ the process-global connection resolver for everyone after it. Suites that had done
147
+ nothing wrong failed with `No database connection. Is DatabaseProvider registered?`. It
148
+ read as a CI-only flake for months, because whether it bites depends on test-file order.
149
+
150
+ - **`zt doctor` says when its outside-in probes did not run** (`@zerotal/core`). The
151
+ response-header and WebSocket-transport checks need `--url`, and they are the two that
152
+ see what no in-process check can. Skipping them silently meant the people who most needed
153
+ them had no way to learn they existed.
154
+
155
+ Three more are internal, and in the notes for each package: a MySQL adapter that offers
156
+ connection reservation may still refuse it, a dialect-coverage test left the ORM in MySQL
157
+ mode for the rest of the process, and four test suites left their fixture trees on disk
158
+ because a recursive `rm()` of a `"./"`-prefixed path silently deletes nothing on Windows.
159
+
30
160
  ## 1.15.0 — 2026-09-04
31
161
 
32
162
  A working developer building a training-provider platform on 1.14.3 sent eight findings
@@ -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
@@ -185,13 +185,17 @@ Middleware stacks from outer directories are prepended automatically —
185
185
  you get `root/_middleware → admin/_middleware → route handler` in one pipeline.
186
186
 
187
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
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
192
  > fails to import: a typo or a bad import path is a boot error, not a subtree that
193
193
  > quietly loses its guard. A directory with no `_middleware` file at all is the
194
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.
195
199
 
196
200
  ## Global middleware
197
201
 
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/upgrade.md CHANGED
@@ -371,6 +371,12 @@ routes, so a guard never drops mid-session.
371
371
 
372
372
  ### Also worth knowing (not breaking)
373
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.
374
380
  - **`bun zt route:types` regenerates the Inertia page registry too.** Adding a page and
375
381
  getting `TS2345: … not assignable to parameter of type 'PageName'` is now fixed by the
376
382
  command whose name says it generates types. `--check` gates on both files.
package/docs/view.md CHANGED
@@ -272,9 +272,27 @@ Router.view("/terms", TermsPage)
272
272
 
273
273
  ## File-based pages
274
274
 
275
- Under [file-based routing](/docs/routing), a page file's **default export**
276
- becomes a `GET` route automatically. The page is a function of `(http, params)`
277
- and may be async:
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.15.0",
3
+ "version": "1.15.2",
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.15.0"
38
+ "@zerotal/core": "1.15.2"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.15.0"
42
+ "@zerotal/orm": "1.15.2"
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": [