@zerotal/orm 1.7.2 → 1.7.4

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,10 +8,46 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.7.4] — 2026-08-21
12
+
13
+ ### Fixed
14
+
15
+ - **A string column could not carry an index on MySQL.** `table.string()` compiled to `TEXT`
16
+ on every engine and discarded its `length` argument — the parameter existed and was
17
+ documented as "accepted for multi-DB compatibility", wired to nothing. MySQL refuses to key
18
+ a TEXT column without a prefix length, so `table.string("email").unique()` failed at
19
+ `CREATE TABLE`:
20
+
21
+ ```text
22
+ BLOB/TEXT column 'email' used in key specification without a key length
23
+ ```
24
+
25
+ Any natural key — an email, a slug — was unusable on MySQL, and `index()` the same. The
26
+ storage type now comes from the dialect, beside `booleanType` and `autoIncrementColumn`:
27
+ MySQL gets `VARCHAR(length)`, while SQLite and PostgreSQL keep `TEXT`, which PostgreSQL
28
+ indexes happily. `char()` had the identical bug and the identical fix.
29
+
30
+ Found by the new MySQL smoke suite on its first run against a real server.
31
+
32
+ ## [1.7.3] — 2026-08-20
33
+
34
+ ### Fixed
35
+
36
+ - **A boolean column could not hold a boolean on PostgreSQL.** `table.boolean()` compiled to
37
+ `INTEGER` on every engine — correct on SQLite, which has no boolean type, and rejected outright
38
+ by PostgreSQL: `column "…" is of type integer but expression is of type boolean` (42804) on the
39
+ first insert, and again on any `where(column, true)`. `DEFAULT` clauses failed the same way, a
40
+ boolean default having been serialised to `1`. The storage type now comes from the dialect, as
41
+ the auto-increment column already did. SQLite and MySQL are unchanged; existing PostgreSQL
42
+ tables keep their integer columns until a migration alters them. Found by the new smoke suite
43
+ that runs the ORM against a real PostgreSQL in CI.
44
+
45
+ ## [1.7.2] — 2026-08-18
46
+
11
47
  ### Fixed
12
48
 
13
49
  - **A seeder that failed partway left its rows behind.** `Seeder.call()` has always wrapped
14
- *composed* seeders in a transaction, so a `DatabaseSeeder` that delegates was atomic and one that
50
+ _composed_ seeders in a transaction, so a `DatabaseSeeder` that delegates was atomic and one that
15
51
  does its work inline — which is most of them — was not. A failure on the fourth table committed
16
52
  the first three, so the obvious next move, running it again, died on a unique constraint, and the
17
53
  only way out was `migrate:fresh`. Migrations became transactional in 1.7.0; this closes the
@@ -22,9 +58,6 @@ follows the Zerotal monorepo's unified versioning.
22
58
  no connection is bound, because a seeder is not obliged to touch the database and an app that has
23
59
  not configured one should not fail to seed over a transaction it never needed.
24
60
 
25
-
26
- ### Fixed
27
-
28
61
  - **`DatabaseProvider` now runs in `worker`, so `zt queue:work` can boot.** It did not, and the
29
62
  consequence was total rather than partial: `QueueProvider` _does_ run in `worker`, the
30
63
  queue's own default driver is `sqlite`, and so the worker asked for a connection this
@@ -43,6 +76,10 @@ follows the Zerotal monorepo's unified versioning.
43
76
 
44
77
  Found building the first cookbook app, whose first queued job could not run.
45
78
 
79
+ ## [1.7.1] — 2026-08-16
80
+
81
+ ### Fixed
82
+
46
83
  - **Relation keys now accept the JS spelling, like every other identifier.** The convention is
47
84
  camelCase in the application and snake_case in the database, converted on the way through —
48
85
  and relation keys were the one place it did not happen. `@hasMany(() => Issue, { foreignKey:
package/api-surface.md CHANGED
@@ -116,7 +116,7 @@ class Blueprint = {
116
116
  bigInteger: (name: string) => ColumnBuilder
117
117
  binary: (name: string) => ColumnBuilder
118
118
  boolean: (name: string) => ColumnBuilder
119
- char: (name: string, _length?: number) => ColumnBuilder
119
+ char: (name: string, length?: number) => ColumnBuilder
120
120
  date: (name: string) => ColumnBuilder
121
121
  dateTime: (name: string) => ColumnBuilder
122
122
  datetime: (name: string) => ColumnBuilder
@@ -153,7 +153,7 @@ class Blueprint = {
153
153
  smallInteger: (name: string) => ColumnBuilder
154
154
  softDeletes: (column?: string) => void
155
155
  spatialIndex: (columns: string | string[], name?: string) => Blueprint
156
- string: (name: string, _length?: number) => ColumnBuilder
156
+ string: (name: string, length?: number) => ColumnBuilder
157
157
  text: (name: string) => ColumnBuilder
158
158
  time: (name: string) => ColumnBuilder
159
159
  timestamp: (name: string) => ColumnBuilder
@@ -182,7 +182,7 @@ class Cast = {
182
182
  }
183
183
 
184
184
  class ColumnBuilder = {
185
- new <Locked extends string = never>(name: string, _sqlType: string, isPrimary?: boolean, isAutoIncrement?: boolean): ColumnBuilder<Locked>
185
+ new <Locked extends string = never>(name: string, _sqlType: string, isPrimary?: boolean, isAutoIncrement?: boolean, _isBoolean?: boolean, _stringLength?: number | undefined): ColumnBuilder<Locked>
186
186
  after: (_column: string) => ColumnBuilder<Locked>
187
187
  alter: () => ColumnBuilder<Locked>
188
188
  before: (_column: string) => ColumnBuilder<Locked>
@@ -566,12 +566,15 @@ class MysqlDialect = {
566
566
  advisoryLockSql: (key: number) => DialectQuery
567
567
  advisoryUnlockSql: (key: number) => DialectQuery
568
568
  autoIncrementColumn: (column: string) => string
569
+ booleanLiteral: (value: boolean) => string
569
570
  dateExpr: (part: DatePart, column: string) => string
570
571
  hasColumnSql: (table: string, column: string) => DialectQuery
571
572
  hasTableSql: (table: string) => DialectQuery
573
+ readonly booleanType: 'INTEGER'
572
574
  readonly name: 'mysql'
573
575
  readonly supportsAdvisoryLocks: true
574
576
  readonly supportsTransactionalDdl: false
577
+ stringType: (length: number) => string
575
578
  }
576
579
 
577
580
  class NPlusOneDetected = {
@@ -605,12 +608,15 @@ class PostgresDialect = {
605
608
  advisoryLockSql: (key: number) => DialectQuery
606
609
  advisoryUnlockSql: (key: number) => DialectQuery
607
610
  autoIncrementColumn: (column: string) => string
611
+ booleanLiteral: (value: boolean) => string
608
612
  dateExpr: (part: DatePart, column: string) => string
609
613
  hasColumnSql: (table: string, column: string) => DialectQuery
610
614
  hasTableSql: (table: string) => DialectQuery
615
+ readonly booleanType: 'BOOLEAN'
611
616
  readonly name: 'postgres'
612
617
  readonly supportsAdvisoryLocks: true
613
618
  readonly supportsTransactionalDdl: true
619
+ stringType: () => string
614
620
  }
615
621
 
616
622
  class QueryBuilder = {
@@ -742,12 +748,15 @@ class SqliteDialect = {
742
748
  advisoryLockSql: () => DialectQuery | null
743
749
  advisoryUnlockSql: () => DialectQuery | null
744
750
  autoIncrementColumn: (column: string) => string
751
+ booleanLiteral: (value: boolean) => string
745
752
  dateExpr: (part: DatePart, column: string) => string
746
753
  hasColumnSql: (table: string, column: string) => DialectQuery
747
754
  hasTableSql: (table: string) => DialectQuery
755
+ readonly booleanType: 'INTEGER'
748
756
  readonly name: 'sqlite'
749
757
  readonly supportsAdvisoryLocks: false
750
758
  readonly supportsTransactionalDdl: true
759
+ stringType: () => string
751
760
  }
752
761
 
753
762
  class StateError = {
@@ -1260,12 +1269,15 @@ interface SqlDialect = {
1260
1269
  advisoryLockSql: (key: number) => DialectQuery | null
1261
1270
  advisoryUnlockSql: (key: number) => DialectQuery | null
1262
1271
  autoIncrementColumn: (column: string) => string
1272
+ booleanLiteral: (value: boolean) => string
1263
1273
  dateExpr: (part: DatePart, column: string) => string
1264
1274
  hasColumnSql: (table: string, column: string) => DialectQuery
1265
1275
  hasTableSql: (table: string) => DialectQuery
1276
+ readonly booleanType: string
1266
1277
  readonly name: DialectName
1267
1278
  readonly supportsAdvisoryLocks: boolean
1268
1279
  readonly supportsTransactionalDdl: boolean
1280
+ stringType: (length: number) => string
1269
1281
  }
1270
1282
 
1271
1283
  interface SQLInstance = {
@@ -1307,13 +1319,13 @@ type ClassRef = abstract new (...args: never[]) => unknown
1307
1319
 
1308
1320
  type Columns = { [K in keyof T & string]: K extends `_${string}` ? never : T[K] extends (...args: any[]) => any ? never : K; }[keyof T & string]
1309
1321
 
1310
- type ColumnShorthand = 'string' | 'number' | 'boolean' | 'text' | 'date' | 'datetime' | 'array' | 'integer' | 'json' | 'float' | 'encrypted' | 'encrypted:json'
1322
+ type ColumnShorthand = 'string' | 'text' | 'integer' | 'number' | 'float' | 'boolean' | 'datetime' | 'date' | 'json' | 'array' | 'encrypted' | 'encrypted:json'
1311
1323
 
1312
1324
  type Constructor = new (...args: any[]) => T
1313
1325
 
1314
1326
  type ContextConnectionResolver = (ModelClass?: typeof BaseModel) => SQLInstance | null
1315
1327
 
1316
- type DatePart = 'date' | 'time' | 'year' | 'month' | 'day'
1328
+ type DatePart = 'date' | 'time' | 'day' | 'month' | 'year'
1317
1329
 
1318
1330
  type DialectName = 'sqlite' | 'postgres' | 'mysql'
1319
1331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
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.7.2",
35
- "@zerotal/validator": "1.7.2"
34
+ "@zerotal/core": "1.7.4",
35
+ "@zerotal/validator": "1.7.4"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
@@ -51,6 +51,19 @@ export class MysqlDialect implements SqlDialect {
51
51
  return `${column} INT AUTO_INCREMENT PRIMARY KEY`;
52
52
  }
53
53
 
54
+ // MySQL BOOLEAN is a synonym for TINYINT(1) and INTEGER accepts 0/1 all the same.
55
+ readonly booleanType = "INTEGER";
56
+
57
+ booleanLiteral(value: boolean): string {
58
+ return value ? "1" : "0";
59
+ }
60
+
61
+ // VARCHAR so the column can carry an index or a unique constraint; MySQL
62
+ // refuses to key a TEXT column without a prefix length.
63
+ stringType(length: number): string {
64
+ return `VARCHAR(${length})`;
65
+ }
66
+
54
67
  advisoryLockSql(key: number): DialectQuery {
55
68
  return { sql: `SELECT GET_LOCK(?, -1)`, params: [`zerotal_lock_${key}`] };
56
69
  }
@@ -48,6 +48,17 @@ export class PostgresDialect implements SqlDialect {
48
48
  return `${column} INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY`;
49
49
  }
50
50
 
51
+ readonly booleanType = "BOOLEAN";
52
+
53
+ booleanLiteral(value: boolean): string {
54
+ return value ? "TRUE" : "FALSE";
55
+ }
56
+
57
+ // PostgreSQL indexes TEXT without a key length, so the portable type stands.
58
+ stringType(): string {
59
+ return "TEXT";
60
+ }
61
+
51
62
  advisoryLockSql(key: number): DialectQuery {
52
63
  return { sql: `SELECT pg_advisory_lock(?)`, params: [key] };
53
64
  }
@@ -48,6 +48,18 @@ export class SqliteDialect implements SqlDialect {
48
48
  return `${column} INTEGER PRIMARY KEY AUTOINCREMENT`;
49
49
  }
50
50
 
51
+ // SQLite has no boolean type — 0/1 in an INTEGER is the storage class it uses.
52
+ readonly booleanType = "INTEGER";
53
+
54
+ booleanLiteral(value: boolean): string {
55
+ return value ? "1" : "0";
56
+ }
57
+
58
+ // SQLite has one string type and no length to honour.
59
+ stringType(): string {
60
+ return "TEXT";
61
+ }
62
+
51
63
  advisoryLockSql(): DialectQuery | null {
52
64
  return null;
53
65
  }
@@ -56,6 +56,42 @@ export interface SqlDialect {
56
56
  */
57
57
  autoIncrementColumn(column: string): string;
58
58
 
59
+ /**
60
+ * The column type a portable `table.boolean()` compiles to.
61
+ *
62
+ * SQLite has no boolean type and stores 0/1 in an `INTEGER`, which is why the
63
+ * Blueprint emitted `INTEGER` for every engine. PostgreSQL has a real `boolean`
64
+ * and refuses to compare or assign one against an integer column, so a table
65
+ * built that way rejected its own booleans — `column "active" is of type integer
66
+ * but expression is of type boolean` (SQLSTATE 42804) on the first insert, and
67
+ * again on any `where("active", true)`. Nothing caught it because no test
68
+ * executed the DDL against a server; `postgres.smoke.test.ts` now does.
69
+ *
70
+ * MySQL keeps `INTEGER`: its `BOOLEAN` is a synonym for `TINYINT(1)` and it
71
+ * accepts 0/1 either way, so there is no defect there to fix and no reason to
72
+ * churn the DDL of an engine no CI job covers.
73
+ */
74
+ readonly booleanType: string;
75
+
76
+ /** A boolean as this engine spells it in a `DEFAULT` clause. */
77
+ booleanLiteral(value: boolean): string;
78
+
79
+ /**
80
+ * The column type a portable `table.string(name, length)` compiles to.
81
+ *
82
+ * SQLite has one string type and ignores the length, so the Blueprint emitted
83
+ * `TEXT` for every engine and threw the length away. MySQL cannot index a TEXT
84
+ * column without a key length, so `table.string("name").unique()` — an email, a
85
+ * slug, any natural key — failed at `CREATE TABLE`:
86
+ *
87
+ * BLOB/TEXT column 'name' used in key specification without a key length
88
+ *
89
+ * The length was already in the signature and already documented as accepted;
90
+ * it just had nowhere to go. PostgreSQL keeps `TEXT`, which it indexes happily
91
+ * and which is the idiomatic choice there.
92
+ */
93
+ stringType(length: number): string;
94
+
59
95
  /** Whether the engine supports application-level advisory locks. */
60
96
  readonly supportsAdvisoryLocks: boolean;
61
97
 
@@ -202,19 +202,21 @@ export class Blueprint {
202
202
  /**
203
203
  * Variable-length string column (`VARCHAR`-style), stored as `TEXT`.
204
204
  * @param name - Column name.
205
- * @param _length - Max length; accepted for multi-DB compatibility but ignored on SQLite.
205
+ * @param length - Max length. Ignored on SQLite and PostgreSQL, which have one
206
+ * string type; on MySQL it becomes `VARCHAR(length)`, which is what lets the
207
+ * column carry an index or a unique constraint.
206
208
  * @category Column types
207
209
  */
208
- string(name: string, _length = 255): ColumnBuilder {
209
- return this._add(new ColumnBuilder(name, "TEXT"));
210
+ string(name: string, length = 255): ColumnBuilder {
211
+ return this._add(new ColumnBuilder(name, "TEXT", false, false, false, length));
210
212
  }
211
213
 
212
214
  /**
213
215
  * Fixed-length `CHAR` column. Stored as `TEXT` on SQLite; `_length` is ignored.
214
216
  * @category Column types
215
217
  */
216
- char(name: string, _length = 255): ColumnBuilder {
217
- return this._add(new ColumnBuilder(name, "TEXT"));
218
+ char(name: string, length = 255): ColumnBuilder {
219
+ return this._add(new ColumnBuilder(name, "TEXT", false, false, false, length));
218
220
  }
219
221
 
220
222
  /**
@@ -314,12 +316,15 @@ export class Blueprint {
314
316
  // ── Boolean ───────────────────────────────────────────────────────────────
315
317
 
316
318
  /**
317
- * Boolean column. Stored as `INTEGER` (0 / 1); JS booleans passed to
318
- * {@link ColumnBuilder.default} serialise to `1` / `0`.
319
+ * Boolean column. The storage type is the engine's: `INTEGER` holding 0 / 1 on
320
+ * SQLite and MySQL, a real `BOOLEAN` on PostgreSQL — which rejects the integer
321
+ * form for both assignment and comparison, so emitting `INTEGER` everywhere
322
+ * built a column that would not take its own booleans. JS booleans passed to
323
+ * {@link ColumnBuilder.default} follow the same engine's spelling.
319
324
  * @category Column types
320
325
  */
321
326
  boolean(name: string): ColumnBuilder {
322
- return this._add(new ColumnBuilder(name, "INTEGER"));
327
+ return this._add(new ColumnBuilder(name, "INTEGER", false, false, true));
323
328
  }
324
329
 
325
330
  // ── Date / time columns ───────────────────────────────────────────────────
@@ -64,6 +64,16 @@ export class ColumnBuilder<Locked extends string = never> {
64
64
  private _sqlType: string,
65
65
  isPrimary = false,
66
66
  isAutoIncrement = false,
67
+ /**
68
+ * Logically a boolean, whatever `_sqlType` says. The engine decides the
69
+ * storage type at compile time — see {@link SqlDialect.booleanType}.
70
+ */
71
+ private _isBoolean = false,
72
+ /**
73
+ * Declared max length for a portable string column. The engine decides
74
+ * whether it matters — see {@link SqlDialect.stringType}.
75
+ */
76
+ private _stringLength?: number,
67
77
  ) {
68
78
  this._isPrimary = isPrimary;
69
79
  this._isAutoIncr = isAutoIncrement;
@@ -324,13 +334,22 @@ export class ColumnBuilder<Locked extends string = never> {
324
334
  // PostgreSQL a syntax error and against MySQL a 1064.
325
335
  if (this._isAutoIncr) return getDialect(dialect).autoIncrementColumn(this.name);
326
336
 
327
- const parts: string[] = [`${this.name} ${this._sqlType}`];
337
+ // Storage type is the engine's to choose wherever the engines disagree:
338
+ // SQLite keeps 0/1 in an INTEGER where PostgreSQL has a real boolean, and
339
+ // MySQL needs a VARCHAR length before it will index a string at all.
340
+ const d = getDialect(dialect);
341
+ const sqlType = this._isBoolean
342
+ ? d.booleanType
343
+ : this._stringLength !== undefined
344
+ ? d.stringType(this._stringLength)
345
+ : this._sqlType;
346
+ const parts: string[] = [`${this.name} ${sqlType}`];
328
347
 
329
348
  if (this._isPrimary) parts.push("PRIMARY KEY");
330
349
  if (!this._isNullable && !this._isPrimary) parts.push("NOT NULL");
331
350
 
332
351
  if (this._useCurrent) parts.push("DEFAULT CURRENT_TIMESTAMP");
333
- else if (this._hasDefault) parts.push(`DEFAULT ${this._serializeDefault()}`);
352
+ else if (this._hasDefault) parts.push(`DEFAULT ${this._serializeDefault(dialect)}`);
334
353
 
335
354
  if (this._isUnique) parts.push("UNIQUE");
336
355
  if (this._check) parts.push(`CHECK (${this._check})`);
@@ -340,10 +359,11 @@ export class ColumnBuilder<Locked extends string = never> {
340
359
  return parts.join(" ");
341
360
  }
342
361
 
343
- private _serializeDefault(): string {
362
+ private _serializeDefault(dialect: DialectName = "sqlite"): string {
344
363
  const v = this._default;
345
364
  if (v === null) return "NULL";
346
- if (typeof v === "boolean") return v ? "1" : "0";
365
+ // `DEFAULT 1` on a PostgreSQL boolean column is the same 42804 the value itself hit.
366
+ if (typeof v === "boolean") return getDialect(dialect).booleanLiteral(v);
347
367
  if (typeof v === "string") return `'${v.replace(/'/g, "''")}'`;
348
368
  return String(v);
349
369
  }