@zerotal/orm 1.11.2 → 1.13.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/CHANGELOG.md CHANGED
@@ -8,6 +8,37 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.12.0] — 2026-08-31
12
+
13
+ ### Changed — **BREAKING**
14
+
15
+ - **A boolean written to a column declared to hold text is refused.** A bare
16
+ `@column()` resolves to `{ type: "string" }`, so a boolean property decorated with
17
+ one was stored as text — and a text column has text affinity, so `false` was stored
18
+ as `"0"`, which is truthy in JavaScript. Every `if (model.flag)` took the wrong
19
+ branch for a stored `false`, on every row, with nothing in the app or the database
20
+ registering a fault. An app found it when a feature flag read as enabled for every
21
+ record that had it turned off.
22
+
23
+ There is no correct coercion — `0` becomes `"0"` and `"false"` is truthy too — so
24
+ the value cannot survive the round trip and the write raises `ColumnTypeError`
25
+ instead, naming the property and the fix: `@column("boolean")`.
26
+
27
+ The decorator cannot infer it for you. `declare active: boolean` erases the
28
+ TypeScript type at runtime, so a bare `@column()` on a boolean is indistinguishable
29
+ from one on a string until a value arrives. Declaring the type is the only signal
30
+ there is.
31
+
32
+ An explicit `@column({ type: "string", cast: "boolean" })` is honoured — that is
33
+ someone saying what they meant. The guard is for the column that says nothing.
34
+
35
+ **This release stops new bad writes; it does not migrate old rows.** See
36
+ [the upgrade guide](https://zerotal.dev/docs/upgrade#1-11-to-1-12).
37
+
38
+ ### Added
39
+
40
+ - **`ColumnTypeError`**, for the above.
41
+
11
42
  ## [1.11.0] — 2026-08-31
12
43
 
13
44
  ### Changed
package/api-surface.md CHANGED
@@ -208,6 +208,13 @@ class ColumnBuilder = {
208
208
  wantsIndex: boolean
209
209
  }
210
210
 
211
+ class ColumnTypeError = {
212
+ new (column: string, declared: string): ColumnTypeError
213
+ readonly code: string
214
+ readonly context?: Record<string, unknown> | undefined
215
+ readonly status: number
216
+ }
217
+
211
218
  class DatabaseProvider = {
212
219
  new (app: Application): DatabaseProvider
213
220
  static dependsOn?: (new (app: Application) => ServiceProvider)[]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.11.2",
3
+ "version": "1.13.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -31,8 +31,8 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.11.2",
35
- "@zerotal/validator": "1.11.2"
34
+ "@zerotal/core": "1.13.0",
35
+ "@zerotal/validator": "1.13.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
@@ -0,0 +1,39 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown when a boolean is written to a column declared to hold text.
5
+ *
6
+ * The reason this is an error rather than a coercion is that there is no correct
7
+ * coercion. SQLite gives a `TEXT` column text affinity, so an integer `0` written
8
+ * there is stored as the string `"0"` — and `"0"` is truthy in JavaScript. A
9
+ * `false` written to a text column therefore reads back as something that passes
10
+ * `if (model.flag)`, silently, on every row, forever. Writing `"false"` has the
11
+ * same problem. The value cannot survive the round trip, so the write is refused
12
+ * instead of being quietly made wrong.
13
+ *
14
+ * **A bare `@column()` is a text column.** With no argument it resolves to
15
+ * `{ type: "string" }`, which is the right default for the common case and the
16
+ * trap here: a boolean property decorated with a bare `@column()` looks declared
17
+ * and is stored as text. The `declare` keyword erases the property's TypeScript
18
+ * type at runtime, so the decorator cannot infer `boolean` and pick for you —
19
+ * which is exactly why the mistake is worth naming loudly rather than guessing at.
20
+ *
21
+ * Reported from production: a feature flag read as enabled for every record that
22
+ * had it turned off, and nothing in the app or the database registered a fault.
23
+ */
24
+ export class ColumnTypeError extends ZerotalError {
25
+ constructor(column: string, declared: string) {
26
+ super(
27
+ `[Zerotal ORM] ${column} is declared as a \`${declared}\` column and was given a boolean. ` +
28
+ `A text column stores that as "0"/"1", and "0" is truthy in JavaScript — so a stored ` +
29
+ `\`false\` would read back as true and every \`if (…)\` on it would take the wrong branch. ` +
30
+ `Declare the column's type instead: \`@column("boolean")\`. ` +
31
+ `A bare \`@column()\` defaults to \`string\`, which is the usual cause. ` +
32
+ `If you genuinely want the text "true"/"false", assign a string.`,
33
+ "E_COLUMN_TYPE",
34
+ 500,
35
+ { column, declared },
36
+ );
37
+ this.name = "ColumnTypeError";
38
+ }
39
+ }
@@ -3,5 +3,6 @@ export { RelationNotLoadedError } from "./RelationNotLoadedError.ts";
3
3
  export { TransactionError } from "./TransactionError.ts";
4
4
  export { MigrationError } from "./MigrationError.ts";
5
5
  export { StateError } from "./StateError.ts";
6
+ export { ColumnTypeError } from "./ColumnTypeError.ts";
6
7
  export { MassAssignmentError } from "./MassAssignmentError.ts";
7
8
  export { UnsupportedDialectError } from "./UnsupportedDialectError.ts";
package/src/index.ts CHANGED
@@ -206,6 +206,7 @@ export {
206
206
  MigrationError,
207
207
  StateError,
208
208
  UnsupportedDialectError,
209
+ ColumnTypeError,
209
210
  } from "./errors/index.ts";
210
211
 
211
212
  // Provider
@@ -30,6 +30,7 @@ import {
30
30
  ModelNotFoundError,
31
31
  RelationNotLoadedError,
32
32
  MassAssignmentError,
33
+ ColumnTypeError,
33
34
  } from "../errors/index.ts";
34
35
  import { type ManyToMany } from "./relations/RelationRegistry.ts";
35
36
  import { installReactiveAccessors, type ColumnOptions } from "./decorators/column.ts";
@@ -396,6 +397,13 @@ function _serializeForWrite(
396
397
  serializedVal = castOpt.set(val);
397
398
  } else if (typeof castOpt === "string") {
398
399
  serializedVal = applyCastSet(val, castOpt, model ? `${model}.${key}` : key);
400
+ } else if (typeof val === "boolean" && (colType === "string" || colType === "text")) {
401
+ // No coercion is correct here, so the write is refused rather than made wrong.
402
+ // A text column has text affinity: `0` is stored as "0", and "0" is truthy in
403
+ // JavaScript, so a stored `false` reads back as true on every row. "false" has
404
+ // the same problem. A bare `@column()` resolves to `{ type: "string" }`, which
405
+ // is how a boolean property ends up here looking perfectly declared.
406
+ throw new ColumnTypeError(model ? `${model}.${key}` : key, colType);
399
407
  } else if (colType === "boolean" && val !== null && val !== undefined) {
400
408
  serializedVal = val ? 1 : 0;
401
409
  } else if (colType === "json" && val !== null) {