@zerotal/orm 1.8.1 → 1.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/api-surface.md +34 -220
  3. package/package.json +3 -3
  4. package/src/commands/DbBackupCommand.ts +143 -0
  5. package/src/commands/_backup.ts +356 -0
  6. package/src/config.ts +10 -8
  7. package/src/db/DB.ts +27 -2
  8. package/src/db/NPlusOneDetector.ts +2 -2
  9. package/src/db/ReadWriteRouter.ts +2 -0
  10. package/src/db/dialects/MysqlDialect.ts +2 -0
  11. package/src/db/dialects/PostgresDialect.ts +2 -0
  12. package/src/db/dialects/SqliteDialect.ts +2 -0
  13. package/src/db/dialects/index.ts +2 -0
  14. package/src/db/dialects/types.ts +12 -2
  15. package/src/db/resolver.ts +4 -0
  16. package/src/db/types.ts +5 -5
  17. package/src/diagnostics/pendingMigrationsCheck.ts +60 -0
  18. package/src/implicitBinding.ts +1 -0
  19. package/src/model/BaseModel.ts +6 -0
  20. package/src/model/ModelQueryBuilder.ts +1 -0
  21. package/src/model/OrmContext.ts +14 -2
  22. package/src/model/decorators/belongsTo.ts +2 -2
  23. package/src/model/decorators/column.ts +8 -7
  24. package/src/model/decorators/hasMany.ts +1 -1
  25. package/src/model/decorators/hasManyThrough.ts +2 -2
  26. package/src/model/decorators/hasOne.ts +1 -1
  27. package/src/model/decorators/manyToMany.ts +4 -4
  28. package/src/model/decorators/morphMany.ts +1 -1
  29. package/src/model/decorators/morphOne.ts +1 -1
  30. package/src/model/decorators/morphTo.ts +2 -2
  31. package/src/model/decorators/morphToMany.ts +3 -3
  32. package/src/model/decorators/morphedByMany.ts +3 -3
  33. package/src/model/decorators/table.ts +2 -2
  34. package/src/model/hooks/HookRegistry.ts +2 -0
  35. package/src/observability.ts +2 -0
  36. package/src/provider/DatabaseProvider.ts +17 -1
  37. package/src/schema/MigrationCodegen.ts +2 -0
  38. package/src/schema/ModelInspector.ts +4 -0
  39. package/src/schema/SchemaDiffer.ts +6 -0
  40. package/src/schema/SchemaInspector.ts +4 -0
  41. package/src/schema/autoMigrate.ts +15 -3
@@ -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[] | undefined;
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 | undefined;
55
+ /** Clock, so the snapshot name is deterministic under test. */
56
+ now?: Date | undefined;
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/config.ts CHANGED
@@ -28,17 +28,19 @@ 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
- /** Maximum number of connections in the pool. Default: 10 */
38
- max?: number;
39
- /** Seconds an idle connection is kept before being closed. Default: 30 */
40
- idleTimeout?: number;
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. */
@@ -58,7 +60,7 @@ export interface DatabaseConfigShape {
58
60
  * synchronize: { enabled: true, disruptive: false } // explicit, additive
59
61
  * synchronize: { enabled: true, disruptive: true } // also drops removed columns
60
62
  */
61
- synchronize?: boolean | { enabled: boolean; disruptive?: boolean };
63
+ synchronize?: boolean | { enabled: boolean; disruptive?: boolean } | undefined;
62
64
  }
63
65
 
64
66
  const defaults: DatabaseConfigShape = {
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
- /** Test helper: inject a primary connection without going through the container/provider. */
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
- /** Alias used by migration command helpers. */
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("?");
@@ -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
  /**
@@ -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;
@@ -14,6 +14,8 @@ const _dialects: Record<DialectName, SqlDialect> = {
14
14
  *
15
15
  * @example
16
16
  * getDialect(dialectFor(conn)).dateExpr('day', 'created_at')
17
+ *
18
+ * @internal
17
19
  */
18
20
  export function getDialect(name: DialectName): SqlDialect {
19
21
  return _dialects[name];
@@ -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
- /** Supported database engines. Mirrors `Dialect` in QueryBuilder.ts. */
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
- /** A parameterised statement: `sql` uses `?` placeholders bound from `params`. */
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;
@@ -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;
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,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
+ };
@@ -68,6 +68,7 @@ export function resolverForParam(paramName: string): ModelBindingResolver | unde
68
68
  : M.findOrFail(value);
69
69
  }
70
70
 
71
+ /** @internal */
71
72
  export function registerImplicitBinding(): void {
72
73
  setImplicitModelResolver(resolverForParam);
73
74
  }