@zerotal/orm 1.10.0 → 1.11.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,94 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.0] — 2026-08-31
12
+
13
+ ### Changed
14
+
15
+ - **BREAKING: SQLite enforces foreign keys.** `database.sqlite.foreignKeys` now
16
+ defaults to `true`, so `PRAGMA foreign_keys = ON` is set on every connection.
17
+ Until now SQLite ignored them, which made `constrained()` and `cascadeOnDelete()`
18
+ in a migration a statement of intent the database would not perform — deleting a
19
+ parent left its children, silently, and an app's data-erasure path missed three
20
+ tables because of it.
21
+
22
+ **What can break:** a child row whose parent is missing was legal without
23
+ enforcement and is a violation with it, so a write touching one now fails. Run
24
+ `zt db:check-foreign-keys` before deploying — it lists every offending row by table
25
+ and rowid and exits non-zero, so a release script can gate on it. `zt doctor`
26
+ reports the same. To take the release without dealing with it yet, set
27
+ `sqlite: { foreignKeys: false }`, and remove the override afterwards; with it in
28
+ place `cascadeOnDelete()` does nothing. See
29
+ [the upgrade guide](/docs/upgrade#1-10-to-1-11).
30
+
31
+ - **BREAKING: a renumbered migration is refused rather than re-run.** A migration is
32
+ recorded under its filename, so renaming one makes an applied migration look
33
+ pending — the runner tried it again and failed on `table already exists`, which is a
34
+ failed boot with an error naming a table rather than the rename. An app renumbered
35
+ `001_` to `0001_` to match this framework's own scaffold convention and would have
36
+ made all nine of its production migrations look unrun.
37
+
38
+ `migrate` now recognises a pending migration whose name matches a recorded one once
39
+ the leading digits are stripped, and stops with both spellings and the fix. It
40
+ refuses rather than warns because the alternative is running it, and running it is
41
+ the outage.
42
+
43
+ ### Added
44
+
45
+ - **`Migration.id`** — a declared identity that decouples a migration from its
46
+ filename. Set it to what the database already recorded and the file is free to be
47
+ renamed. Deliberately not a content hash: a migration's content is edited far more
48
+ often than its name, and a hash would make every edit look like a new migration.
49
+
50
+ - **`zt db:check-foreign-keys`** — lists the rows enforcement would reject, by table
51
+ and rowid, and exits non-zero when there are any.
52
+
53
+ ### Changed
54
+
55
+ - **`@column({ type: "integer" })` compiles.** `ColumnShorthand` has twelve names and
56
+ `ColumnOptions["type"]` had six, so `@column("integer")` worked and
57
+ `@column({ type: "integer", default: 0 })` did not — the vocabulary shrank by half
58
+ exactly when a caller needed `default`, `nullable` or `unique`, which is most real
59
+ columns. The error listed the six without mentioning that `integer` resolves to
60
+ `{ type: "number", cast: "integer" }`, so the way out was reading `SHORTHAND_MAP` in
61
+ the source.
62
+
63
+ The object form now takes either vocabulary and resolves a shorthand the way the
64
+ string form does. An explicit `cast` alongside one still wins: somebody who wrote
65
+ both meant the one they spelled out.
66
+
67
+ ### Fixed
68
+
69
+ - **Migration names no longer carry the platform that recorded them.** `Bun.Glob`
70
+ yields native separators, and the prefix strip was a forward-slash-only pattern —
71
+ so on Windows the whole joined path went into the `migrations` table. An app found
72
+ `database\migrations\010_add_tenant_limits` in ten dev rows and
73
+ `010_add_tenant_limits` in production, from the same files.
74
+
75
+ A database moved between the two re-runs every migration: every recorded name
76
+ misses, all of them look pending, and the first fails on `table already exists` —
77
+ a failed boot, not a graceful skip. The name is now the bare filename on both.
78
+
79
+ Note the half this does _not_ fix: the recorded identity is still the filename, so
80
+ renaming a migration makes it look unrun. The same app nearly took an outage
81
+ renumbering `001_` to `0001_` to match this framework's own scaffold convention.
82
+
83
+ ### Added
84
+
85
+ - **`database.sqlite.foreignKeys`**, and a `zt doctor` check for the schemas that
86
+ need it. **SQLite ignores foreign keys unless the connection asks it not to**, so
87
+ `constrained()` and `cascadeOnDelete()` in a migration described behaviour the
88
+ database would not perform: deleting a parent left its children, silently, and
89
+ every child had to be removed by hand in the right order by code that remembered
90
+ to. An app's data-erasure path swept fifteen tables and missed three — including
91
+ both holding uploaded files — so an erasure left the paperwork on disk.
92
+
93
+ Off by default, because turning it on can fail writes an existing database already
94
+ permits: a row whose parent is missing is legal without enforcement and a violation
95
+ with it. Run `PRAGMA foreign_key_check` before enabling on an existing database. The
96
+ doctor check warns only when the schema actually declares a foreign key, so an app
97
+ with none hears nothing.
98
+
11
99
  ## [1.9.0] — 2026-08-29
12
100
 
13
101
  ### Changed
package/api-surface.md CHANGED
@@ -287,6 +287,7 @@ class JsonCast = {
287
287
 
288
288
  class Migration = {
289
289
  new (): Migration
290
+ static id?: string
290
291
  down: () => Promise<void>
291
292
  up: () => Promise<void>
292
293
  }
@@ -838,7 +839,7 @@ interface DatabaseConfigShape = {
838
839
  driver: 'sqlite' | 'postgres' | 'mysql'
839
840
  pool?: { max?: number; idleTimeout?: number;} | undefined
840
841
  replicas?: string[] | undefined
841
- sqlite: { path: string;}
842
+ sqlite: { path: string; foreignKeys?: boolean | undefined;}
842
843
  synchronize?: boolean | { enabled: boolean; disruptive?: boolean;} | undefined
843
844
  url: string
844
845
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.10.0",
3
+ "version": "1.11.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.10.0",
35
- "@zerotal/validator": "1.10.0"
34
+ "@zerotal/core": "1.11.0",
35
+ "@zerotal/validator": "1.11.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
@@ -0,0 +1,80 @@
1
+ import { Command } from "@zerotal/core";
2
+ import { _getConnection } from "../db/DB.ts";
3
+
4
+ /** One row `PRAGMA foreign_key_check` objects to. */
5
+ interface Violation {
6
+ table: string;
7
+ rowid: number | null;
8
+ parent: string;
9
+ fkid: number;
10
+ }
11
+
12
+ /**
13
+ * `bun zt db:check-foreign-keys` — list the rows enforcement would reject.
14
+ *
15
+ * SQLite ignores foreign keys unless the connection asks it not to, and Zerotal now
16
+ * asks by default. That is the right default — a `cascadeOnDelete()` the database
17
+ * will not perform is worse than no cascade at all — but it changes what an
18
+ * *existing* database accepts: a child row whose parent is missing is perfectly legal
19
+ * without enforcement and a constraint violation with it. So an app that has been
20
+ * running without it may already hold rows that a write will now fail on.
21
+ *
22
+ * This is how you find out before your users do. It reports every offending row by
23
+ * table and rowid, and exits non-zero when there are any, so a deploy script can gate
24
+ * on it.
25
+ *
26
+ * @example
27
+ * ```bash
28
+ * # before taking the upgrade, or any time after
29
+ * bun zt db:check-foreign-keys
30
+ * ```
31
+ *
32
+ * @category Database
33
+ */
34
+ export class CheckForeignKeysCommand extends Command {
35
+ static override commandName = "db:check-foreign-keys";
36
+ static override description = "List rows that violate a foreign key constraint";
37
+ static override needsApp = true;
38
+
39
+ async run(): Promise<void> {
40
+ const sql = _getConnection();
41
+
42
+ let violations: Violation[];
43
+ try {
44
+ violations = await sql<Violation>`PRAGMA foreign_key_check`;
45
+ } catch (error) {
46
+ // Postgres and MySQL always enforce, so they have no such pragma and nothing
47
+ // to report. Saying that is more useful than an error about syntax.
48
+ this.info(
49
+ `No foreign-key check to run on this driver — only SQLite can have enforcement off. ` +
50
+ `(${(error as Error).message})`,
51
+ );
52
+ return;
53
+ }
54
+
55
+ if (violations.length === 0) {
56
+ this.info("No foreign-key violations. Enforcement is safe to leave on.");
57
+ return;
58
+ }
59
+
60
+ this.error(`${violations.length} row(s) violate a foreign key:`);
61
+ this.newLine();
62
+ this.table(
63
+ violations.map((v) => [
64
+ `${v.table} rowid ${v.rowid ?? "?"}`,
65
+ `references ${v.parent}, which has no matching row`,
66
+ ]) as [string, string][],
67
+ );
68
+ this.newLine();
69
+ this.line(
70
+ "Each of these is a row whose parent is missing. With enforcement on, a write\n" +
71
+ "touching one fails. Delete them, or repoint them at a parent that exists.\n\n" +
72
+ "To keep deploying while you sort it out, set sqlite.foreignKeys: false in\n" +
73
+ "config/database.ts — and take the override back off afterwards, because with\n" +
74
+ "it in place cascadeOnDelete() does nothing.",
75
+ );
76
+
77
+ // Non-zero so a release script can gate on this the way it gates on tests.
78
+ process.exitCode = 1;
79
+ }
80
+ }
@@ -26,9 +26,40 @@ export async function loadMigrations(): Promise<MigrationRecord[]> {
26
26
  const mod = await import(`${process.cwd()}/${file}`);
27
27
  const Ctor = mod.default as (new () => MigrationRecord["instance"]) | undefined;
28
28
  if (!Ctor) continue;
29
- const name = file.replace(/^database\/migrations\//, "").replace(/\.ts$/, "");
30
- records.push({ name, instance: new Ctor() });
29
+ // A declared `static id` wins over the filename — see `Migration.id`.
30
+ const declared = (Ctor as { id?: string }).id;
31
+ records.push({ name: declared ?? _migrationName(file), instance: new Ctor() });
31
32
  }
32
33
 
33
34
  return records;
34
35
  }
36
+
37
+ /**
38
+ * The identity a migration is recorded under: its bare filename, no directory, no
39
+ * extension, no platform in it.
40
+ *
41
+ * `Bun.Glob` yields native separators, so on Windows this receives
42
+ * `database\migrations\010_x.ts`. The prefix used to be stripped with a
43
+ * forward-slash-only pattern, which matched nothing there — and the value written
44
+ * into the `migrations` table was the whole joined path, backslashes and all.
45
+ *
46
+ * Two ways that hurts, and the second is the dangerous one:
47
+ *
48
+ * - **A database moved between platforms re-runs every migration.** Every recorded
49
+ * name misses, all of them look pending, and the first one fails on `table
50
+ * already exists`. That is a failed boot rather than a graceful skip.
51
+ * - **The recorded name is compared against the file's**, so anything that changes
52
+ * the string makes a migration look unrun. An app renumbered `001_` to `0001_` to
53
+ * match this framework's own scaffold convention and nearly took an outage: nine
54
+ * applied migrations would have looked pending. Normalising the separator does not
55
+ * fix that half — a rename is still a new identity — but it stops the *platform*
56
+ * from being part of the string, which is the half nobody can see coming.
57
+ *
58
+ * @param file - Path as the glob yielded it, relative to the project root.
59
+ * @returns The bare migration name.
60
+ * @internal
61
+ */
62
+ export function _migrationName(file: string): string {
63
+ const normalised = file.split("\\").join("/");
64
+ return (normalised.split("/").at(-1) ?? normalised).replace(/\.[cm]?[jt]s$/, "");
65
+ }
package/src/config.ts CHANGED
@@ -45,6 +45,34 @@ export interface DatabaseConfigShape {
45
45
  sqlite: {
46
46
  /** Path to the SQLite file. Use ':memory:' for in-memory database. */
47
47
  path: string;
48
+ /**
49
+ * Enforce foreign keys on every SQLite connection (`PRAGMA foreign_keys = ON`).
50
+ *
51
+ * **SQLite ignores foreign keys unless you ask it not to**, and it is the only
52
+ * supported dialect that does. Without this, `cascadeOnDelete()` and
53
+ * `constrained()` in a migration are a statement of intent that the database
54
+ * will not perform: deleting a parent leaves its children behind, and every
55
+ * child has to be deleted by hand, in the right order, by application code that
56
+ * remembers to.
57
+ *
58
+ * That is not a theoretical cost. An app's data-erasure path swept fifteen
59
+ * tables and missed three — including both tables holding uploaded files — so an
60
+ * account erasure left the paperwork on disk and its rows in the database. They
61
+ * found it by having a second person write the runbook and compare the prose
62
+ * against the code.
63
+ *
64
+ * **On by default**, because an API describing behaviour the database will not
65
+ * perform is the worst of the available states — worse than not offering it.
66
+ *
67
+ * Set it to `false` for an existing database that may already hold rows a
68
+ * constraint would reject: a child whose parent is missing is legal without
69
+ * enforcement and a violation with it, so turning it on can fail a write the app
70
+ * has always made. `zt db:check-foreign-keys` lists exactly those rows, and
71
+ * `zt doctor` reports them too. Fix them, then remove the override.
72
+ *
73
+ * Default: `true`. Postgres and MySQL always enforce, so this is ignored there.
74
+ */
75
+ foreignKeys?: boolean | undefined;
48
76
  };
49
77
  /**
50
78
  * Auto-sync the schema to your models at boot (TypeORM-style). Opt-in, and
@@ -66,7 +94,7 @@ export interface DatabaseConfigShape {
66
94
  const defaults: DatabaseConfigShape = {
67
95
  driver: "sqlite",
68
96
  url: "./database/db.sqlite",
69
- sqlite: { path: "./database/db.sqlite" },
97
+ sqlite: { path: "./database/db.sqlite", foreignKeys: true },
70
98
  };
71
99
 
72
100
  /**
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The check for a migration that promises a cascade the database will not perform.
3
+ *
4
+ * SQLite ignores foreign keys unless the connection asks it not to, and it is the
5
+ * only supported dialect that does. So a migration reading
6
+ *
7
+ * ```ts
8
+ * table.foreignId("transaction_id").constrained("transactions").cascadeOnDelete();
9
+ * ```
10
+ *
11
+ * describes behaviour that does not happen: deleting a parent leaves its children,
12
+ * and every child has to be removed by hand, in the right order, by application code
13
+ * that remembers to. Nothing errors. Nothing logs. The declaration reads like a
14
+ * guarantee and is a comment.
15
+ *
16
+ * An app's data-erasure path swept fifteen tables and missed three, including both
17
+ * that held uploaded files — so an account erasure left the paperwork on disk and its
18
+ * rows in the database. That is a compliance failure produced by an API that reads
19
+ * like a promise, which is why this warns rather than staying quiet: an API
20
+ * describing behaviour the database will not perform is worse than either turning it
21
+ * on or not offering it.
22
+ *
23
+ * @module
24
+ */
25
+ import type { Application } from "@zerotal/core";
26
+ import type { DoctorCheck, DoctorCheckResult } from "@zerotal/core";
27
+ import type { SQLInstance } from "../db/sql-types.ts";
28
+
29
+ /** Whether this connection enforces foreign keys right now. */
30
+ async function _enforcing(sql: SQLInstance): Promise<boolean | null> {
31
+ try {
32
+ const rows = (await sql`PRAGMA foreign_keys`) as { foreign_keys?: number }[];
33
+ const value = rows[0]?.foreign_keys;
34
+ return value === undefined ? null : Number(value) === 1;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /** Tables that declare at least one foreign key, so the promise is actually being made. */
41
+ async function _tablesWithForeignKeys(sql: SQLInstance): Promise<number> {
42
+ try {
43
+ const rows = (await sql`
44
+ SELECT COUNT(*) AS n
45
+ FROM sqlite_master AS m
46
+ WHERE m.type = 'table'
47
+ AND m.sql LIKE '%REFERENCES%'
48
+ `) as { n?: number }[];
49
+ return Number(rows[0]?.n ?? 0);
50
+ } catch {
51
+ return 0;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Report a schema that declares foreign keys on a connection that ignores them.
57
+ *
58
+ * Silent when the app declares none — plenty of schemas legitimately have no
59
+ * constraints, and warning about an unused setting is how a doctor becomes noise.
60
+ */
61
+ export const foreignKeysCheck: DoctorCheck = {
62
+ id: "sqlite-foreign-keys",
63
+ label: "Foreign keys",
64
+ async run(app: Application): Promise<DoctorCheckResult> {
65
+ let driver = "sqlite";
66
+ let sql: SQLInstance | undefined;
67
+ try {
68
+ const config = app.container.makeSync("config") as { get<T>(k: string, d: T): T };
69
+ driver = config.get<string>("database.driver", "sqlite");
70
+ sql = app.container.makeSync("db") as SQLInstance;
71
+ } catch {
72
+ return { status: "ok", message: "no database connection to check" };
73
+ }
74
+
75
+ // Postgres and MySQL always enforce; there is nothing to get wrong.
76
+ if (driver !== "sqlite") return { status: "ok", message: `${driver} enforces them` };
77
+ if (!sql) return { status: "ok", message: "no database connection to check" };
78
+
79
+ const enforcing = await _enforcing(sql);
80
+ if (enforcing === null) return { status: "ok", message: "could not read the pragma" };
81
+
82
+ // Enforced: the remaining question is whether the data already agrees. A row whose
83
+ // parent is missing was legal before enforcement and is a violation now, so an
84
+ // upgraded database can carry rows that the next write touching them will fail on.
85
+ if (enforcing) {
86
+ const violations = await _violationCount(sql);
87
+ if (violations === 0) return { status: "ok", message: "enforced, and the data agrees" };
88
+ return {
89
+ status: "fail",
90
+ message:
91
+ `Foreign keys are enforced and ${violations} row(s) already violate one — rows whose ` +
92
+ `parent is missing, which were legal before enforcement and are not now. A write ` +
93
+ `touching one of them fails.`,
94
+ fix:
95
+ "Run `zt db:check-foreign-keys` to see them by table and rowid. Delete them or " +
96
+ "repoint them at a parent that exists. To keep deploying meanwhile, set " +
97
+ "sqlite.foreignKeys: false in config/database.ts — and take it back off after, " +
98
+ "because with it in place cascadeOnDelete() does nothing.",
99
+ };
100
+ }
101
+
102
+ const declaring = await _tablesWithForeignKeys(sql);
103
+ if (declaring === 0) {
104
+ return { status: "ok", message: "not enforced, and nothing declares one" };
105
+ }
106
+
107
+ return {
108
+ status: "warn",
109
+ message:
110
+ `${declaring} table(s) declare a foreign key, and this SQLite connection does not ` +
111
+ `enforce them — so \`constrained()\` and \`cascadeOnDelete()\` in your migrations ` +
112
+ `describe behaviour the database will not perform. Deleting a parent leaves its ` +
113
+ `children, silently.`,
114
+ fix:
115
+ "Set database.sqlite.foreignKeys: true in config/database.ts. On an existing " +
116
+ "database, run `PRAGMA foreign_key_check` first — enforcement turns an already-" +
117
+ "orphaned row into a write that fails.",
118
+ };
119
+ },
120
+ };
121
+
122
+ /** How many rows violate a foreign key right now. `0` when the pragma is unavailable. */
123
+ async function _violationCount(sql: SQLInstance): Promise<number> {
124
+ try {
125
+ const rows = (await sql`PRAGMA foreign_key_check`) as unknown[];
126
+ return rows.length;
127
+ } catch {
128
+ return 0;
129
+ }
130
+ }
@@ -82,6 +82,9 @@ export interface ColumnOptions {
82
82
  *
83
83
  * @default "string"
84
84
  */
85
+ // Spelled out rather than written as {@link ColumnStorageType}: this line is the
86
+ // recorded public surface, and replacing a union with an alias of identical members
87
+ // reads as a removal in the diff even though nothing changed.
85
88
  type?: "string" | "text" | "number" | "boolean" | "datetime" | "json" | undefined;
86
89
  /** Mark this column as the table's primary key. */
87
90
  primary?: boolean | undefined;
@@ -143,6 +146,28 @@ export interface ColumnOptions {
143
146
  /**
144
147
  * Map each ColumnShorthand to its resolved ColumnOptions.
145
148
  */
149
+ /**
150
+ * The types a column is actually *stored* as — what schema generation emits.
151
+ *
152
+ * Distinct from {@link ColumnShorthand}, which is the larger vocabulary a caller may
153
+ * write. `integer`, `float`, `date` and the encrypted forms are shorthands that
154
+ * resolve to one of these plus a cast.
155
+ */
156
+ export type ColumnStorageType = "string" | "text" | "number" | "boolean" | "datetime" | "json";
157
+
158
+ /**
159
+ * What `@column({ … })` accepts, as opposed to what it resolves to.
160
+ *
161
+ * The difference is `type`: a caller may write any {@link ColumnShorthand} there and
162
+ * it is resolved to a storage type plus a cast, exactly as the string form is.
163
+ * {@link ColumnOptions} — the resolved shape held in metadata and read back by
164
+ * schema generation — keeps the narrow storage union, because widening a property
165
+ * people *read* breaks the ones who assigned it to the narrower type.
166
+ */
167
+ export type ColumnOptionsInput = Omit<ColumnOptions, "type"> & {
168
+ type?: ColumnStorageType | ColumnShorthand | undefined;
169
+ };
170
+
146
171
  const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
147
172
  string: { type: "string" },
148
173
  text: { type: "text" },
@@ -170,7 +195,7 @@ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
170
195
  * `Omit<ColumnOptions, "type">`, so it cannot contradict the type it is modifying.
171
196
  */
172
197
  function resolveOptions(
173
- arg?: ColumnShorthand | ColumnOptions,
198
+ arg?: ColumnShorthand | ColumnOptionsInput,
174
199
  extra?: Omit<ColumnOptions, "type">,
175
200
  ): ColumnOptions {
176
201
  const base = _resolveBase(arg);
@@ -184,7 +209,7 @@ function resolveOptions(
184
209
  return merged;
185
210
  }
186
211
 
187
- function _resolveBase(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
212
+ function _resolveBase(arg?: ColumnShorthand | ColumnOptionsInput): ColumnOptions {
188
213
  if (arg === undefined) return { type: "string" };
189
214
  if (typeof arg === "string") {
190
215
  const a = arg as string;
@@ -197,7 +222,36 @@ function _resolveBase(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
197
222
  } as ColumnOptions;
198
223
  return SHORTHAND_MAP[a as ColumnShorthand] ?? { type: "string" };
199
224
  }
200
- return arg;
225
+ return _resolveOptionType(arg);
226
+ }
227
+
228
+ /**
229
+ * Let the object form take a shorthand `type`, resolving it the way the string form
230
+ * would.
231
+ *
232
+ * `@column("integer")` compiled and `@column({ type: "integer", default: 0 })` did
233
+ * not — so the vocabulary shrank from twelve names to six exactly when a caller
234
+ * needed `default`, `nullable` or `unique`, which is most real columns. And the
235
+ * error listed the six without mentioning that `integer` means
236
+ * `{ type: "number", cast: "integer" }`, so the way out was reading this file.
237
+ *
238
+ * An explicit `cast` wins over the shorthand's own: someone who wrote both meant the
239
+ * one they spelled out.
240
+ *
241
+ * @param options - The options object as written.
242
+ * @returns The same options with any shorthand `type` resolved to storage type + cast.
243
+ */
244
+ function _resolveOptionType(options: ColumnOptionsInput): ColumnOptions {
245
+ const declared = options.type;
246
+ if (declared === undefined) return options as ColumnOptions;
247
+
248
+ const shorthand = SHORTHAND_MAP[declared as ColumnShorthand];
249
+ // A storage type maps to itself with no cast — `string`, `text`, `number`,
250
+ // `boolean`, `datetime` and `json` appear in both vocabularies and mean the same
251
+ // thing in each, so there is nothing to resolve.
252
+ if (!shorthand || shorthand.type === declared) return options as ColumnOptions;
253
+
254
+ return { ...shorthand, ...options, type: shorthand.type };
201
255
  }
202
256
 
203
257
  // ── Reactivity ────────────────────────────────────────────────────────────────
@@ -308,7 +362,7 @@ export function column(
308
362
  options: Omit<ColumnOptions, "type">,
309
363
  ): ColumnDecorator;
310
364
  export function column(
311
- arg?: ColumnShorthand | ColumnOptions,
365
+ arg?: ColumnShorthand | ColumnOptionsInput,
312
366
  extra?: Omit<ColumnOptions, "type">,
313
367
  ): ColumnDecorator {
314
368
  const options = resolveOptions(arg, extra);
@@ -5,6 +5,7 @@ import type { ConfigManager } from "@zerotal/core/config";
5
5
  import { SQL } from "bun";
6
6
  import { DB, _getConnection } from "../db/DB.ts";
7
7
  import { preventNPlusOne } from "../db/NPlusOneDetector.ts";
8
+ import { foreignKeysCheck } from "../doctor/foreignKeys.ts";
8
9
  import {
9
10
  _setBaseModelConnection,
10
11
  _setBaseModelDialect,
@@ -118,6 +119,10 @@ export class DatabaseProvider extends ServiceProvider {
118
119
  pool?.max !== undefined || pool?.idleTimeout !== undefined ? { url, ...pool } : url;
119
120
  const primary = new SQL(sqlArg as unknown as string);
120
121
 
122
+ const driver = config.get<string>("database.driver", "sqlite");
123
+ const enforceForeignKeys = config.get<boolean>("database.sqlite.foreignKeys", true);
124
+ await _applySqlitePragmas(primary, driver, enforceForeignKeys);
125
+
121
126
  if (replicaUrls.length === 0) return primary;
122
127
 
123
128
  // Build read replicas and wrap in a transparent read/write router.
@@ -131,6 +136,9 @@ export class DatabaseProvider extends ServiceProvider {
131
136
  : replicaUrl;
132
137
  return new SQL(replicaSqlArg as unknown as string);
133
138
  });
139
+ for (const replica of replicas) {
140
+ await _applySqlitePragmas(replica, driver, enforceForeignKeys);
141
+ }
134
142
 
135
143
  return createReadWriteRouter(primary, replicas);
136
144
  });
@@ -214,7 +222,7 @@ export class DatabaseProvider extends ServiceProvider {
214
222
  * it — `doctor` already boots the app and already holds the connection.
215
223
  */
216
224
  override doctorChecks(): DoctorCheck[] {
217
- return [pendingMigrationsCheck];
225
+ return [pendingMigrationsCheck, foreignKeysCheck];
218
226
  }
219
227
 
220
228
  override async onBooted(): Promise<void> {
@@ -252,6 +260,9 @@ export class DatabaseProvider extends ServiceProvider {
252
260
  runner.registerLazy("migrate:refresh", () =>
253
261
  import("../commands/MigrateRefreshCommand.ts").then((m) => m.MigrateRefreshCommand),
254
262
  );
263
+ runner.registerLazy("db:check-foreign-keys", () =>
264
+ import("../commands/CheckForeignKeysCommand.ts").then((m) => m.CheckForeignKeysCommand),
265
+ );
255
266
  runner.registerLazy("migrate:status", () =>
256
267
  import("../commands/MigrateStatusCommand.ts").then((m) => m.MigrateStatusCommand),
257
268
  );
@@ -323,3 +334,29 @@ function _detectDialect(raw: string): "sqlite" | "postgres" | "mysql" {
323
334
  if (raw.startsWith("mysql://") || raw.startsWith("mysql2://")) return "mysql";
324
335
  return "sqlite";
325
336
  }
337
+
338
+ /**
339
+ * Apply the connection-scoped PRAGMAs SQLite needs, if this is SQLite.
340
+ *
341
+ * `foreign_keys` is **per connection**, not per database, so it has to be set on
342
+ * every handle the app opens — including each read replica. Setting it once on the
343
+ * primary and assuming it stuck is how this ends up half-enforced.
344
+ *
345
+ * @param sql - The connection to configure.
346
+ * @param driver - The configured driver; anything but `sqlite` is left alone.
347
+ * @param foreignKeys - Whether to enforce foreign keys on this connection.
348
+ * @internal
349
+ */
350
+ export async function _applySqlitePragmas(
351
+ sql: SQLInstance,
352
+ driver: string,
353
+ foreignKeys: boolean,
354
+ ): Promise<void> {
355
+ if (driver !== "sqlite" || !foreignKeys) return;
356
+ try {
357
+ await sql`PRAGMA foreign_keys = ON`;
358
+ } catch {
359
+ // A driver that will not take the pragma is not a reason to refuse the
360
+ // connection; the doctor check reports the consequence.
361
+ }
362
+ }
@@ -27,6 +27,36 @@
27
27
  * ```
28
28
  */
29
29
  export abstract class Migration {
30
+ /**
31
+ * The name this migration is recorded under, instead of its filename.
32
+ *
33
+ * **Identity is a filename by default, and a filename is a thing people rename.**
34
+ * The `migrations` table stores the name a migration ran as, and the next run
35
+ * compares files against it — so renaming a file makes an applied migration look
36
+ * pending, the runner tries it again, and it fails on `table already exists`. That
37
+ * is a failed boot rather than a graceful skip.
38
+ *
39
+ * It is not a hypothetical. An app renumbered `001_` to `0001_` to match this
40
+ * framework's own scaffold convention — exactly what a careful person does — and
41
+ * would have made all nine of its production migrations look unrun. They caught it
42
+ * before deploying and renamed the files back.
43
+ *
44
+ * Declaring `id` decouples the two. Set it to whatever the file is *already*
45
+ * recorded as, and the file is then free to be named anything:
46
+ *
47
+ * ```ts
48
+ * export default class AddTenantLimits extends Migration {
49
+ * static override id = "010_add_tenant_limits"; // what the table already holds
50
+ * async up() { … }
51
+ * }
52
+ * ```
53
+ *
54
+ * Deliberately not a content hash: a migration's content is edited far more often
55
+ * than its name, and a hash would make every edit look like a new migration — the
56
+ * same failure, more often.
57
+ */
58
+ static id?: string;
59
+
30
60
  /** Apply the migration's forward schema change. */
31
61
  abstract up(): Promise<void>;
32
62
  /** Reverse everything {@link Migration.up} did, for rollback. */
@@ -150,6 +150,8 @@ export class MigrationRunner {
150
150
  const batch = (await this._lastBatch()) + 1;
151
151
 
152
152
  const pendingEntries = entries.filter((e) => !ran.has(e.name));
153
+ _refuseLikelyRenames(pendingEntries, ran);
154
+
153
155
  const executed: string[] = [];
154
156
 
155
157
  for (const entry of pendingEntries) {
@@ -425,11 +427,75 @@ export class MigrationRunner {
425
427
  // Dynamic import — each migration file must have a default export
426
428
  const mod = (await import(fullPath)) as { default: new () => Migration };
427
429
  const Ctor = mod.default;
430
+ // A declared `static id` wins over the filename — see `Migration.id`. Without
431
+ // it, renaming a file makes an applied migration look pending.
432
+ const declared = (Ctor as { id?: string }).id;
428
433
  entries.push({
429
- name: file.replace(/\.(ts|js)$/, ""),
434
+ name: declared ?? file.replace(/\.(ts|js)$/, ""),
430
435
  migration: new Ctor(),
431
436
  });
432
437
  }
433
438
  return entries;
434
439
  }
435
440
  }
441
+
442
+ /**
443
+ * Refuse to run a "pending" migration that is almost certainly a renamed applied one.
444
+ *
445
+ * A migration is recorded under a name, and the next run compares files against those
446
+ * names — so renaming a file makes an applied migration look pending. The runner then
447
+ * tries it again and it fails on `table already exists`, which is a failed boot rather
448
+ * than a graceful skip, and the error names a table rather than the rename that caused
449
+ * it.
450
+ *
451
+ * An app renumbered `001_` to `0001_` to match this framework's own scaffold
452
+ * convention and would have made all nine of its production migrations look unrun.
453
+ * They caught it by reading, not by being told.
454
+ *
455
+ * The signal is precise enough to act on: a pending migration whose name matches a
456
+ * recorded one once the leading digits are stripped is a renumbering, not a new
457
+ * migration. Nobody writes `0001_create_users` alongside an applied
458
+ * `001_create_users` and means two different things.
459
+ *
460
+ * This refuses rather than warns because the alternative is running it — and running
461
+ * it is the outage. {@link Migration.id} is the way to make the rename permanent.
462
+ *
463
+ * @param pending - Migrations about to run.
464
+ * @param ran - Names already recorded.
465
+ * @throws {@link MigrationError} naming both spellings and the fix.
466
+ * @internal
467
+ */
468
+ export function _refuseLikelyRenames(pending: MigrationEntry[], ran: Set<string>): void {
469
+ /** The name with any leading ordinal removed: `0001_create_users` → `create_users`. */
470
+ const withoutOrdinal = (name: string): string => name.replace(/^[0-9]+[_-]?/, "");
471
+
472
+ const appliedBySuffix = new Map<string, string>();
473
+ for (const name of ran) appliedBySuffix.set(withoutOrdinal(name), name);
474
+
475
+ for (const entry of pending) {
476
+ const suffix = withoutOrdinal(entry.name);
477
+ // A bare ordinal strips to nothing; two of those are not evidence of anything.
478
+ if (suffix === "") continue;
479
+ const applied = appliedBySuffix.get(suffix);
480
+ if (applied === undefined || applied === entry.name) continue;
481
+
482
+ throw new MigrationError(
483
+ entry.name,
484
+ new Error(
485
+ `"${entry.name}" looks like "${applied}", which has already run — the same ` +
486
+ `migration renumbered rather than a new one. Running it would apply a schema ` +
487
+ `change that is already applied, and fail on the first table it creates.
488
+
489
+ ` +
490
+ ` If it IS the same migration: rename the file back to "${applied}", or keep ` +
491
+ `the new filename and pin the identity to what the database already holds:
492
+ ` +
493
+ ` static override id = "${applied}";
494
+
495
+ ` +
496
+ ` If it is genuinely a new migration, give it a name that does not collide ` +
497
+ `once the leading digits are removed.`,
498
+ ),
499
+ );
500
+ }
501
+ }