@zerotal/orm 1.9.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 +36 -35
- package/package.json +3 -3
- package/src/commands/CheckForeignKeysCommand.ts +80 -0
- package/src/commands/_backup.ts +3 -3
- package/src/commands/_loadMigrations.ts +33 -2
- package/src/config.ts +39 -9
- package/src/db/NPlusOneDetector.ts +2 -2
- package/src/db/types.ts +5 -5
- package/src/doctor/foreignKeys.ts +130 -0
- package/src/model/decorators/belongsTo.ts +2 -2
- package/src/model/decorators/column.ts +66 -11
- package/src/model/decorators/hasMany.ts +1 -1
- package/src/model/decorators/hasManyThrough.ts +2 -2
- package/src/model/decorators/hasOne.ts +1 -1
- package/src/model/decorators/manyToMany.ts +4 -4
- package/src/model/decorators/morphMany.ts +1 -1
- package/src/model/decorators/morphOne.ts +1 -1
- package/src/model/decorators/morphTo.ts +2 -2
- package/src/model/decorators/morphToMany.ts +3 -3
- package/src/model/decorators/morphedByMany.ts +3 -3
- package/src/model/decorators/table.ts +2 -2
- package/src/provider/DatabaseProvider.ts +38 -1
- package/src/schema/Migration.ts +30 -0
- package/src/schema/MigrationRunner.ts +67 -1
- package/src/schema/autoMigrate.ts +1 -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
|
}
|
|
@@ -806,14 +807,14 @@ interface CastField = {
|
|
|
806
807
|
}
|
|
807
808
|
|
|
808
809
|
interface ColumnOptions = {
|
|
809
|
-
cast?: 'boolean' | 'date' | 'datetime' | 'array' | 'integer' | 'json' | 'float' | 'enum' | 'immutable_datetime' | 'encrypted' | 'encrypted:json' | `decimal:${number}` | { get?: (dbValue: unknown) => unknown; set?: (jsValue: unknown) => unknown;} | CastContract<unknown>
|
|
810
|
+
cast?: 'boolean' | 'date' | 'datetime' | 'array' | 'integer' | 'json' | 'float' | 'enum' | 'immutable_datetime' | 'encrypted' | 'encrypted:json' | `decimal:${number}` | { get?: (dbValue: unknown) => unknown; set?: (jsValue: unknown) => unknown;} | CastContract<unknown> | undefined
|
|
810
811
|
default?: unknown
|
|
811
|
-
enumValues?: Record<string, string | number>
|
|
812
|
-
index?: boolean
|
|
813
|
-
nullable?: boolean
|
|
814
|
-
primary?: boolean
|
|
815
|
-
type?: 'string' | 'number' | 'boolean' | 'text' | 'datetime' | 'json'
|
|
816
|
-
unique?: boolean
|
|
812
|
+
enumValues?: Record<string, string | number> | undefined
|
|
813
|
+
index?: boolean | undefined
|
|
814
|
+
nullable?: boolean | undefined
|
|
815
|
+
primary?: boolean | undefined
|
|
816
|
+
type?: 'string' | 'number' | 'boolean' | 'text' | 'datetime' | 'json' | undefined
|
|
817
|
+
unique?: boolean | undefined
|
|
817
818
|
}
|
|
818
819
|
|
|
819
820
|
interface Compose = {
|
|
@@ -836,10 +837,10 @@ interface CursorPaginateResult = {
|
|
|
836
837
|
|
|
837
838
|
interface DatabaseConfigShape = {
|
|
838
839
|
driver: 'sqlite' | 'postgres' | 'mysql'
|
|
839
|
-
pool?: { max?: number; idleTimeout?: number;}
|
|
840
|
-
replicas?: string[]
|
|
841
|
-
sqlite: { path: string;}
|
|
842
|
-
synchronize?: boolean | { enabled: boolean; disruptive?: boolean;}
|
|
840
|
+
pool?: { max?: number; idleTimeout?: number;} | undefined
|
|
841
|
+
replicas?: string[] | undefined
|
|
842
|
+
sqlite: { path: string; foreignKeys?: boolean | undefined;}
|
|
843
|
+
synchronize?: boolean | { enabled: boolean; disruptive?: boolean;} | undefined
|
|
843
844
|
url: string
|
|
844
845
|
}
|
|
845
846
|
|
|
@@ -850,9 +851,9 @@ interface HasMany = {
|
|
|
850
851
|
|
|
851
852
|
interface HasManyThroughOptions = {
|
|
852
853
|
firstKey: string
|
|
853
|
-
localKey?: string
|
|
854
|
+
localKey?: string | undefined
|
|
854
855
|
secondKey: string
|
|
855
|
-
throughLocalKey?: string
|
|
856
|
+
throughLocalKey?: string | undefined
|
|
856
857
|
}
|
|
857
858
|
|
|
858
859
|
interface HasOne = {
|
|
@@ -861,10 +862,10 @@ interface HasOne = {
|
|
|
861
862
|
}
|
|
862
863
|
|
|
863
864
|
interface KeysetOptions = {
|
|
864
|
-
column?: string
|
|
865
|
-
cursor?: string | null
|
|
866
|
-
direction?: 'asc' | 'desc'
|
|
867
|
-
limit?: number
|
|
865
|
+
column?: string | undefined
|
|
866
|
+
cursor?: string | null | undefined
|
|
867
|
+
direction?: 'asc' | 'desc' | undefined
|
|
868
|
+
limit?: number | undefined
|
|
868
869
|
}
|
|
869
870
|
|
|
870
871
|
interface KeysetPaginateResult = {
|
|
@@ -890,13 +891,13 @@ interface ManyToMany = {
|
|
|
890
891
|
}
|
|
891
892
|
|
|
892
893
|
interface ManyToManyOptions = {
|
|
893
|
-
localKey?: string
|
|
894
|
+
localKey?: string | undefined
|
|
894
895
|
pivotForeignKey: string
|
|
895
896
|
pivotRelatedKey: string
|
|
896
897
|
pivotTable: string
|
|
897
|
-
relatedKey?: string
|
|
898
|
-
withPivot?: string[]
|
|
899
|
-
withTimestamps?: boolean
|
|
898
|
+
relatedKey?: string | undefined
|
|
899
|
+
withPivot?: string[] | undefined
|
|
900
|
+
withTimestamps?: boolean | undefined
|
|
900
901
|
}
|
|
901
902
|
|
|
902
903
|
interface MigrationEntry = {
|
|
@@ -931,9 +932,9 @@ interface ModelObserver = {
|
|
|
931
932
|
interface MorphedByManyOptions = {
|
|
932
933
|
morphName: string
|
|
933
934
|
parentPivotKey: string
|
|
934
|
-
pivotTable?: string
|
|
935
|
-
withPivot?: string[]
|
|
936
|
-
withTimestamps?: boolean
|
|
935
|
+
pivotTable?: string | undefined
|
|
936
|
+
withPivot?: string[] | undefined
|
|
937
|
+
withTimestamps?: boolean | undefined
|
|
937
938
|
}
|
|
938
939
|
|
|
939
940
|
interface MorphMany = {
|
|
@@ -942,7 +943,7 @@ interface MorphMany = {
|
|
|
942
943
|
}
|
|
943
944
|
|
|
944
945
|
interface MorphManyOptions = {
|
|
945
|
-
localKey?: string
|
|
946
|
+
localKey?: string | undefined
|
|
946
947
|
morphName: string
|
|
947
948
|
}
|
|
948
949
|
|
|
@@ -952,7 +953,7 @@ interface MorphOne = {
|
|
|
952
953
|
}
|
|
953
954
|
|
|
954
955
|
interface MorphOneOptions = {
|
|
955
|
-
localKey?: string
|
|
956
|
+
localKey?: string | undefined
|
|
956
957
|
morphName: string
|
|
957
958
|
}
|
|
958
959
|
|
|
@@ -963,21 +964,21 @@ interface MorphTo = {
|
|
|
963
964
|
|
|
964
965
|
interface MorphToManyOptions = {
|
|
965
966
|
morphName: string
|
|
966
|
-
pivotTable?: string
|
|
967
|
+
pivotTable?: string | undefined
|
|
967
968
|
relatedPivotKey: string
|
|
968
|
-
withPivot?: string[]
|
|
969
|
-
withTimestamps?: boolean
|
|
969
|
+
withPivot?: string[] | undefined
|
|
970
|
+
withTimestamps?: boolean | undefined
|
|
970
971
|
}
|
|
971
972
|
|
|
972
973
|
interface MorphToOptions = {
|
|
973
|
-
morphForeignKey?: string
|
|
974
|
+
morphForeignKey?: string | undefined
|
|
974
975
|
morphMap: Record<string, () => unknown>
|
|
975
|
-
morphTypeColumn?: string
|
|
976
|
+
morphTypeColumn?: string | undefined
|
|
976
977
|
}
|
|
977
978
|
|
|
978
979
|
interface NPlusOneOptions = {
|
|
979
|
-
mode?: 'warn' | 'throw'
|
|
980
|
-
threshold?: number
|
|
980
|
+
mode?: 'warn' | 'throw' | undefined
|
|
981
|
+
threshold?: number | undefined
|
|
981
982
|
}
|
|
982
983
|
|
|
983
984
|
interface PaginateMeta = {
|
|
@@ -1067,8 +1068,8 @@ interface TableDecoratorBuilder = {
|
|
|
1067
1068
|
}
|
|
1068
1069
|
|
|
1069
1070
|
interface TableOptions = {
|
|
1070
|
-
primaryKey?: string
|
|
1071
|
-
timestamps?: boolean
|
|
1071
|
+
primaryKey?: string | undefined
|
|
1072
|
+
timestamps?: boolean | undefined
|
|
1072
1073
|
}
|
|
1073
1074
|
|
|
1074
1075
|
interface TransitionContext = {
|
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
|
+
}
|
package/src/commands/_backup.ts
CHANGED
|
@@ -45,15 +45,15 @@ export interface BackupOptions {
|
|
|
45
45
|
* it". An empty `bookings` table in a snapshot of a live system is not a small
|
|
46
46
|
* discrepancy, it is the whole failure — and it is invisible in a byte count.
|
|
47
47
|
*/
|
|
48
|
-
requireRows?: string[];
|
|
48
|
+
requireRows?: string[] | undefined;
|
|
49
49
|
/**
|
|
50
50
|
* Perform the restore, not just a read of the snapshot: copy it to a scratch
|
|
51
51
|
* path, open the copy, and check it there. Exercises the operation an incident
|
|
52
52
|
* actually needs, rather than the one that is convenient to test.
|
|
53
53
|
*/
|
|
54
|
-
rehearse?: boolean;
|
|
54
|
+
rehearse?: boolean | undefined;
|
|
55
55
|
/** Clock, so the snapshot name is deterministic under test. */
|
|
56
|
-
now?: Date;
|
|
56
|
+
now?: Date | undefined;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** What a completed backup wrote and proved. */
|
|
@@ -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
|
@@ -28,21 +28,51 @@ export interface DatabaseConfigShape {
|
|
|
28
28
|
* env('REPLICA_2_URL'),
|
|
29
29
|
* ].filter((url) => url !== undefined)
|
|
30
30
|
*/
|
|
31
|
-
replicas?: string[];
|
|
31
|
+
replicas?: string[] | undefined;
|
|
32
32
|
/**
|
|
33
33
|
* Connection pool options (PostgreSQL and MySQL only).
|
|
34
34
|
* Bun.sql manages the pool automatically - these tune its behaviour.
|
|
35
35
|
*/
|
|
36
|
-
pool?:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
pool?:
|
|
37
|
+
| {
|
|
38
|
+
/** Maximum number of connections in the pool. Default: 10 */
|
|
39
|
+
max?: number;
|
|
40
|
+
/** Seconds an idle connection is kept before being closed. Default: 30 */
|
|
41
|
+
idleTimeout?: number;
|
|
42
|
+
}
|
|
43
|
+
| undefined;
|
|
42
44
|
/** SQLite-specific options */
|
|
43
45
|
sqlite: {
|
|
44
46
|
/** Path to the SQLite file. Use ':memory:' for in-memory database. */
|
|
45
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;
|
|
46
76
|
};
|
|
47
77
|
/**
|
|
48
78
|
* Auto-sync the schema to your models at boot (TypeORM-style). Opt-in, and
|
|
@@ -58,13 +88,13 @@ export interface DatabaseConfigShape {
|
|
|
58
88
|
* synchronize: { enabled: true, disruptive: false } // explicit, additive
|
|
59
89
|
* synchronize: { enabled: true, disruptive: true } // also drops removed columns
|
|
60
90
|
*/
|
|
61
|
-
synchronize?: boolean | { enabled: boolean; disruptive?: boolean };
|
|
91
|
+
synchronize?: boolean | { enabled: boolean; disruptive?: boolean } | undefined;
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
const defaults: DatabaseConfigShape = {
|
|
65
95
|
driver: "sqlite",
|
|
66
96
|
url: "./database/db.sqlite",
|
|
67
|
-
sqlite: { path: "./database/db.sqlite" },
|
|
97
|
+
sqlite: { path: "./database/db.sqlite", foreignKeys: true },
|
|
68
98
|
};
|
|
69
99
|
|
|
70
100
|
/**
|
|
@@ -104,12 +104,12 @@ let _forced = false; // true when explicitly configured via preventNPlusOne()
|
|
|
104
104
|
|
|
105
105
|
export interface NPlusOneOptions {
|
|
106
106
|
/** How many identical query shapes in one request triggers a violation. Default: 5. */
|
|
107
|
-
threshold?: number;
|
|
107
|
+
threshold?: number | undefined;
|
|
108
108
|
/**
|
|
109
109
|
* 'warn' → console.warn (default — non-breaking, still visible)
|
|
110
110
|
* 'throw' → throws NPlusOneError (good for CI / strict mode)
|
|
111
111
|
*/
|
|
112
|
-
mode?: "warn" | "throw";
|
|
112
|
+
mode?: "warn" | "throw" | undefined;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
package/src/db/types.ts
CHANGED
|
@@ -159,7 +159,7 @@ function _pageUrl(baseUrl: string, page: number, extra: Record<string, string> =
|
|
|
159
159
|
/** Options accepted by the pagination helper factories. */
|
|
160
160
|
export interface PaginationHelperOptions {
|
|
161
161
|
/** Base path used by no-argument URL helpers and `meta.path`. Default: `''`. */
|
|
162
|
-
path?: string;
|
|
162
|
+
path?: string | undefined;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/** Attach URL helper methods + metadata to a plain paginate result object. */
|
|
@@ -276,13 +276,13 @@ export interface KeysetOptions {
|
|
|
276
276
|
* Opaque cursor string from the previous page's `nextCursor`.
|
|
277
277
|
* `null` or omitted = first page.
|
|
278
278
|
*/
|
|
279
|
-
cursor?: string | null;
|
|
279
|
+
cursor?: string | null | undefined;
|
|
280
280
|
/** Column used for keyset ordering. Must be a safe SQL identifier. Default: `'id'`. */
|
|
281
|
-
column?: string;
|
|
281
|
+
column?: string | undefined;
|
|
282
282
|
/** Sort direction. Default: `'asc'`. */
|
|
283
|
-
direction?: "asc" | "desc";
|
|
283
|
+
direction?: "asc" | "desc" | undefined;
|
|
284
284
|
/** Items per page. Default: `15`. */
|
|
285
|
-
limit?: number;
|
|
285
|
+
limit?: number | undefined;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
/** Result of {@link QueryBuilder.keysetPaginate} — opaque-cursor keyset pagination. */
|
|
@@ -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
|
+
}
|
|
@@ -5,9 +5,9 @@ export interface BelongsToOptions {
|
|
|
5
5
|
/** FK column on this (child) model pointing to the related row (e.g. `user_id`). */
|
|
6
6
|
foreignKey: string;
|
|
7
7
|
/** Key on the related model the FK references. Defaults to `'id'`. */
|
|
8
|
-
localKey?: string;
|
|
8
|
+
localKey?: string | undefined;
|
|
9
9
|
/** Return a default (unsaved) related model instead of null when absent. */
|
|
10
|
-
withDefault?: boolean | Record<string, unknown> | ((model: unknown) => void);
|
|
10
|
+
withDefault?: boolean | Record<string, unknown> | ((model: unknown) => void) | undefined;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -82,11 +82,14 @@ export interface ColumnOptions {
|
|
|
82
82
|
*
|
|
83
83
|
* @default "string"
|
|
84
84
|
*/
|
|
85
|
-
|
|
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.
|
|
88
|
+
type?: "string" | "text" | "number" | "boolean" | "datetime" | "json" | undefined;
|
|
86
89
|
/** Mark this column as the table's primary key. */
|
|
87
|
-
primary?: boolean;
|
|
90
|
+
primary?: boolean | undefined;
|
|
88
91
|
/** Allow SQL `NULL` for this column. */
|
|
89
|
-
nullable?: boolean;
|
|
92
|
+
nullable?: boolean | undefined;
|
|
90
93
|
/** Default value applied when none is provided. */
|
|
91
94
|
default?: unknown;
|
|
92
95
|
/**
|
|
@@ -96,9 +99,9 @@ export interface ColumnOptions {
|
|
|
96
99
|
* usually a correctness property (a webhook idempotency key, an invoice number),
|
|
97
100
|
* and `migrate:generate` can only emit constraints it can see declared.
|
|
98
101
|
*/
|
|
99
|
-
unique?: boolean;
|
|
102
|
+
unique?: boolean | undefined;
|
|
100
103
|
/** Add a plain (non-unique) index on this column. */
|
|
101
|
-
index?: boolean;
|
|
104
|
+
index?: boolean | undefined;
|
|
102
105
|
/**
|
|
103
106
|
* Shorthand cast types automatically serialize/deserialize the column value.
|
|
104
107
|
* Can also be a custom object with `get`/`set` functions for full control.
|
|
@@ -134,14 +137,37 @@ export interface ColumnOptions {
|
|
|
134
137
|
get?: (dbValue: unknown) => unknown;
|
|
135
138
|
set?: (jsValue: unknown) => unknown;
|
|
136
139
|
}
|
|
137
|
-
| CastContract<unknown
|
|
140
|
+
| CastContract<unknown>
|
|
141
|
+
| undefined;
|
|
138
142
|
/** Enum object (e.g. the imported TS enum) used alongside cast: 'enum'. */
|
|
139
|
-
enumValues?: Record<string, string | number
|
|
143
|
+
enumValues?: Record<string, string | number> | undefined;
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
/**
|
|
143
147
|
* Map each ColumnShorthand to its resolved ColumnOptions.
|
|
144
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
|
+
|
|
145
171
|
const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
|
|
146
172
|
string: { type: "string" },
|
|
147
173
|
text: { type: "text" },
|
|
@@ -169,7 +195,7 @@ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
|
|
|
169
195
|
* `Omit<ColumnOptions, "type">`, so it cannot contradict the type it is modifying.
|
|
170
196
|
*/
|
|
171
197
|
function resolveOptions(
|
|
172
|
-
arg?: ColumnShorthand |
|
|
198
|
+
arg?: ColumnShorthand | ColumnOptionsInput,
|
|
173
199
|
extra?: Omit<ColumnOptions, "type">,
|
|
174
200
|
): ColumnOptions {
|
|
175
201
|
const base = _resolveBase(arg);
|
|
@@ -183,7 +209,7 @@ function resolveOptions(
|
|
|
183
209
|
return merged;
|
|
184
210
|
}
|
|
185
211
|
|
|
186
|
-
function _resolveBase(arg?: ColumnShorthand |
|
|
212
|
+
function _resolveBase(arg?: ColumnShorthand | ColumnOptionsInput): ColumnOptions {
|
|
187
213
|
if (arg === undefined) return { type: "string" };
|
|
188
214
|
if (typeof arg === "string") {
|
|
189
215
|
const a = arg as string;
|
|
@@ -196,7 +222,36 @@ function _resolveBase(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
|
|
|
196
222
|
} as ColumnOptions;
|
|
197
223
|
return SHORTHAND_MAP[a as ColumnShorthand] ?? { type: "string" };
|
|
198
224
|
}
|
|
199
|
-
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 };
|
|
200
255
|
}
|
|
201
256
|
|
|
202
257
|
// ── Reactivity ────────────────────────────────────────────────────────────────
|
|
@@ -307,7 +362,7 @@ export function column(
|
|
|
307
362
|
options: Omit<ColumnOptions, "type">,
|
|
308
363
|
): ColumnDecorator;
|
|
309
364
|
export function column(
|
|
310
|
-
arg?: ColumnShorthand |
|
|
365
|
+
arg?: ColumnShorthand | ColumnOptionsInput,
|
|
311
366
|
extra?: Omit<ColumnOptions, "type">,
|
|
312
367
|
): ColumnDecorator {
|
|
313
368
|
const options = resolveOptions(arg, extra);
|
|
@@ -5,7 +5,7 @@ export interface HasManyOptions {
|
|
|
5
5
|
/** FK column on the related table pointing back to the parent (e.g. `user_id`). */
|
|
6
6
|
foreignKey: string;
|
|
7
7
|
/** Parent local key the FK references. Defaults to `'id'`. */
|
|
8
|
-
localKey?: string;
|
|
8
|
+
localKey?: string | undefined;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -6,9 +6,9 @@ export interface HasManyThroughOptions {
|
|
|
6
6
|
/** FK on the related table referencing the through model (e.g. 'user_id' on posts). */
|
|
7
7
|
secondKey: string;
|
|
8
8
|
/** Parent local key the firstKey references. Default 'id'. */
|
|
9
|
-
localKey?: string;
|
|
9
|
+
localKey?: string | undefined;
|
|
10
10
|
/** Through local key the secondKey references. Default 'id'. */
|
|
11
|
-
throughLocalKey?: string;
|
|
11
|
+
throughLocalKey?: string | undefined;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -5,7 +5,7 @@ export interface HasOneOptions {
|
|
|
5
5
|
/** FK column on the related table pointing back to the parent (e.g. `user_id`). */
|
|
6
6
|
foreignKey: string;
|
|
7
7
|
/** Parent local key the FK references. Defaults to `'id'`. */
|
|
8
|
-
localKey?: string;
|
|
8
|
+
localKey?: string | undefined;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -9,13 +9,13 @@ export interface ManyToManyOptions {
|
|
|
9
9
|
/** Pivot column referencing the related model (e.g. `role_id`). */
|
|
10
10
|
pivotRelatedKey: string;
|
|
11
11
|
/** Parent local key the pivot FK references. Defaults to `'id'`. */
|
|
12
|
-
localKey?: string;
|
|
12
|
+
localKey?: string | undefined;
|
|
13
13
|
/** Related local key the pivot relatedKey references. Defaults to `'id'`. */
|
|
14
|
-
relatedKey?: string;
|
|
14
|
+
relatedKey?: string | undefined;
|
|
15
15
|
/** Extra pivot columns to hydrate onto each related model's `pivot` bag. */
|
|
16
|
-
withPivot?: string[];
|
|
16
|
+
withPivot?: string[] | undefined;
|
|
17
17
|
/** Maintain created_at / updated_at on the pivot table during attach/sync. */
|
|
18
|
-
withTimestamps?: boolean;
|
|
18
|
+
withTimestamps?: boolean | undefined;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -7,12 +7,12 @@ export interface MorphToOptions {
|
|
|
7
7
|
* Override the _type column name. Defaults to `<propertyName>_type`.
|
|
8
8
|
* e.g. property `commentable` → `commentable_type`.
|
|
9
9
|
*/
|
|
10
|
-
morphTypeColumn?: string;
|
|
10
|
+
morphTypeColumn?: string | undefined;
|
|
11
11
|
/**
|
|
12
12
|
* Override the _id column name. Defaults to `<propertyName>_id`.
|
|
13
13
|
* e.g. property `commentable` → `commentable_id`.
|
|
14
14
|
*/
|
|
15
|
-
morphForeignKey?: string;
|
|
15
|
+
morphForeignKey?: string | undefined;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function _toSnake(s: string): string {
|
|
@@ -6,11 +6,11 @@ export interface MorphToManyOptions {
|
|
|
6
6
|
/** FK on the pivot table referencing the related model (e.g. 'tag_id'). */
|
|
7
7
|
relatedPivotKey: string;
|
|
8
8
|
/** Pivot table name. Defaults to `{morphName}s`. */
|
|
9
|
-
pivotTable?: string;
|
|
9
|
+
pivotTable?: string | undefined;
|
|
10
10
|
/** Extra pivot columns to hydrate. */
|
|
11
|
-
withPivot?: string[];
|
|
11
|
+
withPivot?: string[] | undefined;
|
|
12
12
|
/** Maintain pivot timestamps. */
|
|
13
|
-
withTimestamps?: boolean;
|
|
13
|
+
withTimestamps?: boolean | undefined;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -6,9 +6,9 @@ export interface MorphedByManyOptions {
|
|
|
6
6
|
/** FK on the pivot referencing the parent model (e.g. 'tag_id'). */
|
|
7
7
|
parentPivotKey: string;
|
|
8
8
|
/** Pivot table name. Defaults to `{morphName}s`. */
|
|
9
|
-
pivotTable?: string;
|
|
10
|
-
withPivot?: string[];
|
|
11
|
-
withTimestamps?: boolean;
|
|
9
|
+
pivotTable?: string | undefined;
|
|
10
|
+
withPivot?: string[] | undefined;
|
|
11
|
+
withTimestamps?: boolean | undefined;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -11,9 +11,9 @@ import type { ClassRef } from "../../support/classRef.ts";
|
|
|
11
11
|
*/
|
|
12
12
|
export interface TableOptions {
|
|
13
13
|
/** Enable / disable automatic timestamp columns. @default true */
|
|
14
|
-
timestamps?: boolean;
|
|
14
|
+
timestamps?: boolean | undefined;
|
|
15
15
|
/** Override the primary key column name. @default "id" */
|
|
16
|
-
primaryKey?: string;
|
|
16
|
+
primaryKey?: string | undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -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
|
+
}
|