@zerotal/arch 1.9.0 → 1.11.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 +6 -6
- package/docs/ai.md +82 -0
- package/docs/changelog.md +292 -0
- package/docs/deployment.md +52 -4
- package/docs/helpers.md +51 -0
- package/docs/inertia/middleware.md +44 -0
- package/docs/inertia/rendering.md +67 -0
- package/docs/inertia/ssr.md +95 -10
- package/docs/middleware.md +31 -0
- package/docs/migrations.md +10 -0
- package/docs/orm/index.md +15 -6
- package/docs/rate-limiting.md +45 -17
- package/docs/scheduler.md +71 -8
- package/docs/support-policy.md +14 -10
- package/docs/testing/http.md +52 -36
- package/docs/testing/index.md +140 -0
- package/docs/upgrade.md +129 -9
- package/package.json +3 -3
- package/src/install/guidelines.ts +1 -1
- package/src/mcp/stdio.ts +3 -3
- package/src/tools/_probe.ts +2 -2
package/docs/testing/http.md
CHANGED
|
@@ -171,6 +171,21 @@ await testApp.withSession({ locale: "fr", flash: "saved" }).get("/profile");
|
|
|
171
171
|
`session.secret` and `session.cookie` from your config. `withSession()` preserves
|
|
172
172
|
any `user_id` already set by `actingAs()`.
|
|
173
173
|
|
|
174
|
+
> **No users table?** `withSession()` is the whole answer, and it is the one to reach
|
|
175
|
+
> for when identity is not a row — an app whose login _is_ an IMAP login has no user
|
|
176
|
+
> to hand `actingAs()`. Seed whatever your app reads from the session and the request
|
|
177
|
+
> is authenticated:
|
|
178
|
+
>
|
|
179
|
+
> ```typescript fragment
|
|
180
|
+
> // in a test
|
|
181
|
+
> await testApp.withSession({ mail_wallet: { primary: "a@example.test" } }).get("/mail");
|
|
182
|
+
> ```
|
|
183
|
+
>
|
|
184
|
+
> Reaching past this to the session driver is the wrong layer and does not work —
|
|
185
|
+
> `driver.write()` is not a method, and `saveSession()` wants an id and a `Response`
|
|
186
|
+
> you do not have yet. Both of these encode through the app's _own_ driver, so the
|
|
187
|
+
> cookie always matches the format the app will read.
|
|
188
|
+
|
|
174
189
|
### Headers and redirects
|
|
175
190
|
|
|
176
191
|
```typescript fragment
|
|
@@ -396,42 +411,43 @@ expect(ctx.response?.status).toBe(200);
|
|
|
396
411
|
|
|
397
412
|
### TestResponse
|
|
398
413
|
|
|
399
|
-
| Member | Signature
|
|
400
|
-
| ----------------------------------------------------------------------------------- |
|
|
401
|
-
| `assertStatus` | `assertStatus(expected: number): this`
|
|
402
|
-
| `assertOk` / `assertCreated` / `assertNoContent` | `(): this`
|
|
403
|
-
| `assertSuccessful` | `(): this`
|
|
404
|
-
| `assertMovedPermanently` | `(): this`
|
|
405
|
-
| `assertUnauthorized` / `assertForbidden` / `assertNotFound` / `assertUnprocessable` | `(): this`
|
|
406
|
-
| `assertServerError` | `(): this`
|
|
407
|
-
| `assertRedirect` | `assertRedirect(url: string): this`
|
|
408
|
-
| `
|
|
409
|
-
| `
|
|
410
|
-
| `
|
|
411
|
-
| `
|
|
412
|
-
| `
|
|
413
|
-
| `
|
|
414
|
-
| `
|
|
415
|
-
| `
|
|
416
|
-
| `
|
|
417
|
-
| `
|
|
418
|
-
| `
|
|
419
|
-
| `
|
|
420
|
-
| `
|
|
421
|
-
| `
|
|
422
|
-
| `
|
|
423
|
-
| `
|
|
424
|
-
| `
|
|
425
|
-
| `
|
|
426
|
-
| `
|
|
427
|
-
| `
|
|
428
|
-
| `
|
|
429
|
-
| `
|
|
430
|
-
| `
|
|
431
|
-
| `
|
|
432
|
-
| `
|
|
433
|
-
| `
|
|
434
|
-
| `
|
|
414
|
+
| Member | Signature | Description |
|
|
415
|
+
| ----------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------ |
|
|
416
|
+
| `assertStatus` | `assertStatus(expected: number): this` | Assert the status code. |
|
|
417
|
+
| `assertOk` / `assertCreated` / `assertNoContent` | `(): this` | Assert `200` / `201` / `204`. |
|
|
418
|
+
| `assertSuccessful` | `(): this` | Assert any `2xx`. |
|
|
419
|
+
| `assertMovedPermanently` | `(): this` | Assert `301`. |
|
|
420
|
+
| `assertUnauthorized` / `assertForbidden` / `assertNotFound` / `assertUnprocessable` | `(): this` | Assert `401` / `403` / `404` / `422`. |
|
|
421
|
+
| `assertServerError` | `(): this` | Assert `500`. |
|
|
422
|
+
| `assertRedirect` | `assertRedirect(url: string): this` | Assert a `3xx` whose `Location` path equals `url`. |
|
|
423
|
+
| `assertRedirectContains` | `assertRedirectContains(fragment: string): this` | Assert a `3xx` whose `Location` merely contains `fragment` — for a signed URL. |
|
|
424
|
+
| `assertHeader` | `assertHeader(name, value?): this` | Assert a header is present (and contains `value`). |
|
|
425
|
+
| `assertHeaderMissing` | `assertHeaderMissing(name): this` | Assert a header is absent. |
|
|
426
|
+
| `assertJson` | `assertJson(expected): this` | Assert each key in `expected` matches the JSON body. |
|
|
427
|
+
| `assertJsonPath` | `assertJsonPath(path, expected): this` | Assert a dot-notation path in the JSON body. |
|
|
428
|
+
| `assertJsonCount` | `assertJsonCount(count, key?): this` | Assert an array length at the body or `key`. |
|
|
429
|
+
| `assertSee` / `assertBodyContains` | `(needle): this` | Assert the body contains `needle`. |
|
|
430
|
+
| `assertDontSee` | `assertDontSee(needle): this` | Assert the body does not contain `needle`. |
|
|
431
|
+
| `assertSeeText` / `assertDontSeeText` | `(needle): this` | The same, against the body with its tags stripped. |
|
|
432
|
+
| `assertInvalid` | `assertInvalid(fields?): this` | Assert validation failed, optionally on `fields`. |
|
|
433
|
+
| `assertValid` | `assertValid(fields?): this` | Assert validation did not fail. |
|
|
434
|
+
| `validationErrors` | `(): Record<string, string[]> \| null` | The errors, from the body or the session. |
|
|
435
|
+
| `assertAuthenticated` | `(): this` | Assert the session holds a `user_id`. |
|
|
436
|
+
| `assertAuthenticatedAs` | `assertAuthenticatedAs(user \| id): this` | Assert that specific user is signed in. |
|
|
437
|
+
| `assertGuest` | `(): this` | Assert nobody is signed in. |
|
|
438
|
+
| `assertCookie` | `assertCookie(name, value?): this` | Assert a `Set-Cookie` (and optional value). |
|
|
439
|
+
| `assertCookieMissing` | `assertCookieMissing(name): this` | Assert no such cookie is set. |
|
|
440
|
+
| `assertSessionHas` | `assertSessionHas(key, value?): this` | Assert the session contains `key`. |
|
|
441
|
+
| `assertSessionMissing` | `assertSessionMissing(key): this` | Assert the session lacks `key`. |
|
|
442
|
+
| `assertSessionHasErrors` / `assertSessionHasNoErrors` | `(fields?): this` | Assert flashed validation errors. |
|
|
443
|
+
| `session` | `(): Record<string, unknown> \| null` | The decoded session. |
|
|
444
|
+
| `assertInertia` | `assertInertia(component?, props?): this` | Assert the Inertia page and a partial prop match. |
|
|
445
|
+
| `assertInertiaProp` | `assertInertiaProp(key, value?): this` | Assert a single Inertia prop. |
|
|
446
|
+
| `inertia` | `(): InertiaPage \| null` | The Inertia page object, from either wire shape. |
|
|
447
|
+
| `exception` | `(): unknown` | The exception the request raised, if any. |
|
|
448
|
+
| `json` | `json<T>(): T` | Parse and return the full JSON body. |
|
|
449
|
+
| `text` | `text(): string` | Return the body as text. |
|
|
450
|
+
| `status` / `ok` / `headers` | getters | The underlying `Response` status, `ok`, and headers. |
|
|
435
451
|
|
|
436
452
|
## Next steps
|
|
437
453
|
|
package/docs/testing/index.md
CHANGED
|
@@ -209,6 +209,146 @@ observers, global scopes, and state-machine callbacks, plus framework event
|
|
|
209
209
|
subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
|
|
210
210
|
using those helpers don't need the explicit `afterEach`.
|
|
211
211
|
|
|
212
|
+
## Pages render
|
|
213
|
+
|
|
214
|
+
A test that asserts a status code or an Inertia payload proves the _server_ did its
|
|
215
|
+
job. It proves nothing about the component, and a page can throw on its first paint
|
|
216
|
+
while every such test passes — the route answers `200`, the payload is correct, and
|
|
217
|
+
the failure happens in a browser the suite never opened.
|
|
218
|
+
|
|
219
|
+
An app shipped a blank page to production with **614 passing tests** exactly that way:
|
|
220
|
+
a [layout callback](/docs/inertia/rendering#persistent-layouts) read `page.props`,
|
|
221
|
+
which the callback is not given.
|
|
222
|
+
|
|
223
|
+
`renderPage()` builds the component tree and lets whatever it throws escape:
|
|
224
|
+
|
|
225
|
+
```typescript fragment
|
|
226
|
+
// tests/pages.test.ts
|
|
227
|
+
import { renderPage } from "@zerotal/inertia/testing";
|
|
228
|
+
import Profile from "../resources/js/pages/profile";
|
|
229
|
+
|
|
230
|
+
test("profile builds", async () => {
|
|
231
|
+
await renderPage(Profile, { title: "Profile" }, { shared: SHARED });
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
It renders through Inertia's own `<App>`, so `usePage()`, `<Head>` and a persistent
|
|
236
|
+
layout all behave as they do in the browser — the layout is resolved and rendered
|
|
237
|
+
too, which is the case worth catching.
|
|
238
|
+
|
|
239
|
+
Two things to know:
|
|
240
|
+
|
|
241
|
+
- **Seed the shared props.** A component that destructures `auth` or `flash` throws
|
|
242
|
+
without them. That is a real failure and rarely the one you are testing for, so
|
|
243
|
+
pass the shape your `Inertia.share()` actually sends.
|
|
244
|
+
- **It is not a DOM.** `useEffect` does not run and nothing clicks; this is
|
|
245
|
+
`renderToString`. For behaviour after paint, use
|
|
246
|
+
[the browser harness](/docs/testing/browser).
|
|
247
|
+
|
|
248
|
+
The React scaffold ships one of these covering every page it generates. Add a line
|
|
249
|
+
when you add a page — the cost is one line and the bug it catches is a white screen
|
|
250
|
+
your users find first.
|
|
251
|
+
|
|
252
|
+
## `bun test` vs `bun zt test`
|
|
253
|
+
|
|
254
|
+
Both run the same files. `bun zt test` is a wrapper that sets up three things Bun's
|
|
255
|
+
runner does not, and each of them has cost somebody a day:
|
|
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` |
|
|
262
|
+
|
|
263
|
+
### The timeout
|
|
264
|
+
|
|
265
|
+
Bun's default per-test timeout is 5000ms, and a suite that boots an app per file
|
|
266
|
+
exceeds it on a loaded machine — CI, or a laptop that has just run `tsc`. The
|
|
267
|
+
failures look like flakes, which is the expensive part: a flake gets re-run, and a
|
|
268
|
+
re-run passes.
|
|
269
|
+
|
|
270
|
+
`bun zt test` sets `--timeout=30000`. If you run `bun test` directly, pass it
|
|
271
|
+
yourself, because **the two documented-looking alternatives do not work**:
|
|
272
|
+
|
|
273
|
+
- `[test] timeout` in `bunfig.toml` — ignored.
|
|
274
|
+
- `setDefaultTimeout()` in a preload — applies to the first test file only. Bun
|
|
275
|
+
re-imports the preload per file, but the setting does not survive.
|
|
276
|
+
|
|
277
|
+
The command-line flag is the only mechanism that covers hooks as well as tests,
|
|
278
|
+
which matters because it is usually a `beforeAll` that boots the app.
|
|
279
|
+
|
|
280
|
+
### The runtime
|
|
281
|
+
|
|
282
|
+
`engines.bun` in your `package.json` is a floor, and until you enforce it, it is a
|
|
283
|
+
comment. The shell's `bun` and the project's can differ, and the difference between
|
|
284
|
+
two Bun releases is real and narrow: `Intl` formatting, the SQLite bindings and
|
|
285
|
+
`node:` compatibility all move. So a handful of currency or date assertions go red
|
|
286
|
+
and the rest pass, and you go looking for a bug in the code they touch, because
|
|
287
|
+
nothing in the failure says "wrong binary".
|
|
288
|
+
|
|
289
|
+
`bun zt test` refuses to run below the declared floor. Direct `bun test` runs get the
|
|
290
|
+
same check as a warning if you load the preload:
|
|
291
|
+
|
|
292
|
+
```toml
|
|
293
|
+
# bunfig.toml
|
|
294
|
+
[test]
|
|
295
|
+
preload = ["@zerotal/testing/preload"]
|
|
296
|
+
timeout = 30000 # note: currently ignored by Bun — pass --timeout on the command line
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Set `ZT_ALLOW_RUNTIME_MISMATCH=1` to downgrade the refusal to a warning while you
|
|
300
|
+
are mid-upgrade.
|
|
301
|
+
|
|
302
|
+
### `@zerotal/core/runtime`
|
|
303
|
+
|
|
304
|
+
The checks behind the two paragraphs above, exported so a script or a test of your
|
|
305
|
+
own can make the same assertion. `zt` runs both at the top of every command; the test
|
|
306
|
+
preload runs the floor check as a warning.
|
|
307
|
+
|
|
308
|
+
| Export | Signature | What it answers |
|
|
309
|
+
| -------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
310
|
+
| `declaredBunFloor` | `declaredBunFloor(cwd): { range, manifest } \| null` | The nearest `engines.bun` up the tree from `cwd`. |
|
|
311
|
+
| `runtimeBelowFloor` | `runtimeBelowFloor(cwd?): RuntimeFloor \| null` | Is this process below that floor? `null` when it is met or none is declared. |
|
|
312
|
+
| `runtimeBelowFloorMessage` | `runtimeBelowFloorMessage(floor): string` | The explanation to print — both versions, the manifest, and the way out. |
|
|
313
|
+
| `installedBunVersion` | `installedBunVersion(cwd): { version, manifest } \| null` | The Bun in `node_modules`, if the project installs one as a package. |
|
|
314
|
+
| `declaresBunDependency` | `declaresBunDependency(cwd): boolean` | Whether the project _asked_ for that package, or acquired it as a transitive peer. |
|
|
315
|
+
| `runtimeMismatch` | `runtimeMismatch(cwd?): RuntimeMismatch \| null` | Does the running Bun differ from the installed one? Compared exactly — a patch is a binary. |
|
|
316
|
+
| `runtimeMismatchMessage` | `runtimeMismatchMessage(mismatch): string` | The explanation for that one. |
|
|
317
|
+
| `runtimeMismatchAllowed` | `runtimeMismatchAllowed(): boolean` | Whether `ZT_ALLOW_RUNTIME_MISMATCH` is set. |
|
|
318
|
+
| `bunBinary` | `bunBinary(): string` | The binary to spawn a child with — `process.execPath`, never the name PATH resolves. |
|
|
319
|
+
| `RUNTIME_MISMATCH_ESCAPE` | `"ZT_ALLOW_RUNTIME_MISMATCH"` | The env var name, so a script can set it without hardcoding the string. |
|
|
320
|
+
|
|
321
|
+
`RuntimeFloor` is `{ running, required, manifest }`; `RuntimeMismatch` is
|
|
322
|
+
`{ running, installed, manifest }`. Both name the file the second version came from,
|
|
323
|
+
because "which one is wrong" is the question you actually have.
|
|
324
|
+
|
|
325
|
+
## Configuration is per-process, and `bun test` is one process
|
|
326
|
+
|
|
327
|
+
Zerotal resolves configuration once, at boot. `bun test` runs every file in the same
|
|
328
|
+
process, so **whichever file boots the app first fixes the configuration for all of
|
|
329
|
+
them.**
|
|
330
|
+
|
|
331
|
+
A test that sets an environment variable in its own `beforeAll` and then asserts on
|
|
332
|
+
the resulting behaviour passes alone and fails in the suite — or worse, passes in the
|
|
333
|
+
suite for a reason unrelated to what it claims to test:
|
|
334
|
+
|
|
335
|
+
```typescript fragment
|
|
336
|
+
// Passes alone. In a suite, the app may already be booted with CSRF on, and the
|
|
337
|
+
// three "rejects without a token" assertions below pass on a 419 they would have
|
|
338
|
+
// got anyway — never reaching the guard they name.
|
|
339
|
+
beforeAll(() => {
|
|
340
|
+
Bun.env.CSRF_DISABLED = "1";
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Assert on the _relationship_ rather than on a literal — the published origin equals
|
|
345
|
+
the configured one, whatever it is — or boot a dedicated app for the case:
|
|
346
|
+
|
|
347
|
+
```typescript fragment
|
|
348
|
+
const app = await createTestApp({ config: { app: { url: "https://example.test" } } });
|
|
349
|
+
expect(page.canonical).toBe(config("app.url"));
|
|
350
|
+
```
|
|
351
|
+
|
|
212
352
|
## Running the suite from a script
|
|
213
353
|
|
|
214
354
|
A script that gates on the tests has to read the tests' exit status, and a pipe hides it:
|
package/docs/upgrade.md
CHANGED
|
@@ -10,15 +10,28 @@ what changed in each version, see the [Release Notes](/docs/changelog).
|
|
|
10
10
|
|
|
11
11
|
## Versioning
|
|
12
12
|
|
|
13
|
-
Zerotal
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
Zerotal's `@zerotal/*` packages share one version line, and what each number means
|
|
14
|
+
is set by how much the framework still moves in a year rather than by the letter of
|
|
15
|
+
semver:
|
|
16
|
+
|
|
17
|
+
- **Patch** (`x.y.Z`) — anything that does not break. Fixes, and features too.
|
|
18
|
+
Safe to take at any time.
|
|
19
|
+
- **Minor** (`x.Y.z`) — a breaking change. Always labelled **BREAKING** in the
|
|
20
|
+
[Release Notes](/docs/changelog), with the reason and the migration steps, and
|
|
21
|
+
given its own section on this page.
|
|
22
|
+
- **Major** (`X.y.z`) — an annual consolidation, cut each July. The next is 2.0, in
|
|
23
|
+
July 2027.
|
|
24
|
+
|
|
25
|
+
Why not strict semver: a framework this young corrects itself often, and under
|
|
26
|
+
strict semver every correction is a major. A version line that reaches 9.0 in its
|
|
27
|
+
first year tells a reader nothing about how much has changed — only that the
|
|
28
|
+
project is willing to break things, which the release notes already say far more
|
|
29
|
+
precisely. Keeping the major for a yearly line in the sand leaves it meaning
|
|
30
|
+
something, and puts the work where it is useful: reading the notes for each minor.
|
|
31
|
+
|
|
32
|
+
> **Warning** — **a caret range crosses a minor.** `"zerotal": "^1.10.0"` will
|
|
33
|
+
> install 1.11.0, and its breaking change, without asking. Read the notes for every
|
|
34
|
+
> minor you cross, or pin with a tilde (`~1.10.0`) and cross them deliberately.
|
|
22
35
|
|
|
23
36
|
> **Warning** — always upgrade the `@zerotal/*` packages together. Mixing versions across core, ORM, and feature packages leads to type and runtime mismatches.
|
|
24
37
|
|
|
@@ -169,6 +182,113 @@ these are the changes that need action. Full detail is in the
|
|
|
169
182
|
interface so pages that read them do not look unpassed; see
|
|
170
183
|
[Typed props](/docs/inertia/props#typed-props).
|
|
171
184
|
|
|
185
|
+
## 1.9 to 1.10
|
|
186
|
+
|
|
187
|
+
Three settings changed meaning. Each is quiet if it does not apply to you, and each is
|
|
188
|
+
worth thirty seconds of checking if it does.
|
|
189
|
+
|
|
190
|
+
1. **`scheduler.timezone` is honoured.** It was documented as informational and read by
|
|
191
|
+
nothing, so whatever you put there had no effect and your schedules ran in the
|
|
192
|
+
server's zone. It is now the zone every schedule is evaluated in unless the task sets
|
|
193
|
+
its own.
|
|
194
|
+
|
|
195
|
+
Its default moved from the literal `"UTC"` to **the system zone**, so an app that never
|
|
196
|
+
set the key keeps doing exactly what it did. The case to check is an app that _did_:
|
|
197
|
+
|
|
198
|
+
```ts fragment
|
|
199
|
+
// config/scheduler.ts
|
|
200
|
+
export default SchedulerConfig({ timezone: env("APP_TIMEZONE", "UTC") });
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
On a server that is not on UTC, that line used to do nothing and now moves every
|
|
204
|
+
schedule. Either set it to the zone you actually want your crons read in — which is
|
|
205
|
+
the point of the setting — or delete the key to keep the server's zone.
|
|
206
|
+
|
|
207
|
+
`bun zt schedule:list` prints each task's next run in its own zone, which is the
|
|
208
|
+
quickest way to see whether anything moved.
|
|
209
|
+
|
|
210
|
+
2. **Named rate limiters need `.trustedProxies(n)` behind a proxy.** `RateLimiter`'s
|
|
211
|
+
`.byIp()`, `.byUser()` and `.byApiKey()` ignored the proxy count entirely and read
|
|
212
|
+
`X-Forwarded-For` unconditionally. They now follow the same rule `ThrottleMiddleware`
|
|
213
|
+
already did — the header is consulted only when you say how many proxies sit in front:
|
|
214
|
+
|
|
215
|
+
```ts fragment
|
|
216
|
+
RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Without it the address used is the socket's, which behind a proxy is the _proxy's_, and
|
|
220
|
+
every visitor shares one bucket. `bun zt doctor` reports any limiter that needs this —
|
|
221
|
+
it could not before, because its check exempted custom key resolvers and all three of
|
|
222
|
+
these are one.
|
|
223
|
+
|
|
224
|
+
3. **React apps using SSR need `@inertiajs/react` installed.** The same adapter your
|
|
225
|
+
browser entry point already uses. Server-side rendering now goes through its `<App>`,
|
|
226
|
+
which is what makes `<Head>` produce a title and an og: card in the HTML your server
|
|
227
|
+
actually sends. If it is missing you get a named error at render time, not a silent
|
|
228
|
+
omission.
|
|
229
|
+
|
|
230
|
+
Nothing to change if you already have it as a dependency, which every React Inertia app
|
|
231
|
+
does.
|
|
232
|
+
|
|
233
|
+
## 1.10 to 1.11
|
|
234
|
+
|
|
235
|
+
Two changes to how the database is treated. Both are **BREAKING** in the narrow sense
|
|
236
|
+
that a working app can stop working on upgrade, and both refuse loudly rather than
|
|
237
|
+
doing something quiet.
|
|
238
|
+
|
|
239
|
+
1. **SQLite enforces foreign keys.** `database.sqlite.foreignKeys` defaults to `true`,
|
|
240
|
+
so `PRAGMA foreign_keys = ON` is set on every connection. Until now SQLite ignored
|
|
241
|
+
them, which meant `constrained()` and `cascadeOnDelete()` in your migrations
|
|
242
|
+
described behaviour the database would not perform — deleting a parent left its
|
|
243
|
+
children, silently.
|
|
244
|
+
|
|
245
|
+
The risk is data you already have. A child row whose parent is missing was legal
|
|
246
|
+
without enforcement and is a constraint violation with it, so a write touching one
|
|
247
|
+
now fails. Find them before deploying:
|
|
248
|
+
|
|
249
|
+
```bash fragment
|
|
250
|
+
bun zt db:check-foreign-keys
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
It lists every offending row by table and rowid and exits non-zero, so a release
|
|
254
|
+
script can gate on it. `zt doctor` reports the same thing. Delete them or repoint
|
|
255
|
+
them at a parent that exists.
|
|
256
|
+
|
|
257
|
+
To take the release without dealing with it yet:
|
|
258
|
+
|
|
259
|
+
```ts fragment
|
|
260
|
+
// config/database.ts
|
|
261
|
+
export default DatabaseConfig({ sqlite: { foreignKeys: false } });
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Take that override back off afterwards. With it in place `cascadeOnDelete()` is a
|
|
265
|
+
comment.
|
|
266
|
+
|
|
267
|
+
2. **A renumbered migration is refused rather than re-run.** A migration is recorded
|
|
268
|
+
under its filename, so renaming one makes an applied migration look pending — the
|
|
269
|
+
runner tries it again and fails on `table already exists`. Renumbering `001_` to
|
|
270
|
+
`0001_` to match the scaffold's convention is exactly the kind of tidying that
|
|
271
|
+
causes it, and it takes every migration with it.
|
|
272
|
+
|
|
273
|
+
`migrate` now recognises that shape and stops:
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
"0001_create_users" looks like "001_create_users", which has already run — the
|
|
277
|
+
same migration renumbered rather than a new one.
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
If you meant to rename, pin the identity to what the database already holds and the
|
|
281
|
+
filename is then free:
|
|
282
|
+
|
|
283
|
+
```ts fragment
|
|
284
|
+
export default class CreateUsers extends Migration {
|
|
285
|
+
static override id = "001_create_users";
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
If it really is a new migration, give it a name that does not collide once the
|
|
290
|
+
leading digits are removed.
|
|
291
|
+
|
|
172
292
|
## The managed zt.ts
|
|
173
293
|
|
|
174
294
|
`zt.ts` is framework-managed — the header says _do not modify_. If a release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/arch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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.
|
|
38
|
+
"@zerotal/core": "1.11.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.
|
|
42
|
+
"@zerotal/orm": "1.11.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": [
|
|
@@ -150,7 +150,7 @@ export interface GuidelineOptions {
|
|
|
150
150
|
* How this project is configured, from {@link detectShape}. Omitted, the block
|
|
151
151
|
* is what it always was — a function of the package list.
|
|
152
152
|
*/
|
|
153
|
-
shape?: ProjectShape;
|
|
153
|
+
shape?: ProjectShape | undefined;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
package/src/mcp/stdio.ts
CHANGED
|
@@ -27,11 +27,11 @@ import type { JsonRpcResponse } from "./types.ts";
|
|
|
27
27
|
export interface StdioOptions {
|
|
28
28
|
server: McpServer;
|
|
29
29
|
/** Byte source. Defaults to this process's stdin. */
|
|
30
|
-
input?: ReadableStream<Uint8Array
|
|
30
|
+
input?: ReadableStream<Uint8Array> | undefined;
|
|
31
31
|
/** Frame sink. Defaults to this process's stdout. Injected in tests. */
|
|
32
|
-
write?: (frame: string) => void;
|
|
32
|
+
write?: ((frame: string) => void) | undefined;
|
|
33
33
|
/** Diagnostics sink. Defaults to stderr — never stdout. */
|
|
34
|
-
log?: (message: string) => void;
|
|
34
|
+
log?: ((message: string) => void) | undefined;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
package/src/tools/_probe.ts
CHANGED
|
@@ -35,8 +35,8 @@ export interface ProbeRunner {
|
|
|
35
35
|
|
|
36
36
|
export interface SpawnProbeOptions {
|
|
37
37
|
/** Where to start looking for the app. Defaults to the server's working directory. */
|
|
38
|
-
cwd?: string;
|
|
39
|
-
timeoutMs?: number;
|
|
38
|
+
cwd?: string | undefined;
|
|
39
|
+
timeoutMs?: number | undefined;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|