@zerotal/orm 1.7.3 → 1.7.5

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>
@@ -277,13 +277,6 @@ class ForeignKeyBuilder = {
277
277
  toConstraintSQL: () => string
278
278
  }
279
279
 
280
- class HookRegistry = {
281
- new (): HookRegistry
282
- static onAfterRun: ((ModelClass: ClassRef, hook: HookName, model: unknown) => void) | undefined
283
- static register: <T>(ModelClass: ClassRef, hook: HookName, fn: HookFn<T>) => void
284
- static run: <T>(ModelClass: ClassRef, hook: HookName, model: T) => Promise<void>
285
- }
286
-
287
280
  class JsonCast = {
288
281
  new <T = unknown>(mapper?: CastMapper<T> | undefined): JsonCast<T>
289
282
  fields: () => CastField[]
@@ -574,6 +567,7 @@ class MysqlDialect = {
574
567
  readonly name: 'mysql'
575
568
  readonly supportsAdvisoryLocks: true
576
569
  readonly supportsTransactionalDdl: false
570
+ stringType: (length: number) => string
577
571
  }
578
572
 
579
573
  class NPlusOneDetected = {
@@ -615,6 +609,7 @@ class PostgresDialect = {
615
609
  readonly name: 'postgres'
616
610
  readonly supportsAdvisoryLocks: true
617
611
  readonly supportsTransactionalDdl: true
612
+ stringType: () => string
618
613
  }
619
614
 
620
615
  class QueryBuilder = {
@@ -754,6 +749,7 @@ class SqliteDialect = {
754
749
  readonly name: 'sqlite'
755
750
  readonly supportsAdvisoryLocks: false
756
751
  readonly supportsTransactionalDdl: true
752
+ stringType: () => string
757
753
  }
758
754
 
759
755
  class StateError = {
@@ -798,14 +794,10 @@ class UnsupportedDialectError = {
798
794
  readonly status: number
799
795
  }
800
796
 
801
- const columnRegistry = Map<ClassRef, Map<string, ColumnOptions>>
802
-
803
797
  const DB = { table(tableName: string): QueryBuilder; raw<T = Record<string, unknown>>(sql: TemplateStringsArray | string, ...rest: unknown[]): Promise<T[]>; transaction<T>(callback: (tx?: SQLInstance) => Promise<T>, attempts?: number): Promise<T>; beginTransaction(): Promise<ManualTransaction>; onPrimary(): { table(name: string): QueryBuilder; }; currentTx(): unknown | undefined; advisoryLock<T>(key: number, callback: () => Promise<T>): Promise<T>; preventNPlusOne(options?: NPlusOneOptions): void; allowNPlusOne(pattern: string, options?: { once?: boolean; }): void;}
804
798
 
805
799
  const ModelInspector = { load(pattern: string, cwd?: string): Promise<void>; all(): ModelSchema[]; fromClass(ctor: ClassRef): ModelSchema | null;}
806
800
 
807
- const modelsByName = Map<string, ClassRef>
808
-
809
801
  const relationRegistry = Map<ClassRef, Map<string, RelationMetadata>>
810
802
 
811
803
  const Schema = { create(table: string, callback: (bp: Blueprint) => void): Promise<void>; createIfNotExists(table: string, callback: (bp: Blueprint) => void): Promise<void>; table(name: string, callback: (bp: Blueprint) => void): Promise<void>; alter(name: string, callback: (bp: Blueprint) => void): Promise<void>; drop(table: string): Promise<void>; dropIfExists(table: string): Promise<void>; rename(from: string, to: string): Promise<void>; hasTable(table: string): Promise<boolean>; hasColumn(table: string, column: string): Promise<boolean>;}
@@ -816,32 +808,14 @@ const SchemaInspector = { tables(): Promise<string[]>; columns(table: stri
816
808
 
817
809
  const TransactionContext = AsyncLocalStorage<SQLInstance>
818
810
 
819
- function _clearModelConnections = () => void
820
-
821
- function _clearTransitionCallbacks = () => void
822
-
823
811
  function _getConnection = () => SQLInstance
824
812
 
825
813
  function _getDbConnectionOverride = () => SQLInstance | null
826
814
 
827
- function _getDialect = () => 'sqlite' | 'postgres' | 'mysql'
828
-
829
- function _getModelConnection = () => SQLInstance
830
-
831
815
  function _globalScopeRegistry = () => Map<ClassRef, Map<string, GlobalScopeCallback>>
832
816
 
833
- function _normaliseSqliteUrl = (raw: string) => string
834
-
835
- function _resolveConn = (ModelClass?: typeof BaseModel) => SQLInstance
836
-
837
- function _setBaseModelConnection = (conn: SQLInstance | null) => void
838
-
839
- function _setBaseModelDialect = (dialect: 'sqlite' | 'postgres' | 'mysql') => void
840
-
841
817
  function _setDbConnection = (conn: SQLInstance | null) => void
842
818
 
843
- function _setQueryBuilderDialect = (d: Dialect) => void
844
-
845
819
  function _setReadReplicas = (primary: SQLInstance, replicas: SQLInstance[]) => void
846
820
 
847
821
  function _suppressHooks = <T>(fn: () => Promise<T>) => Promise<T>
@@ -854,8 +828,6 @@ function belongsTo = (related: () => unknown, options: BelongsToOptions) => (_va
854
828
 
855
829
  function column = { (): ColumnDecorator; (type: ColumnShorthand): ColumnDecorator; (options: ColumnOptions): ColumnDecorator; (type: ColumnShorthand, options: Omit<ColumnOptions, 'type'>): ColumnDecorator;}
856
830
 
857
- function columnsFor = (ctor: ClassRef) => Map<string, ColumnOptions> | null
858
-
859
831
  function createReadWriteRouter = (primary: SQLInstance, replicas: SQLInstance[]) => SQLInstance
860
832
 
861
833
  function currentOrmContext = () => OrmContext
@@ -882,10 +854,6 @@ function json = <T = unknown>(mapper?: CastMapper<T>) => JsonCast<T>
882
854
 
883
855
  function manyToMany = (related: () => unknown, options: ManyToManyOptions) => (_value: unknown, context: ClassFieldDecoratorContext) => void
884
856
 
885
- function modelByName = (name: string) => ClassRef | undefined
886
-
887
- function modelForParam = (paramName: string) => ImplicitModel | undefined
888
-
889
857
  function morphedByMany = (related: () => unknown, options: MorphedByManyOptions) => (_value: unknown, context: ClassFieldDecoratorContext) => void
890
858
 
891
859
  function morphMany = (related: () => unknown, options: MorphManyOptions) => (_value: unknown, context: ClassFieldDecoratorContext) => void
@@ -900,14 +868,10 @@ function objectOf = <T = unknown>(mapper?: CastMapper<T>) => JsonCast<T>
900
868
 
901
869
  function preventNPlusOne = (options?: NPlusOneOptions) => void
902
870
 
903
- function registerColumn = (ctor: ClassRef, name: string, options: ColumnOptions) => void
904
-
905
871
  function registerConnectionResolver = (fn: ContextConnectionResolver | null) => void
906
872
 
907
873
  function registerImplicitBinding = () => void
908
874
 
909
- function registerModel = (ctor: ClassRef) => void
910
-
911
875
  function registerModelConnection = (name: string, conn: SQLInstance, dialect?: Dialect) => void
912
876
 
913
877
  function resetOrmContext = () => void
@@ -1203,21 +1167,6 @@ interface PaginateResult = {
1203
1167
  url: (page: number, baseUrl?: string, query?: Record<string, string>) => string
1204
1168
  }
1205
1169
 
1206
- interface QueryState = {
1207
- distinct: boolean
1208
- groupBys: string[]
1209
- havings: HavingClause[]
1210
- joins: JoinClause[]
1211
- limit: number | undefined
1212
- lock: string | undefined
1213
- offset: number | undefined
1214
- orders: OrderClause[]
1215
- selects: string[]
1216
- table: string
1217
- unions: UnionClause[]
1218
- wheres: WhereClause[]
1219
- }
1220
-
1221
1170
  interface RelationMetadata = {
1222
1171
  firstKey?: string
1223
1172
  foreignKey: string
@@ -1274,6 +1223,7 @@ interface SqlDialect = {
1274
1223
  readonly name: DialectName
1275
1224
  readonly supportsAdvisoryLocks: boolean
1276
1225
  readonly supportsTransactionalDdl: boolean
1226
+ stringType: (length: number) => string
1277
1227
  }
1278
1228
 
1279
1229
  interface SQLInstance = {
@@ -1311,17 +1261,15 @@ interface TransitionContext = {
1311
1261
 
1312
1262
  type CastMapper = ((raw: unknown) => T) | (new (...args: never[]) => T)
1313
1263
 
1314
- type ClassRef = abstract new (...args: never[]) => unknown
1315
-
1316
1264
  type Columns = { [K in keyof T & string]: K extends `_${string}` ? never : T[K] extends (...args: any[]) => any ? never : K; }[keyof T & string]
1317
1265
 
1318
- type ColumnShorthand = 'string' | 'number' | 'boolean' | 'text' | 'date' | 'datetime' | 'array' | 'integer' | 'json' | 'float' | 'encrypted' | 'encrypted:json'
1266
+ type ColumnShorthand = 'string' | 'text' | 'integer' | 'number' | 'float' | 'boolean' | 'datetime' | 'date' | 'json' | 'array' | 'encrypted' | 'encrypted:json'
1319
1267
 
1320
1268
  type Constructor = new (...args: any[]) => T
1321
1269
 
1322
1270
  type ContextConnectionResolver = (ModelClass?: typeof BaseModel) => SQLInstance | null
1323
1271
 
1324
- type DatePart = 'date' | 'time' | 'year' | 'month' | 'day'
1272
+ type DatePart = 'date' | 'time' | 'day' | 'month' | 'year'
1325
1273
 
1326
1274
  type DialectName = 'sqlite' | 'postgres' | 'mysql'
1327
1275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
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.5",
35
+ "@zerotal/validator": "1.7.5"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
package/src/config.ts CHANGED
@@ -19,11 +19,14 @@ export interface DatabaseConfigShape {
19
19
  * mutating queries (INSERT, UPDATE, DELETE, DDL) plus transactions to the
20
20
  * primary. No code changes are required in controllers or models.
21
21
  *
22
+ * `env()` with no fallback is `string | undefined`, so an unset replica has to
23
+ * drop out rather than widen the array — this field is `string[]`.
24
+ *
22
25
  * @example
23
26
  * replicas: [
24
27
  * env('REPLICA_1_URL'),
25
28
  * env('REPLICA_2_URL'),
26
- * ]
29
+ * ].filter((url) => url !== undefined)
27
30
  */
28
31
  replicas?: string[];
29
32
  /**
@@ -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");