@zerotal/arch 1.11.2 → 1.12.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/docs/changelog.md CHANGED
@@ -27,6 +27,57 @@ 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.12.0 — 2026-08-31
31
+
32
+ One change, deliberately alone: the minor exists to carry it.
33
+
34
+ A field report from an app running in production found a feature flag reading as
35
+ enabled for every record that had it turned off. Nothing errored, nothing logged, and
36
+ the database was doing exactly what it had been asked to.
37
+
38
+ ### Changed — BREAKING
39
+
40
+ - **A boolean written to a column declared to hold text is refused.**
41
+
42
+ A bare `@column()` resolves to `{ type: "string" }` — the right default for the
43
+ common case, and the wrong one for a boolean. A text column has text affinity, so
44
+ `false` was stored as the string `"0"`, and `"0"` is truthy in JavaScript. Every
45
+ `if (model.flag)` on such a column took the wrong branch for a stored `false`, on
46
+ every row, silently.
47
+
48
+ There is no correct coercion. `0` becomes `"0"`; `"false"` is truthy too. The value
49
+ cannot survive the round trip, so the only honest options were to refuse the write or
50
+ to keep letting a stored `false` read back as `true`. It now raises
51
+ `ColumnTypeError`, naming the property and the fix:
52
+
53
+ ```
54
+ [Zerotal ORM] Widget.active is declared as a `string` column and was given a boolean.
55
+ A text column stores that as "0"/"1", and "0" is truthy in JavaScript — so a stored
56
+ `false` would read back as true and every `if (…)` on it would take the wrong branch.
57
+ Declare the column's type instead: `@column("boolean")`.
58
+ ```
59
+
60
+ The decorator cannot pick for you: `declare active: boolean` erases the TypeScript
61
+ type at runtime, so the property looks identical to a decorator whether it holds a
62
+ boolean or a string. Declaring the type is the only signal there is — which is why
63
+ the mistake is worth refusing loudly rather than guessing at.
64
+
65
+ An explicit `@column({ type: "string", cast: "boolean" })` is still honoured. That is
66
+ someone stating what they meant; the guard is for the column that says nothing.
67
+
68
+ ### Before you upgrade
69
+
70
+ - **Find the boolean properties whose `@column()` declares no type.** Nothing can find
71
+ them for you, for the reason above — a search of your models for a bare `@column()`,
72
+ read against the property types beside them, is the reliable way.
73
+ - **The rows you already wrote are still text.** This stops new bad writes; it does not
74
+ migrate old ones. Those rows keep reading truthy until they are converted. The
75
+ [upgrade guide](/docs/upgrade#1-11-to-1-12) has the statement.
76
+
77
+ ### Added
78
+
79
+ - **`ColumnTypeError`** — exported, so an app can catch it by class.
80
+
30
81
  ## 1.11.2 — 2026-08-31
31
82
 
32
83
  `@zerotal/ai` is `stable`, and the release that promotes it is the one that fixes five
@@ -101,6 +152,37 @@ which is the only thing between a user's prompt and a log that outlives the requ
101
152
  Both hold up — the parser reassembles a frame whose terminator is split across chunks
102
153
  and a UTF-8 sequence cut mid-character.
103
154
 
155
+ #### **BREAKING** — `countTokens` can return `null`
156
+
157
+ `Ai.countTokens()` and `AiDriver.countTokens()` return `number | null` rather than
158
+ `number`. Only Anthropic has a counting endpoint; the other drivers returned `0`, which
159
+ is also a real count for an empty prompt, so the old value was a number you could divide
160
+ by and budget against without ever being told it meant "unsupported".
161
+
162
+ ```ts
163
+ // before
164
+ const tokens = await Ai.countTokens(prompt);
165
+ if (tokens > 1000) shorten();
166
+
167
+ // after
168
+ const tokens = await Ai.countTokens(prompt);
169
+ if (tokens !== null && tokens > 1000) shorten();
170
+ ```
171
+
172
+ A custom `AiDriver` implementation compiles unchanged — returning `number` still
173
+ satisfies `Promise<number | null>`. It is callers who need the check.
174
+
175
+ **This should have been a minor.** It shipped in a patch, which the versioning scheme
176
+ says cannot carry a break; see [the note in the upgrade guide](/docs/upgrade#1-11-2).
177
+
178
+ #### **INTERNAL** — four `@zerotal/ai` exports left the promised surface
179
+
180
+ `toSchema`, `strippedConstraints`, `resetSpend` and `resetStats` are `@internal`. They
181
+ are still exported and still work, so nothing breaks — they are simply no longer
182
+ covered by the compatibility promise, which is the narrowing that had to happen before
183
+ the package could be promoted at all. Reach for `AiFake` where a test used `resetSpend`
184
+ or `resetStats`; it is the seam built for that.
185
+
104
186
  ### Fixed — the gates
105
187
 
106
188
  - **The release workflow ran three checks; the pull-request workflow ran fifteen.** So
package/docs/orm/index.md CHANGED
@@ -227,6 +227,34 @@ so it is a **string**. TypeScript cannot catch either — the decorator does not
227
227
  constrain the property type — so an annotation that disagrees compiles fine and
228
228
  fails at the first `.diffForHumans()` or arithmetic.
229
229
 
230
+ #### A boolean needs `@column("boolean")`
231
+
232
+ A bare `@column()` resolves to `{ type: "string" }` — the right default for the common
233
+ case, and a trap for a boolean. Writing one to a text column is **refused**:
234
+
235
+ ```
236
+ [Zerotal ORM] Widget.active is declared as a `string` column and was given a boolean.
237
+ A text column stores that as "0"/"1", and "0" is truthy in JavaScript — so a stored
238
+ `false` would read back as true and every `if (…)` on it would take the wrong branch.
239
+ Declare the column's type instead: `@column("boolean")`.
240
+ ```
241
+
242
+ There is no correct coercion, which is why it refuses rather than converting. SQLite
243
+ gives a text column text affinity, so an integer `0` written there is stored as the
244
+ string `"0"`, and `"0"` is truthy in JavaScript. `"false"` is truthy too. The value
245
+ cannot survive the round trip in either direction, so the only honest options are to
246
+ refuse the write or to let a stored `false` read back as `true` — which is what used to
247
+ happen, on every row, with nothing in the app or the database registering a fault.
248
+
249
+ The decorator cannot infer the type for you: `declare active: boolean` erases the
250
+ TypeScript type at runtime, so the property looks the same to a decorator whether it
251
+ holds a boolean or a string. Declaring the type is the only signal there is.
252
+
253
+ If you genuinely want the text `"true"`/`"false"`, assign a string. If you want a
254
+ boolean stored in a text column on purpose, say so with a cast —
255
+ `@column({ type: "string", cast: "boolean" })` is honoured, because it is someone
256
+ stating what they meant.
257
+
230
258
  ### Indexes and uniqueness
231
259
 
232
260
  Declare constraints on the column and schema generation emits them, so `migrate:generate` produces a schema with the guarantees your application depends on rather than a bare set of columns:
@@ -649,6 +677,15 @@ Type helpers exported from `@zerotal/orm`:
649
677
  | `UpdatePayload<T>` | The shape accepted by `fill()` / `update()`. |
650
678
  | `DatabaseConfigShape` | The `config/database.ts` configuration type. |
651
679
 
680
+ ### Errors
681
+
682
+ | Error | Thrown when |
683
+ | ------------------------ | ------------------------------------------------------------------------------------------------------ |
684
+ | `ModelNotFoundError` | `findOrFail()` / `firstOrFail()` found no row. |
685
+ | `MassAssignmentError` | `fill()` / `create()` received an attribute the model's rules do not allow. |
686
+ | `ColumnTypeError` | A boolean was written to a column declared to hold text — see [above](#a-boolean-needs-columnboolean). |
687
+ | `RelationNotLoadedError` | A relation was read without being loaded, under strict relation access. |
688
+
652
689
  ### Commands
653
690
 
654
691
  `@zerotal/orm` ships the migration, model, and seeding commands. Every one runs through `bun zt`:
@@ -75,10 +75,19 @@ 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. Four have shipped so far — the
78
+ own section in the Upgrade Guide. Six have shipped so far — the
79
79
  `ComponentWith` / `BaseModelWith` removal in 1.3.0, Flow's `socket:` listener
80
- prefix in 1.7.2, the removal of Flow's `this.title(…)` in 1.7.3, and SQLite
81
- foreign-key enforcement in 1.11.0.
80
+ prefix in 1.7.2, the removal of Flow's `this.title(…)` in 1.7.3, SQLite
81
+ foreign-key enforcement in 1.11.0, `countTokens` returning `number | null` in
82
+ 1.11.2, and the refusal to write a boolean into a text column in 1.12.0.
83
+ - **One of those five is in the wrong place, and it stays on the record.** 1.11.2
84
+ is a patch, and by the rule above a patch cannot carry a break. It did: the
85
+ `countTokens` signature changed in the same release that promoted `@zerotal/ai`
86
+ to `stable`, and the reasoning that allowed it — the package was still
87
+ `experimental` when the change was made, earlier in that release — is not a
88
+ distinction anyone installing 1.11.2 can observe. What they get is a patch that
89
+ breaks. It is listed here rather than argued away, because a policy that quietly
90
+ excuses its own exceptions is not one you can plan against.
82
91
  - **Provenance:** packages are published with npm provenance, so you can verify
83
92
  a tarball was built by this repository's release workflow rather than someone's
84
93
  laptop.
package/docs/upgrade.md CHANGED
@@ -289,6 +289,85 @@ doing something quiet.
289
289
  If it really is a new migration, give it a name that does not collide once the
290
290
  leading digits are removed.
291
291
 
292
+ ## 1.11 to 1.12
293
+
294
+ One breaking change, and it is the intended kind: a minor, announced, with the reason.
295
+
296
+ **A boolean written to a text column is refused.** A bare `@column()` resolves to
297
+ `{ type: "string" }`, so a boolean property decorated with one was stored as text —
298
+ and a text column has text affinity, so `false` was stored as `"0"`, which is truthy
299
+ in JavaScript. Every `if (model.flag)` on such a column took the wrong branch for a
300
+ stored `false`, on every row, with nothing in the app or the database registering a
301
+ fault. An app found it when a feature flag read as enabled for every record that had
302
+ it turned off.
303
+
304
+ ```ts fragment
305
+ // in a model class body — before, and silently wrong
306
+ @column() declare active: boolean;
307
+
308
+ // after
309
+ @column("boolean") declare active: boolean;
310
+ ```
311
+
312
+ There is no correct coercion: `0` becomes `"0"` and `"false"` is truthy too, so the
313
+ value cannot survive the round trip. The write now throws `ColumnTypeError`, naming
314
+ the property and the fix.
315
+
316
+ ### What to do before upgrading
317
+
318
+ **Find your boolean properties whose `@column()` declares no type.** The decorator
319
+ cannot find them for you — `declare active: boolean` erases the TypeScript type at
320
+ runtime, so a bare `@column()` on a boolean is indistinguishable from one on a string
321
+ until a value arrives. A search of your models for `@column()` with no argument, read
322
+ against the property types beside them, is the reliable way.
323
+
324
+ **The rows you already wrote are still text.** This release stops new bad writes; it
325
+ does not migrate old ones. A column that has been holding `"0"` and `"1"` needs both
326
+ the decorator fixed and the stored values converted — on SQLite,
327
+ `UPDATE widgets SET active = CAST(active AS INTEGER)` after the column type is
328
+ corrected. Until then those rows keep reading truthy, which is the behaviour you are
329
+ upgrading to escape.
330
+
331
+ **If a text column really should hold a boolean**, say so explicitly and it is
332
+ honoured: `@column({ type: "string", cast: "boolean" })`. The guard is for the column
333
+ that says nothing, not for every string column.
334
+
335
+ ## 1.11.2
336
+
337
+ One breaking change, and it is in a release that should not have carried one.
338
+
339
+ **`countTokens` returns `number | null`.** `Ai.countTokens()` and
340
+ `AiDriver.countTokens()` used to return `number`, with `0` standing in for "this
341
+ provider cannot count". Only Anthropic has a counting endpoint, and `0` is also a real
342
+ count for an empty prompt — so the old return value could not tell you which it meant,
343
+ and a budget built on it was quietly wrong for every other provider.
344
+
345
+ ```ts fragment
346
+ // before
347
+ const tokens = await Ai.countTokens(prompt);
348
+ if (tokens > 1000) shorten();
349
+
350
+ // after
351
+ const tokens = await Ai.countTokens(prompt);
352
+ if (tokens !== null && tokens > 1000) shorten();
353
+ ```
354
+
355
+ A custom `AiDriver` needs no change — returning `number` still satisfies
356
+ `Promise<number | null>`. Only callers do.
357
+
358
+ **Why this is in a patch.** It was made while `@zerotal/ai` was still `experimental`
359
+ and therefore outside the compatibility promise, in the same release that then promoted
360
+ the package to `stable`. That ordering is real and it is not a distinction anyone
361
+ installing 1.11.2 can observe: what arrives is a patch that breaks a build. The rule
362
+ stands as written — a patch does not break — and this release is recorded as the
363
+ exception rather than as a reinterpretation of it. See
364
+ [the support policy](/docs/support-policy#releases-and-versioning).
365
+
366
+ Everything else in 1.11.2 is additive. `@zerotal/ai`'s other surface change — `toSchema`,
367
+ `strippedConstraints`, `resetSpend` and `resetStats` becoming `@internal` — leaves those
368
+ exports working; they are no longer covered by the promise, which is different from
369
+ being gone.
370
+
292
371
  ## The managed zt.ts
293
372
 
294
373
  `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.11.2",
3
+ "version": "1.12.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.11.2"
38
+ "@zerotal/core": "1.12.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.11.2"
42
+ "@zerotal/orm": "1.12.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": [