@zerotal/orm 1.8.1 → 1.9.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 +45 -0
- package/api-surface.md +0 -186
- package/package.json +3 -3
- package/src/commands/DbBackupCommand.ts +143 -0
- package/src/commands/_backup.ts +356 -0
- package/src/db/DB.ts +27 -2
- package/src/db/ReadWriteRouter.ts +2 -0
- package/src/db/dialects/MysqlDialect.ts +2 -0
- package/src/db/dialects/PostgresDialect.ts +2 -0
- package/src/db/dialects/SqliteDialect.ts +2 -0
- package/src/db/dialects/index.ts +2 -0
- package/src/db/dialects/types.ts +12 -2
- package/src/db/resolver.ts +4 -0
- package/src/diagnostics/pendingMigrationsCheck.ts +60 -0
- package/src/implicitBinding.ts +1 -0
- package/src/model/BaseModel.ts +6 -0
- package/src/model/ModelQueryBuilder.ts +1 -0
- package/src/model/OrmContext.ts +14 -2
- package/src/model/hooks/HookRegistry.ts +2 -0
- package/src/observability.ts +2 -0
- package/src/provider/DatabaseProvider.ts +17 -1
- package/src/schema/MigrationCodegen.ts +2 -0
- package/src/schema/ModelInspector.ts +4 -0
- package/src/schema/SchemaDiffer.ts +6 -0
- package/src/schema/SchemaInspector.ts +4 -0
- package/src/schema/autoMigrate.ts +14 -2
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taking a copy of the one file that cannot be rebuilt.
|
|
3
|
+
*
|
|
4
|
+
* SQLite is the framework's default database, which makes the database a single
|
|
5
|
+
* file on disk — and makes `cp` look like a backup. It is not one. A live SQLite
|
|
6
|
+
* database has pages in flight and a write-ahead log beside it; copying the file
|
|
7
|
+
* while the server is serving can capture a half-written page, and the result is a
|
|
8
|
+
* file that looks like a backup, sits in the retention directory for months, and
|
|
9
|
+
* turns out to be a corrupt database on the one morning anybody opens it.
|
|
10
|
+
*
|
|
11
|
+
* `VACUUM INTO` is the answer SQLite ships. It takes a read lock, walks the
|
|
12
|
+
* b-tree, and writes a complete, defragmented database while the server keeps
|
|
13
|
+
* serving. It needs no external binary either — which removes the `apt install
|
|
14
|
+
* sqlite3` step and the failure mode where a backup silently stops working because
|
|
15
|
+
* a base image dropped the CLI.
|
|
16
|
+
*
|
|
17
|
+
* The rest of this module exists because **a backup nobody has restored is a
|
|
18
|
+
* hope.** Every snapshot is opened and integrity-checked the moment it is written,
|
|
19
|
+
* and `rehearse` performs the actual restore — copy the file, open the copy, read
|
|
20
|
+
* it — because that is the operation you will be doing at 3am and it is the one
|
|
21
|
+
* worth knowing works.
|
|
22
|
+
*
|
|
23
|
+
* Every failure path throws. A backup timer that reports success while writing
|
|
24
|
+
* nothing is worse than no timer at all: it buys the confidence without the file.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
import { ZerotalError } from "@zerotal/core";
|
|
29
|
+
import { Database } from "bun:sqlite";
|
|
30
|
+
import { copyFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
31
|
+
import { basename, join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** How a backup was asked for. */
|
|
34
|
+
export interface BackupOptions {
|
|
35
|
+
/** Absolute or cwd-relative path of the live SQLite database. */
|
|
36
|
+
source: string;
|
|
37
|
+
/** Directory snapshots are written into. Created if absent. */
|
|
38
|
+
dir: string;
|
|
39
|
+
/** Snapshots to keep, newest first. `0` keeps every one. */
|
|
40
|
+
keep: number;
|
|
41
|
+
/**
|
|
42
|
+
* Tables that must contain at least one row in the snapshot.
|
|
43
|
+
*
|
|
44
|
+
* The check that turns "a file was written" into "the file has the business in
|
|
45
|
+
* it". An empty `bookings` table in a snapshot of a live system is not a small
|
|
46
|
+
* discrepancy, it is the whole failure — and it is invisible in a byte count.
|
|
47
|
+
*/
|
|
48
|
+
requireRows?: string[];
|
|
49
|
+
/**
|
|
50
|
+
* Perform the restore, not just a read of the snapshot: copy it to a scratch
|
|
51
|
+
* path, open the copy, and check it there. Exercises the operation an incident
|
|
52
|
+
* actually needs, rather than the one that is convenient to test.
|
|
53
|
+
*/
|
|
54
|
+
rehearse?: boolean;
|
|
55
|
+
/** Clock, so the snapshot name is deterministic under test. */
|
|
56
|
+
now?: Date;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What a completed backup wrote and proved. */
|
|
60
|
+
export interface BackupResult {
|
|
61
|
+
/** Full path of the snapshot. */
|
|
62
|
+
path: string;
|
|
63
|
+
/** Its size on disk. */
|
|
64
|
+
bytes: number;
|
|
65
|
+
/** User tables found in it. */
|
|
66
|
+
tables: string[];
|
|
67
|
+
/** Row counts for the tables named in `requireRows`. */
|
|
68
|
+
rows: Record<string, number>;
|
|
69
|
+
/** Snapshots deleted by retention. */
|
|
70
|
+
pruned: string[];
|
|
71
|
+
/** Whether a full restore round-trip ran. */
|
|
72
|
+
rehearsed: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Raised for every way a backup can fail to be a backup.
|
|
77
|
+
*
|
|
78
|
+
* `ZerotalError` rather than `Error`, like every other error the framework throws:
|
|
79
|
+
* it carries a stable `code` a wrapper can switch on, which matters here because
|
|
80
|
+
* the caller is usually a shell script reading an exit status and a log line.
|
|
81
|
+
*/
|
|
82
|
+
export class BackupError extends ZerotalError {
|
|
83
|
+
constructor(message: string) {
|
|
84
|
+
super(message, "E_BACKUP_FAILED", 500);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Snapshot names this module writes, and therefore the only ones it will delete. */
|
|
89
|
+
const SNAPSHOT_PATTERN = /^(.+)-(\d{8}-\d{6})\.sqlite$/;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A UTC timestamp that sorts lexically in the same order it sorts chronologically,
|
|
93
|
+
* so retention can order snapshots by filename without parsing dates.
|
|
94
|
+
*/
|
|
95
|
+
export function backupStamp(now: Date): string {
|
|
96
|
+
const pad = (n: number, width = 2): string => String(n).padStart(width, "0");
|
|
97
|
+
return (
|
|
98
|
+
`${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}` +
|
|
99
|
+
`-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The snapshot filename for a source database at a moment. */
|
|
104
|
+
export function snapshotName(source: string, now: Date): string {
|
|
105
|
+
const stem = basename(source).replace(/\.(sqlite3?|db)$/i, "") || "database";
|
|
106
|
+
return `${stem}-${backupStamp(now)}.sqlite`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A path as a SQLite string literal.
|
|
111
|
+
*
|
|
112
|
+
* `VACUUM INTO` takes an expression, and the driver layer between here and SQLite
|
|
113
|
+
* does not reliably bind a parameter into that position across versions — so the
|
|
114
|
+
* path is inlined, and inlining a path means escaping it. A directory with an
|
|
115
|
+
* apostrophe in it is unusual and is not a reason to write a broken statement.
|
|
116
|
+
*/
|
|
117
|
+
export function sqlLiteral(value: string): string {
|
|
118
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether a database URL points at SQLite rather than a database server. */
|
|
122
|
+
export function isSqliteUrl(raw: string): boolean {
|
|
123
|
+
return !/^(postgres|postgresql|mysql|mysql2):\/\//.test(raw);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The filesystem path inside a SQLite URL, with any scheme prefix removed. */
|
|
127
|
+
export function sqlitePathFromUrl(raw: string): string {
|
|
128
|
+
if (raw.startsWith("sqlite://")) return raw.slice("sqlite://".length);
|
|
129
|
+
if (raw.startsWith("sqlite:")) return raw.slice("sqlite:".length);
|
|
130
|
+
if (raw.startsWith("file:")) return raw.slice("file:".length);
|
|
131
|
+
return raw;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** User tables in an open database — `sqlite_%` internals excluded. */
|
|
135
|
+
function userTables(database: Database): string[] {
|
|
136
|
+
const rows = database
|
|
137
|
+
.query<{ name: string }, []>(
|
|
138
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`,
|
|
139
|
+
)
|
|
140
|
+
.all();
|
|
141
|
+
return rows.map((r) => r.name);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Open a snapshot and satisfy yourself that it is a database.
|
|
146
|
+
*
|
|
147
|
+
* `PRAGMA integrity_check` is the whole point: it walks every page and every
|
|
148
|
+
* index and reports the single row `ok` when the file is sound. Anything else is
|
|
149
|
+
* a corrupt snapshot, and finding that out now — while the source database is
|
|
150
|
+
* still there — is the difference between a bad night and a lost business.
|
|
151
|
+
*
|
|
152
|
+
* @throws {@link BackupError} When the file will not open, fails its integrity
|
|
153
|
+
* check, or is missing rows the caller said it must have.
|
|
154
|
+
*/
|
|
155
|
+
export function verifySnapshot(
|
|
156
|
+
path: string,
|
|
157
|
+
requireRows: string[] = [],
|
|
158
|
+
): { tables: string[]; rows: Record<string, number> } {
|
|
159
|
+
let database: Database;
|
|
160
|
+
try {
|
|
161
|
+
database = new Database(path, { readonly: true });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new BackupError(
|
|
164
|
+
`The snapshot at ${path} will not open as a database: ${(error as Error).message}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// The open above proves nothing — bun:sqlite defers the real work to the first
|
|
170
|
+
// statement, so a text file masquerading as a database opens cleanly and fails
|
|
171
|
+
// here instead. The driver's own error is the honest one; it is wrapped so
|
|
172
|
+
// every failure out of this module is a BackupError a caller can match on.
|
|
173
|
+
let integrity: string[];
|
|
174
|
+
try {
|
|
175
|
+
integrity = database
|
|
176
|
+
.query<{ integrity_check: string }, []>(`PRAGMA integrity_check`)
|
|
177
|
+
.all()
|
|
178
|
+
.map((r) => r.integrity_check);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw new BackupError(
|
|
181
|
+
`The snapshot at ${path} is not a readable database: ${(error as Error).message}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (integrity.length !== 1 || integrity[0] !== "ok") {
|
|
185
|
+
throw new BackupError(
|
|
186
|
+
`The snapshot at ${path} failed its integrity check: ${integrity.join("; ")}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const tables = userTables(database);
|
|
191
|
+
const rows: Record<string, number> = {};
|
|
192
|
+
for (const table of requireRows) {
|
|
193
|
+
if (!tables.includes(table)) {
|
|
194
|
+
throw new BackupError(
|
|
195
|
+
`The snapshot at ${path} has no \`${table}\` table, and --require-rows said it must.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// The table name came from an operator's own flag, not from a request, and
|
|
199
|
+
// it has just been checked against the snapshot's own schema.
|
|
200
|
+
const count =
|
|
201
|
+
database.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM "${table}"`).get()?.n ?? 0;
|
|
202
|
+
if (count === 0) {
|
|
203
|
+
throw new BackupError(
|
|
204
|
+
`The snapshot at ${path} has an empty \`${table}\` table. A backup of a live ` +
|
|
205
|
+
`system with nothing in it is the failure, not a small discrepancy.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
rows[table] = count;
|
|
209
|
+
}
|
|
210
|
+
return { tables, rows };
|
|
211
|
+
} finally {
|
|
212
|
+
database.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Do the restore. Copy the snapshot the way an incident would, open the copy, and
|
|
218
|
+
* check it there.
|
|
219
|
+
*
|
|
220
|
+
* Reading the snapshot in place proves the file is sound. This proves the thing
|
|
221
|
+
* you will actually be asked to do with it works — and it costs one file copy.
|
|
222
|
+
*
|
|
223
|
+
* @throws {@link BackupError} When the restored copy will not open or does not check out.
|
|
224
|
+
*/
|
|
225
|
+
export function rehearseRestore(path: string, requireRows: string[] = []): void {
|
|
226
|
+
const scratch = `${path}.rehearsal`;
|
|
227
|
+
try {
|
|
228
|
+
copyFileSync(path, scratch);
|
|
229
|
+
verifySnapshot(scratch, requireRows);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof BackupError) {
|
|
232
|
+
throw new BackupError(`Restore rehearsal failed. ${error.message}`);
|
|
233
|
+
}
|
|
234
|
+
throw new BackupError(`Restore rehearsal failed: ${(error as Error).message}`);
|
|
235
|
+
} finally {
|
|
236
|
+
rmSync(scratch, { force: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Delete snapshots beyond the newest `keep`.
|
|
242
|
+
*
|
|
243
|
+
* Only files matching the name this module writes are considered, so a retention
|
|
244
|
+
* setting can never reach anything a person put in the directory by hand. Names
|
|
245
|
+
* carry a sortable UTC stamp, which is why this can order them without opening one.
|
|
246
|
+
*
|
|
247
|
+
* @returns The filenames deleted.
|
|
248
|
+
*/
|
|
249
|
+
export function pruneSnapshots(dir: string, keep: number): string[] {
|
|
250
|
+
if (keep <= 0) return [];
|
|
251
|
+
let entries: string[];
|
|
252
|
+
try {
|
|
253
|
+
entries = readdirSync(dir);
|
|
254
|
+
} catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
const snapshots = entries.filter((name) => SNAPSHOT_PATTERN.test(name)).sort();
|
|
258
|
+
const doomed = snapshots.slice(0, Math.max(0, snapshots.length - keep));
|
|
259
|
+
for (const name of doomed) rmSync(join(dir, name), { force: true });
|
|
260
|
+
return doomed;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Take a verified snapshot of a live SQLite database.
|
|
265
|
+
*
|
|
266
|
+
* @param run - Runs one statement against the live connection. Injected rather
|
|
267
|
+
* than imported so this module stays testable without a booted application.
|
|
268
|
+
* @param options - Where the database is, where the snapshot goes, and what must
|
|
269
|
+
* be true of it before this returns.
|
|
270
|
+
* @throws {@link BackupError} When anything at all goes wrong. There is no path
|
|
271
|
+
* through this function that reports success without a checked file on disk.
|
|
272
|
+
*/
|
|
273
|
+
export async function takeBackup(
|
|
274
|
+
run: (sql: string) => Promise<unknown>,
|
|
275
|
+
options: BackupOptions,
|
|
276
|
+
): Promise<BackupResult> {
|
|
277
|
+
const { source, dir, keep } = options;
|
|
278
|
+
const requireRows = options.requireRows ?? [];
|
|
279
|
+
|
|
280
|
+
if (source === ":memory:" || source === "") {
|
|
281
|
+
throw new BackupError(
|
|
282
|
+
"This app's database is in memory, so there is nothing on disk to back up. " +
|
|
283
|
+
"Point `database.url` at a file first.",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (!isSqliteUrl(source)) {
|
|
287
|
+
throw new BackupError(
|
|
288
|
+
`db:backup takes SQLite snapshots, and this app is on ${source.split("://")[0]}. ` +
|
|
289
|
+
`Use that server's own tool — pg_dump or mysqldump — and keep its output somewhere ` +
|
|
290
|
+
`this command is not responsible for.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const path = sqlitePathFromUrl(source);
|
|
295
|
+
try {
|
|
296
|
+
statSync(path);
|
|
297
|
+
} catch {
|
|
298
|
+
throw new BackupError(`There is no database at ${path} to back up.`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
mkdirSync(dir, { recursive: true });
|
|
302
|
+
const target = join(dir, snapshotName(path, options.now ?? new Date()));
|
|
303
|
+
|
|
304
|
+
// Never write over a snapshot. Two runs inside the same second is the only way
|
|
305
|
+
// to get here, and silently replacing the first one loses a backup to a clock.
|
|
306
|
+
try {
|
|
307
|
+
statSync(target);
|
|
308
|
+
throw new BackupError(
|
|
309
|
+
`${target} already exists. A snapshot is never overwritten — wait a second and run again.`,
|
|
310
|
+
);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error instanceof BackupError) throw error;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
await run(`VACUUM INTO ${sqlLiteral(target)}`);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
throw new BackupError(
|
|
319
|
+
`VACUUM INTO failed, so no snapshot was written: ${(error as Error).message}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let bytes: number;
|
|
324
|
+
try {
|
|
325
|
+
bytes = statSync(target).size;
|
|
326
|
+
} catch {
|
|
327
|
+
throw new BackupError(
|
|
328
|
+
`VACUUM INTO reported success but wrote no file at ${target}. Nothing has been backed up.`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (bytes === 0) {
|
|
332
|
+
rmSync(target, { force: true });
|
|
333
|
+
throw new BackupError(`The snapshot at ${target} was empty, and has been removed.`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// A snapshot that fails its checks does not get to stay. Left on disk it is
|
|
337
|
+
// indistinguishable from a good one — and it is the *newest*, so the next run's
|
|
338
|
+
// retention would prune a verified older snapshot to make room for it. The
|
|
339
|
+
// directory must contain only files that passed.
|
|
340
|
+
let tables: string[];
|
|
341
|
+
let rows: Record<string, number>;
|
|
342
|
+
const rehearsed = options.rehearse === true;
|
|
343
|
+
try {
|
|
344
|
+
({ tables, rows } = verifySnapshot(target, requireRows));
|
|
345
|
+
if (rehearsed) rehearseRestore(target, requireRows);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
rmSync(target, { force: true });
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Retention runs last, and only after the new snapshot has checked out. Pruning
|
|
352
|
+
// first would mean a failed backup that also deleted the oldest good one.
|
|
353
|
+
const pruned = pruneSnapshots(dir, keep);
|
|
354
|
+
|
|
355
|
+
return { path: target, bytes, tables, rows, pruned, rehearsed };
|
|
356
|
+
}
|
package/src/db/DB.ts
CHANGED
|
@@ -22,7 +22,11 @@ import {
|
|
|
22
22
|
// Production code resolves the connection from the container instead.
|
|
23
23
|
let _connection: SQLInstance | undefined;
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Test helper: inject a primary connection without going through the container/provider.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
26
30
|
export function _setDbConnection(conn: SQLInstance | null): void {
|
|
27
31
|
_connection = conn ?? undefined;
|
|
28
32
|
}
|
|
@@ -34,6 +38,8 @@ export function _setDbConnection(conn: SQLInstance | null): void {
|
|
|
34
38
|
* a helper that installs its own connection needs to restore the *override
|
|
35
39
|
* slot* exactly as it found it, and cannot tell an absent override from a
|
|
36
40
|
* container-resolved connection otherwise.
|
|
41
|
+
*
|
|
42
|
+
* @internal
|
|
37
43
|
*/
|
|
38
44
|
export function _getDbConnectionOverride(): SQLInstance | null {
|
|
39
45
|
return _connection ?? null;
|
|
@@ -42,6 +48,8 @@ export function _getDbConnectionOverride(): SQLInstance | null {
|
|
|
42
48
|
/**
|
|
43
49
|
* Test helper: inject a primary + replicas as a read/write router.
|
|
44
50
|
* Resets to a plain primary when `replicas` is empty.
|
|
51
|
+
*
|
|
52
|
+
* @internal
|
|
45
53
|
*/
|
|
46
54
|
export function _setReadReplicas(primary: SQLInstance, replicas: SQLInstance[]): void {
|
|
47
55
|
_connection = replicas.length > 0 ? createReadWriteRouter(primary, replicas) : primary;
|
|
@@ -90,7 +98,11 @@ export function _getScopedDbConnection(): SQLInstance {
|
|
|
90
98
|
return conn;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Alias used by migration command helpers.
|
|
103
|
+
*
|
|
104
|
+
* @internal
|
|
105
|
+
*/
|
|
94
106
|
export function _getConnection(): SQLInstance {
|
|
95
107
|
return _getDbConnection();
|
|
96
108
|
}
|
|
@@ -132,6 +144,19 @@ function _runRaw(conn: SQLInstance, sql: string): Promise<unknown> {
|
|
|
132
144
|
return conn(arr);
|
|
133
145
|
}
|
|
134
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Run a raw, parameter-less statement on the ambient connection.
|
|
149
|
+
*
|
|
150
|
+
* The tagged-template path rather than `DB.raw(string)`, because that one splits
|
|
151
|
+
* its input on `?` to find bindings — fine for SQL somebody typed, wrong for a
|
|
152
|
+
* statement carrying a filesystem path that the caller has already made safe.
|
|
153
|
+
*
|
|
154
|
+
* @internal Used by `db:backup` for `VACUUM INTO`.
|
|
155
|
+
*/
|
|
156
|
+
export function _rawStatement(sql: string): Promise<unknown> {
|
|
157
|
+
return _runRaw(_getConnection(), sql);
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
/** Run a parameterised statement (`?` placeholders) on a specific connection. */
|
|
136
161
|
function _runParams(conn: SQLInstance, sql: string, params: unknown[]): Promise<unknown> {
|
|
137
162
|
const parts = sql.split("?");
|
|
@@ -24,6 +24,8 @@ import type { SQLInstance } from "./sql-types.ts";
|
|
|
24
24
|
*
|
|
25
25
|
* // Drop-in: pass router wherever you'd pass a SQL connection
|
|
26
26
|
* DB.table('users') // SELECT → replica, mutating → primary
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
27
29
|
*/
|
|
28
30
|
export function createReadWriteRouter(primary: SQLInstance, replicas: SQLInstance[]): SQLInstance {
|
|
29
31
|
if (replicas.length === 0) return primary;
|
|
@@ -3,6 +3,8 @@ import type { DatePart, DialectQuery, SqlDialect } from "./types.ts";
|
|
|
3
3
|
/**
|
|
4
4
|
* MySQL strategy — INFORMATION_SCHEMA introspection, DAY()/MONTH()/YEAR()
|
|
5
5
|
* date parts, GET_LOCK()/RELEASE_LOCK() named advisory locks.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
6
8
|
*/
|
|
7
9
|
export class MysqlDialect implements SqlDialect {
|
|
8
10
|
readonly name = "mysql" as const;
|
|
@@ -3,6 +3,8 @@ import type { DatePart, DialectQuery, SqlDialect } from "./types.ts";
|
|
|
3
3
|
/**
|
|
4
4
|
* PostgreSQL strategy — information_schema introspection, EXTRACT()/casts for
|
|
5
5
|
* date parts, pg_advisory_lock for advisory locks.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
6
8
|
*/
|
|
7
9
|
export class PostgresDialect implements SqlDialect {
|
|
8
10
|
readonly name = "postgres" as const;
|
|
@@ -3,6 +3,8 @@ import type { DatePart, DialectQuery, SqlDialect } from "./types.ts";
|
|
|
3
3
|
/**
|
|
4
4
|
* SQLite strategy — sqlite_master / pragma_table_info introspection,
|
|
5
5
|
* strftime() date parts, no advisory-lock primitive.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
6
8
|
*/
|
|
7
9
|
export class SqliteDialect implements SqlDialect {
|
|
8
10
|
readonly name = "sqlite" as const;
|
package/src/db/dialects/index.ts
CHANGED
package/src/db/dialects/types.ts
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
// consult the active dialect instead of hard-coding SQLite syntax behind the
|
|
8
8
|
// multi-dialect facade.
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Supported database engines. Mirrors `Dialect` in QueryBuilder.ts.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
11
15
|
export type DialectName = "sqlite" | "postgres" | "mysql";
|
|
12
16
|
|
|
13
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* A parameterised statement: `sql` uses `?` placeholders bound from `params`.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
14
22
|
export interface DialectQuery {
|
|
15
23
|
sql: string;
|
|
16
24
|
params: unknown[];
|
|
@@ -25,6 +33,8 @@ export type DatePart = "date" | "time" | "day" | "month" | "year";
|
|
|
25
33
|
* @example
|
|
26
34
|
* const d = getDialect("postgres");
|
|
27
35
|
* const { sql, params } = d.hasTableSql("users");
|
|
36
|
+
*
|
|
37
|
+
* @internal
|
|
28
38
|
*/
|
|
29
39
|
export interface SqlDialect {
|
|
30
40
|
readonly name: DialectName;
|
package/src/db/resolver.ts
CHANGED
|
@@ -11,6 +11,8 @@ let _resolver: ConnectionResolver | null = null;
|
|
|
11
11
|
/**
|
|
12
12
|
* Register the callback used to look up the base connection from the container.
|
|
13
13
|
* Pass `null` to clear it. Set once by the `DatabaseProvider` at boot.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
14
16
|
*/
|
|
15
17
|
export function setConnectionResolver(fn: ConnectionResolver | null): void {
|
|
16
18
|
_resolver = fn;
|
|
@@ -19,6 +21,8 @@ export function setConnectionResolver(fn: ConnectionResolver | null): void {
|
|
|
19
21
|
/**
|
|
20
22
|
* Resolve the container's base connection via the registered resolver, swallowing
|
|
21
23
|
* any resolver error. Returns `undefined` when no resolver is set or it fails.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
22
26
|
*/
|
|
23
27
|
export function resolveContainerConnection(): SQLInstance | undefined {
|
|
24
28
|
if (!_resolver) return undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zt doctor` — are there migrations on disk that have not run?
|
|
3
|
+
*
|
|
4
|
+
* The same question the dev error overlay answers, asked before anything breaks.
|
|
5
|
+
* The overlay is reactive by nature: it needs a request to have already failed
|
|
6
|
+
* with `no such table: assets`, which means somebody already lost the thread of
|
|
7
|
+
* what they were doing. `doctor` is where that finding belongs when nothing has
|
|
8
|
+
* gone wrong yet — it already boots the app, already holds a connection, and
|
|
9
|
+
* already exists to report the silent things.
|
|
10
|
+
*
|
|
11
|
+
* A warning, never a failure. Pending migrations are the *normal* state of a
|
|
12
|
+
* checkout that just pulled, and a doctor that fails there would be one people
|
|
13
|
+
* learn to ignore. The deploy pipeline is the place where they must be applied,
|
|
14
|
+
* and it runs `migrate` as a step rather than asking about it.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
import type { DoctorCheck, DoctorCheckResult } from "@zerotal/core";
|
|
19
|
+
import { pendingMigrations } from "./missingRelation.ts";
|
|
20
|
+
|
|
21
|
+
/** How many migration names to print before summarising the rest. */
|
|
22
|
+
const NAMED = 5;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The check.
|
|
26
|
+
*
|
|
27
|
+
* Silent when nothing is pending, silent when there is no database to ask, and
|
|
28
|
+
* specific when there is something to say — the names, because "3 pending" sends
|
|
29
|
+
* you to `migrate:status` to find out which, and this already knows.
|
|
30
|
+
*/
|
|
31
|
+
export const pendingMigrationsCheck: DoctorCheck = {
|
|
32
|
+
id: "pending-migrations",
|
|
33
|
+
label: "Migrations",
|
|
34
|
+
async run(): Promise<DoctorCheckResult> {
|
|
35
|
+
let pending: string[];
|
|
36
|
+
try {
|
|
37
|
+
pending = await pendingMigrations();
|
|
38
|
+
} catch {
|
|
39
|
+
// No connection, no migrations directory, or a driver that will not answer.
|
|
40
|
+
// None of those is a finding about migrations, and guessing would make this
|
|
41
|
+
// check the noisiest thing in the report on every app that has no database.
|
|
42
|
+
return { status: "ok", message: "no migration state to read." };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (pending.length === 0) {
|
|
46
|
+
return { status: "ok", message: "every migration on disk has run." };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const shown = pending.slice(0, NAMED).join(", ");
|
|
50
|
+
const rest = pending.length - NAMED;
|
|
51
|
+
return {
|
|
52
|
+
status: "warn",
|
|
53
|
+
message:
|
|
54
|
+
`${pending.length} migration(s) have not run: ${shown}${rest > 0 ? `, and ${rest} more` : ""}. ` +
|
|
55
|
+
`Until they do, any query touching what they create fails with \`no such table\` — ` +
|
|
56
|
+
`an error whose stack is entirely framework frames.`,
|
|
57
|
+
fix: "bun zt migrate",
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
package/src/implicitBinding.ts
CHANGED
package/src/model/BaseModel.ts
CHANGED
|
@@ -107,6 +107,8 @@ HookRegistry.onAfterRun = (ModelClass, hook, model): void => {
|
|
|
107
107
|
/**
|
|
108
108
|
* Register a named connection that models can select via `static connection`.
|
|
109
109
|
* Stored on the current OrmContext (execution-scoped), not a global.
|
|
110
|
+
*
|
|
111
|
+
* @internal
|
|
110
112
|
*/
|
|
111
113
|
export function registerModelConnection(name: string, conn: SQLInstance, dialect?: Dialect): void {
|
|
112
114
|
currentOrmContext().namedConnections.set(name, conn);
|
|
@@ -159,6 +161,8 @@ export function _getModelConnection(): SQLInstance {
|
|
|
159
161
|
* multi-database strategy: it returns the active tenant's connection so every model
|
|
160
162
|
* query transparently hits the right database. Returns `null` to defer to the normal
|
|
161
163
|
* resolution. Defaults to a no-op, so it changes nothing until something registers it.
|
|
164
|
+
*
|
|
165
|
+
* @internal
|
|
162
166
|
*/
|
|
163
167
|
export type ContextConnectionResolver = (ModelClass?: typeof BaseModel) => SQLInstance | null;
|
|
164
168
|
let _contextConnectionResolver: ContextConnectionResolver | null = null;
|
|
@@ -167,6 +171,8 @@ let _contextConnectionResolver: ContextConnectionResolver | null = null;
|
|
|
167
171
|
* Register a context-aware connection resolver (pass `null` to clear). Consulted by
|
|
168
172
|
* `_resolveConn` after explicit transactions and `static connection` named bindings,
|
|
169
173
|
* but before the default connection — so transactions and pinned connections still win.
|
|
174
|
+
*
|
|
175
|
+
* @internal
|
|
170
176
|
*/
|
|
171
177
|
export function registerConnectionResolver(fn: ContextConnectionResolver | null): void {
|
|
172
178
|
_contextConnectionResolver = fn;
|
|
@@ -275,6 +275,7 @@ function _createPivotCollection<T extends BaseModel>(
|
|
|
275
275
|
// BaseModel as a type only — no cycle.
|
|
276
276
|
|
|
277
277
|
export type GlobalScopeCallback = (qb: ModelQueryBuilder<BaseModel>) => void;
|
|
278
|
+
/** @internal */
|
|
278
279
|
export function _globalScopeRegistry(): Map<ClassRef, Map<string, GlobalScopeCallback>> {
|
|
279
280
|
return currentOrmContext().globalScopes as unknown as Map<
|
|
280
281
|
ClassRef,
|
package/src/model/OrmContext.ts
CHANGED
|
@@ -18,6 +18,8 @@ import type { SQLInstance } from "../db/sql-types.ts";
|
|
|
18
18
|
* } finally {
|
|
19
19
|
* useOrmContext(prev);
|
|
20
20
|
* }
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
21
23
|
*/
|
|
22
24
|
export class OrmContext {
|
|
23
25
|
/** Explicit connection override that wins over the resolved default (used by tests / `withDatabase`). */
|
|
@@ -34,7 +36,11 @@ export class OrmContext {
|
|
|
34
36
|
|
|
35
37
|
let _ctx = new OrmContext();
|
|
36
38
|
|
|
37
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Return the currently active {@link OrmContext}.
|
|
41
|
+
*
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
38
44
|
export function currentOrmContext(): OrmContext {
|
|
39
45
|
return _ctx;
|
|
40
46
|
}
|
|
@@ -45,6 +51,8 @@ export function currentOrmContext(): OrmContext {
|
|
|
45
51
|
*
|
|
46
52
|
* @param ctx The context to activate (defaults to a new, empty context).
|
|
47
53
|
* @returns The context that was active before this call.
|
|
54
|
+
*
|
|
55
|
+
* @internal
|
|
48
56
|
*/
|
|
49
57
|
export function useOrmContext(ctx: OrmContext = new OrmContext()): OrmContext {
|
|
50
58
|
const prev = _ctx;
|
|
@@ -52,7 +60,11 @@ export function useOrmContext(ctx: OrmContext = new OrmContext()): OrmContext {
|
|
|
52
60
|
return prev;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Replace the active context with a fresh, empty {@link OrmContext} (test/teardown reset).
|
|
65
|
+
*
|
|
66
|
+
* @internal
|
|
67
|
+
*/
|
|
56
68
|
export function resetOrmContext(): void {
|
|
57
69
|
_ctx = new OrmContext();
|
|
58
70
|
}
|
|
@@ -36,6 +36,8 @@ const _suppressCtx = new AsyncLocalStorage<true>();
|
|
|
36
36
|
/**
|
|
37
37
|
* Run `fn` with all model hooks and observers silenced.
|
|
38
38
|
* Used internally by Factory when `dispatchEvents()` has NOT been called.
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
39
41
|
*/
|
|
40
42
|
export function _suppressHooks<T>(fn: () => Promise<T>): Promise<T> {
|
|
41
43
|
return _suppressCtx.run(true, fn);
|