@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 +88 -0
- package/api-surface.md +2 -1
- package/package.json +3 -3
- package/src/commands/CheckForeignKeysCommand.ts +80 -0
- package/src/commands/_loadMigrations.ts +33 -2
- package/src/config.ts +29 -1
- package/src/doctor/foreignKeys.ts +130 -0
- package/src/model/decorators/column.ts +58 -4
- package/src/provider/DatabaseProvider.ts +38 -1
- package/src/schema/Migration.ts +30 -0
- package/src/schema/MigrationRunner.ts +67 -1
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.
|
|
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.
|
|
35
|
-
"@zerotal/validator": "1.
|
|
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
|
-
|
|
30
|
-
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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
|
+
}
|
package/src/schema/Migration.ts
CHANGED
|
@@ -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
|
+
}
|