@zerotal/orm 1.7.3 → 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,6 +8,29 @@ 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
+
11
34
  ### Fixed
12
35
 
13
36
  - **A boolean column could not hold a boolean on PostgreSQL.** `table.boolean()` compiled to
@@ -19,6 +42,10 @@ follows the Zerotal monorepo's unified versioning.
19
42
  tables keep their integer columns until a migration alters them. Found by the new smoke suite
20
43
  that runs the ORM against a real PostgreSQL in CI.
21
44
 
45
+ ## [1.7.2] — 2026-08-18
46
+
47
+ ### Fixed
48
+
22
49
  - **A seeder that failed partway left its rows behind.** `Seeder.call()` has always wrapped
23
50
  _composed_ seeders in a transaction, so a `DatabaseSeeder` that delegates was atomic and one that
24
51
  does its work inline — which is most of them — was not. A failure on the fourth table committed
@@ -31,8 +58,6 @@ follows the Zerotal monorepo's unified versioning.
31
58
  no connection is bound, because a seeder is not obliged to touch the database and an app that has
32
59
  not configured one should not fail to seed over a transaction it never needed.
33
60
 
34
- ### Fixed
35
-
36
61
  - **`DatabaseProvider` now runs in `worker`, so `zt queue:work` can boot.** It did not, and the
37
62
  consequence was total rather than partial: `QueueProvider` _does_ run in `worker`, the
38
63
  queue's own default driver is `sqlite`, and so the worker asked for a connection this
@@ -51,6 +76,10 @@ follows the Zerotal monorepo's unified versioning.
51
76
 
52
77
  Found building the first cookbook app, whose first queued job could not run.
53
78
 
79
+ ## [1.7.1] — 2026-08-16
80
+
81
+ ### Fixed
82
+
54
83
  - **Relation keys now accept the JS spelling, like every other identifier.** The convention is
55
84
  camelCase in the application and snake_case in the database, converted on the way through —
56
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, _isBoolean?: 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>
@@ -574,6 +574,7 @@ class MysqlDialect = {
574
574
  readonly name: 'mysql'
575
575
  readonly supportsAdvisoryLocks: true
576
576
  readonly supportsTransactionalDdl: false
577
+ stringType: (length: number) => string
577
578
  }
578
579
 
579
580
  class NPlusOneDetected = {
@@ -615,6 +616,7 @@ class PostgresDialect = {
615
616
  readonly name: 'postgres'
616
617
  readonly supportsAdvisoryLocks: true
617
618
  readonly supportsTransactionalDdl: true
619
+ stringType: () => string
618
620
  }
619
621
 
620
622
  class QueryBuilder = {
@@ -754,6 +756,7 @@ class SqliteDialect = {
754
756
  readonly name: 'sqlite'
755
757
  readonly supportsAdvisoryLocks: false
756
758
  readonly supportsTransactionalDdl: true
759
+ stringType: () => string
757
760
  }
758
761
 
759
762
  class StateError = {
@@ -1274,6 +1277,7 @@ interface SqlDialect = {
1274
1277
  readonly name: DialectName
1275
1278
  readonly supportsAdvisoryLocks: boolean
1276
1279
  readonly supportsTransactionalDdl: boolean
1280
+ stringType: (length: number) => string
1277
1281
  }
1278
1282
 
1279
1283
  interface SQLInstance = {
@@ -1315,13 +1319,13 @@ type ClassRef = abstract new (...args: never[]) => unknown
1315
1319
 
1316
1320
  type Columns = { [K in keyof T & string]: K extends `_${string}` ? never : T[K] extends (...args: any[]) => any ? never : K; }[keyof T & string]
1317
1321
 
1318
- 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'
1319
1323
 
1320
1324
  type Constructor = new (...args: any[]) => T
1321
1325
 
1322
1326
  type ContextConnectionResolver = (ModelClass?: typeof BaseModel) => SQLInstance | null
1323
1327
 
1324
- type DatePart = 'date' | 'time' | 'year' | 'month' | 'day'
1328
+ type DatePart = 'date' | 'time' | 'day' | 'month' | 'year'
1325
1329
 
1326
1330
  type DialectName = 'sqlite' | 'postgres' | 'mysql'
1327
1331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.7.3",
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.3",
35
- "@zerotal/validator": "1.7.3"
34
+ "@zerotal/core": "1.7.4",
35
+ "@zerotal/validator": "1.7.4"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
@@ -58,6 +58,12 @@ export class MysqlDialect implements SqlDialect {
58
58
  return value ? "1" : "0";
59
59
  }
60
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
+
61
67
  advisoryLockSql(key: number): DialectQuery {
62
68
  return { sql: `SELECT GET_LOCK(?, -1)`, params: [`zerotal_lock_${key}`] };
63
69
  }
@@ -54,6 +54,11 @@ export class PostgresDialect implements SqlDialect {
54
54
  return value ? "TRUE" : "FALSE";
55
55
  }
56
56
 
57
+ // PostgreSQL indexes TEXT without a key length, so the portable type stands.
58
+ stringType(): string {
59
+ return "TEXT";
60
+ }
61
+
57
62
  advisoryLockSql(key: number): DialectQuery {
58
63
  return { sql: `SELECT pg_advisory_lock(?)`, params: [key] };
59
64
  }
@@ -55,6 +55,11 @@ export class SqliteDialect implements SqlDialect {
55
55
  return value ? "1" : "0";
56
56
  }
57
57
 
58
+ // SQLite has one string type and no length to honour.
59
+ stringType(): string {
60
+ return "TEXT";
61
+ }
62
+
58
63
  advisoryLockSql(): DialectQuery | null {
59
64
  return null;
60
65
  }
@@ -76,6 +76,22 @@ export interface SqlDialect {
76
76
  /** A boolean as this engine spells it in a `DEFAULT` clause. */
77
77
  booleanLiteral(value: boolean): string;
78
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
+
79
95
  /** Whether the engine supports application-level advisory locks. */
80
96
  readonly supportsAdvisoryLocks: boolean;
81
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
  /**
@@ -69,6 +69,11 @@ export class ColumnBuilder<Locked extends string = never> {
69
69
  * storage type at compile time — see {@link SqlDialect.booleanType}.
70
70
  */
71
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,
72
77
  ) {
73
78
  this._isPrimary = isPrimary;
74
79
  this._isAutoIncr = isAutoIncrement;
@@ -329,9 +334,15 @@ export class ColumnBuilder<Locked extends string = never> {
329
334
  // PostgreSQL a syntax error and against MySQL a 1064.
330
335
  if (this._isAutoIncr) return getDialect(dialect).autoIncrementColumn(this.name);
331
336
 
332
- // A boolean's storage type is the engine's to choose: SQLite stores 0/1 in an
333
- // INTEGER, PostgreSQL has a real one and rejects the integer form outright.
334
- const sqlType = this._isBoolean ? getDialect(dialect).booleanType : 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;
335
346
  const parts: string[] = [`${this.name} ${sqlType}`];
336
347
 
337
348
  if (this._isPrimary) parts.push("PRIMARY KEY");