@schemic/cli 0.1.0-alpha.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.
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import type { Driver } from "@schemic/core";
4
+
5
+ export interface InitResult {
6
+ created: string[];
7
+ skipped: string[];
8
+ }
9
+
10
+ /**
11
+ * The empty migration snapshot a fresh project starts from. Dialect-NEUTRAL (the KindSnapshot shape is
12
+ * core's), tagged with the driver so the snapshot reader knows who owns its `schema` payload.
13
+ */
14
+ function initialSnapshot(driver: string): string {
15
+ return `${JSON.stringify(
16
+ {
17
+ version: 3,
18
+ driver,
19
+ schema: { kinds: {} },
20
+ files: {},
21
+ },
22
+ null,
23
+ 2,
24
+ )}\n`;
25
+ }
26
+
27
+ /**
28
+ * Scaffold a fresh project for `driver`. The dialect files (config, sample schema, seed, env) come
29
+ * from the driver's {@link Driver.initScaffold}; the CLI adds the neutral migration snapshot. Never
30
+ * overwrites existing files. The CLI itself stays dialect-free — it only knows the file map.
31
+ */
32
+ export function init(cwd: string, driver: Driver<unknown>): InitResult {
33
+ const scaffold = driver.initScaffold?.();
34
+ if (!scaffold)
35
+ throw new Error(
36
+ `the "${driver.name}" driver does not support \`schemic init\` scaffolding.`,
37
+ );
38
+ const files: Record<string, string> = {
39
+ ...scaffold,
40
+ "database/migrations/meta/_snapshot.json": initialSnapshot(driver.name),
41
+ };
42
+
43
+ const created: string[] = [];
44
+ const skipped: string[] = [];
45
+ for (const [rel, content] of Object.entries(files)) {
46
+ const abs = resolve(cwd, rel);
47
+ if (existsSync(abs)) {
48
+ skipped.push(rel);
49
+ continue;
50
+ }
51
+ mkdirSync(dirname(abs), { recursive: true });
52
+ writeFileSync(abs, content);
53
+ created.push(rel);
54
+ }
55
+ return { created, skipped };
56
+ }
@@ -0,0 +1,494 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { join, relative, resolve } from "node:path";
9
+ import type { ResolvedConfig } from "@schemic/core";
10
+ import {
11
+ type Authored,
12
+ type AuthoredDef,
13
+ buildKindDiff,
14
+ checksum,
15
+ type Diff,
16
+ type Driver,
17
+ EMPTY_STORED,
18
+ type Filter,
19
+ filterKinds,
20
+ getDriver,
21
+ intersectKinds,
22
+ isEmptyDiff,
23
+ listMigrations,
24
+ loadDefs,
25
+ lowerSchema,
26
+ type Migration,
27
+ type MigrationStore,
28
+ makeJiti,
29
+ mergeStored,
30
+ parseFilter,
31
+ readSnapshot,
32
+ type StoredSnapshot,
33
+ slug,
34
+ snapshotKinds,
35
+ snapshotObjects,
36
+ timestamp,
37
+ writeSnapshot,
38
+ } from "@schemic/core";
39
+
40
+ /**
41
+ * Build the canonical STORED snapshot from the authored schema: explode authoring into kinded
42
+ * definables, lower via the registry, plus a name->source-file map (display-only; attached to diff
43
+ * items by `attachFiles`).
44
+ */
45
+ function buildStored(
46
+ driver: Driver,
47
+ tables: Authored[],
48
+ defs: AuthoredDef[],
49
+ opts: { fileOf?: Map<unknown, string>; root?: string } = {},
50
+ ): StoredSnapshot {
51
+ const objects = lowerSchema(driver.registry, driver.explode(tables, defs));
52
+ const files: Record<string, string> = {};
53
+ const rel = (abs: string) => (opts.root ? relative(opts.root, abs) : abs);
54
+ for (const t of tables) {
55
+ const abs = opts.fileOf?.get(t);
56
+ if (abs) files[t.name] = rel(abs);
57
+ }
58
+ for (const d of defs) {
59
+ const abs = opts.fileOf?.get(d);
60
+ // An event is file-linked under its owner table; other defs under their own name.
61
+ const key = d.kind === "event" ? d.table : d.name;
62
+ if (abs && key) files[key] = rel(abs);
63
+ }
64
+ return {
65
+ version: 3,
66
+ driver: driver.name,
67
+ schema: snapshotKinds(objects),
68
+ files,
69
+ };
70
+ }
71
+
72
+ /** The driver's apply-time migration bookkeeping (the dialect SQL behind `migrate`/`rollback`/…). */
73
+ function migStore(config: ResolvedConfig): MigrationStore<unknown> {
74
+ const driver = getDriver(config.driver ?? "surrealdb");
75
+ if (!driver.migrations)
76
+ throw new Error(
77
+ `The "${driver.name}" driver does not support running migrations.`,
78
+ );
79
+ return driver.migrations;
80
+ }
81
+
82
+ /** The driver's migration-file extension (e.g. `.surql` / `.sql`); falls back to `.surql`. */
83
+ const migExt = (config: ResolvedConfig): string =>
84
+ getDriver(config.driver ?? "surrealdb").migrations?.extension ?? ".surql";
85
+
86
+ /** Decorate diff items with their source file (from the snapshot `files` maps; driver leaves it unset). */
87
+ function attachFiles(
88
+ diff: Diff,
89
+ prevFiles: Record<string, string>,
90
+ nextFiles: Record<string, string>,
91
+ ): void {
92
+ for (const it of diff.items ?? []) {
93
+ const primary = it.op === "remove" ? prevFiles : nextFiles;
94
+ it.file = primary[it.table] ?? nextFiles[it.table] ?? prevFiles[it.table];
95
+ }
96
+ }
97
+
98
+ export type Direction = "up" | "down";
99
+
100
+ export interface GenerateResult {
101
+ created: boolean;
102
+ tag?: string;
103
+ file?: string;
104
+ up?: number;
105
+ down?: number;
106
+ }
107
+
108
+ export interface MigrationPlan {
109
+ diff: Diff;
110
+ next: StoredSnapshot;
111
+ }
112
+
113
+ /**
114
+ * Compute the pending diff (schemas vs snapshot) WITHOUT writing anything. With `baseline`, the
115
+ * stored snapshot is ignored and the schema is diffed against an EMPTY snapshot — so the resulting
116
+ * migration is the full schema, for regenerating a fresh baseline after removing all migrations.
117
+ */
118
+ export async function planMigration(
119
+ config: ResolvedConfig,
120
+ filter: Filter = parseFilter({}),
121
+ opts: { baseline?: boolean } = {},
122
+ ): Promise<MigrationPlan> {
123
+ const { tables, defs, fileOf } = await loadDefs(config.schemaPath);
124
+ const driver = getDriver(config.driver ?? "surrealdb");
125
+ const reg = driver.registry;
126
+ const next = buildStored(driver, tables, defs, { fileOf, root: config.root });
127
+ const prev = opts.baseline ? EMPTY_STORED : readSnapshot(config.metaDir);
128
+ const diff = buildKindDiff(
129
+ reg,
130
+ filterKinds(reg, snapshotObjects(prev.schema), filter),
131
+ filterKinds(reg, snapshotObjects(next.schema), filter),
132
+ );
133
+ attachFiles(diff, prev.files ?? {}, next.files ?? {});
134
+ // Persist only the generated kinds; excluded kinds (e.g. access) keep their prior snapshot state.
135
+ return { diff, next: mergeStored(reg, prev, next, filter) };
136
+ }
137
+
138
+ /**
139
+ * A fresh, sortable migration tag: a UTC timestamp prefix + name slug. If a file with that tag
140
+ * already exists (two migrations in the same second), the timestamp is bumped a second at a time
141
+ * so the result is unique and ordering stays monotonic.
142
+ */
143
+ function nextTag(migrationsDir: string, name: string, ext: string): string {
144
+ const s = slug(name);
145
+ const date = new Date();
146
+ let tag = `${timestamp(date)}_${s}`;
147
+ while (existsSync(join(migrationsDir, `${tag}${ext}`))) {
148
+ date.setUTCSeconds(date.getUTCSeconds() + 1);
149
+ tag = `${timestamp(date)}_${s}`;
150
+ }
151
+ return tag;
152
+ }
153
+
154
+ export interface PreparedMigration {
155
+ tag: string;
156
+ file: string;
157
+ /** The rendered `.surql` program (what will be written to disk). */
158
+ content: string;
159
+ next: StoredSnapshot;
160
+ up: number;
161
+ down: number;
162
+ }
163
+
164
+ /** Compute a migration's tag, filename, and rendered `.surql` content WITHOUT writing anything. */
165
+ export function prepareMigration(
166
+ config: ResolvedConfig,
167
+ plan: MigrationPlan,
168
+ name?: string,
169
+ ): PreparedMigration | null {
170
+ const { diff, next } = plan;
171
+ if (isEmptyDiff(diff)) return null;
172
+ mkdirSync(config.migrationsDir, { recursive: true });
173
+ const ext = migExt(config);
174
+ const tag = nextTag(config.migrationsDir, name ?? "migration", ext);
175
+ return {
176
+ tag,
177
+ file: `${tag}${ext}`,
178
+ content: migStore(config).render(tag, diff),
179
+ next,
180
+ up: diff.up.length,
181
+ down: diff.down.length,
182
+ };
183
+ }
184
+
185
+ /** Write a prepared migration to disk (file + snapshot). */
186
+ export function commitMigration(
187
+ config: ResolvedConfig,
188
+ prepared: PreparedMigration,
189
+ ): GenerateResult {
190
+ mkdirSync(config.migrationsDir, { recursive: true });
191
+ writeFileSync(join(config.migrationsDir, prepared.file), prepared.content);
192
+ writeSnapshot(config.metaDir, prepared.next);
193
+ return {
194
+ created: true,
195
+ tag: prepared.tag,
196
+ file: prepared.file,
197
+ up: prepared.up,
198
+ down: prepared.down,
199
+ };
200
+ }
201
+
202
+ /** Write a planned migration to disk (file + snapshot). No-op for an empty diff. */
203
+ export function writeMigration(
204
+ config: ResolvedConfig,
205
+ plan: MigrationPlan,
206
+ name?: string,
207
+ ): GenerateResult {
208
+ const prepared = prepareMigration(config, plan, name);
209
+ if (!prepared) return { created: false };
210
+ return commitMigration(config, prepared);
211
+ }
212
+
213
+ /** Diff the schemas against the snapshot and, if anything changed, write a migration. */
214
+ export async function generate(
215
+ config: ResolvedConfig,
216
+ name?: string,
217
+ ): Promise<GenerateResult> {
218
+ return writeMigration(config, await planMigration(config), name);
219
+ }
220
+
221
+ /**
222
+ * Record a migration as already-applied WITHOUT running it — used to baseline an existing database
223
+ * (e.g. after `pull`), where the objects already exist so the DDL must not be re-executed.
224
+ */
225
+ async function recordApplied(
226
+ db: unknown,
227
+ config: ResolvedConfig,
228
+ m: PreparedMigration,
229
+ ): Promise<void> {
230
+ await migStore(config).record(db, config.migrationsTable, {
231
+ tag: m.tag,
232
+ file: m.file,
233
+ checksum: checksum(m.content),
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Baseline the project against the LIVE database (e.g. after `pull`): snapshot the current DB state
239
+ * — respecting `filter`, the same one `pull` used — and, when it differs from the stored snapshot,
240
+ * write a migration capturing that delta, recorded as already-applied (those objects already exist
241
+ * in the DB, so the DDL must not re-run). Only what is actually in the DB is baselined: any
242
+ * hand-written schema not yet in the DB stays pending for the next `schemic gen`. Returns the migration's
243
+ * metadata, or `created: false` when nothing changed.
244
+ */
245
+ export async function baseline(
246
+ db: unknown,
247
+ config: ResolvedConfig,
248
+ filter: Filter = parseFilter({}),
249
+ ): Promise<GenerateResult> {
250
+ const driver = getDriver(config.driver ?? "surrealdb");
251
+ const reg = driver.registry;
252
+ // What actually exists in the live DB (canonical portable objects), used ONLY to scope the
253
+ // baseline: hand-written schema not yet in the DB stays pending for the next `schemic gen` rather
254
+ // than being silently marked applied. introspectAll already canonicalizes (== lowering).
255
+ const live = await driver.introspectAll(
256
+ db,
257
+ new Set([config.migrationsTable, `${config.migrationsTable}_lock`]),
258
+ );
259
+ // The snapshot stores GENERATOR-form schema (what `schemic gen`/`schemic diff` compare against
260
+ // offline). We take the just-pulled disk schema and keep only the objects present in the DB —
261
+ // intersecting by `kind:name` so the stored form stays the canonical (generator) one, not the INFO form.
262
+ const { tables, defs, fileOf } = await loadDefs(config.schemaPath);
263
+ const disk = buildStored(driver, tables, defs, {
264
+ fileOf,
265
+ root: config.root,
266
+ });
267
+ const pulledObjects = intersectKinds(
268
+ reg,
269
+ snapshotObjects(disk.schema),
270
+ live,
271
+ filter,
272
+ );
273
+ const pulled: StoredSnapshot = {
274
+ version: 3,
275
+ driver: driver.name,
276
+ schema: snapshotKinds(pulledObjects),
277
+ files: disk.files,
278
+ };
279
+
280
+ const prev = readSnapshot(config.metaDir);
281
+ const diff = buildKindDiff(
282
+ reg,
283
+ filterKinds(reg, snapshotObjects(prev.schema), filter),
284
+ pulledObjects,
285
+ );
286
+ attachFiles(diff, prev.files ?? {}, pulled.files ?? {});
287
+ const plan: MigrationPlan = {
288
+ diff,
289
+ next: mergeStored(reg, prev, pulled, filter),
290
+ };
291
+ const prepared = prepareMigration(config, plan, "baseline");
292
+ if (!prepared) {
293
+ // DB already matches the snapshot — just persist (in case excluded kinds shifted).
294
+ writeSnapshot(config.metaDir, plan.next);
295
+ return { created: false };
296
+ }
297
+ const res = commitMigration(config, prepared);
298
+ await recordApplied(db, config, prepared);
299
+ return res;
300
+ }
301
+
302
+ /** Delete every migration `.surql` file (the `meta/` snapshot is left intact); returns removed tags. */
303
+ export function clearMigrationFiles(config: ResolvedConfig): string[] {
304
+ const migs = listMigrations(config.migrationsDir, migExt(config));
305
+ for (const m of migs)
306
+ rmSync(join(config.migrationsDir, m.file), { force: true });
307
+ return migs.map((m) => m.tag);
308
+ }
309
+
310
+ /**
311
+ * Reconcile the DB's migration history after a baseline squash (old migrations replaced by one
312
+ * fresh baseline). When the live DB already matches the schema (`drift` false), drop the now-stale
313
+ * applied-records and record the baseline as already-applied — its DDL is never re-run. When the DB
314
+ * differs (`drift` true), leave the history untouched and report the baseline as still pending (the
315
+ * next `schemic migrate` applies it). Caller handles the no-connection case.
316
+ */
317
+ export async function reconcileBaseline(
318
+ db: unknown,
319
+ config: ResolvedConfig,
320
+ prepared: PreparedMigration,
321
+ drift: boolean,
322
+ ): Promise<"applied" | "pending"> {
323
+ const mig = migStore(config);
324
+ await mig.ensure(db, config.migrationsTable);
325
+ if (drift) return "pending";
326
+ // DB == schema: the squashed objects already exist, so wipe the stale tags and mark the baseline
327
+ // applied rather than re-running its DDL.
328
+ await mig.clear(db, config.migrationsTable);
329
+ await recordApplied(db, config, prepared);
330
+ return "applied";
331
+ }
332
+
333
+ export interface MigrateResult {
334
+ applied: Migration[];
335
+ }
336
+
337
+ /** The position of `tag` in the ordered migration list, or throw if it isn't known. */
338
+ function indexOfTag(migrations: Migration[], tag: string): number {
339
+ const i = migrations.findIndex((m) => m.tag === tag);
340
+ if (i < 0) throw new Error(`Unknown migration: ${tag}`);
341
+ return i;
342
+ }
343
+
344
+ /** Manually clear a stale migration lock. */
345
+ export async function unlock(
346
+ db: unknown,
347
+ config: ResolvedConfig,
348
+ ): Promise<void> {
349
+ await migStore(config).unlock(db, config.migrationsTable);
350
+ }
351
+
352
+ /**
353
+ * Apply pending migrations in order — all, the next `count`, or up to and including `to`.
354
+ * Takes an advisory lock so concurrent runs can't race.
355
+ */
356
+ export async function migrate(
357
+ db: unknown,
358
+ config: ResolvedConfig,
359
+ opts: { count?: number; to?: string } = {},
360
+ ): Promise<MigrateResult> {
361
+ const mig = migStore(config);
362
+ const table = config.migrationsTable;
363
+ await mig.ensure(db, table);
364
+ const migrations = listMigrations(config.migrationsDir, migExt(config));
365
+ const applied = await mig.applied(db, table);
366
+ let pending = migrations.filter((m) => !applied.has(m.tag));
367
+ if (opts.to) {
368
+ const targetIdx = indexOfTag(migrations, opts.to);
369
+ const pos = new Map(migrations.map((m, i) => [m.tag, i]));
370
+ pending = pending.filter((m) => (pos.get(m.tag) ?? 0) <= targetIdx);
371
+ }
372
+ if (opts.count !== undefined)
373
+ pending = pending.slice(0, Math.max(0, opts.count));
374
+
375
+ await mig.lock(db, table);
376
+ try {
377
+ for (const m of pending) {
378
+ const content = readFileSync(join(config.migrationsDir, m.file), "utf8");
379
+ await mig.apply(db, table, {
380
+ content,
381
+ direction: "up",
382
+ record: { tag: m.tag, file: m.file, checksum: checksum(content) },
383
+ });
384
+ }
385
+ } finally {
386
+ await mig.unlock(db, table);
387
+ }
388
+ return { applied: pending };
389
+ }
390
+
391
+ export interface StatusRow {
392
+ tag: string;
393
+ applied: boolean;
394
+ /** True if an applied migration's file was edited after it was applied. */
395
+ drift?: boolean;
396
+ /** True if the DB records it applied but the migration file is gone (orphaned bookkeeping). */
397
+ missing?: boolean;
398
+ }
399
+
400
+ /** Recompute a migration file's checksum from its current contents (missing → ""). */
401
+ function currentChecksum(config: ResolvedConfig, m: Migration): string {
402
+ const path = join(config.migrationsDir, m.file);
403
+ if (!existsSync(path)) return "";
404
+ return checksum(readFileSync(path, "utf8"));
405
+ }
406
+
407
+ /** Per-migration applied/pending status (+ drift), in apply order. Includes orphaned rows — tags the
408
+ * DB records applied whose files are gone — so a deleted-files / reset-snapshot drift is visible. */
409
+ export async function status(
410
+ db: unknown,
411
+ config: ResolvedConfig,
412
+ ): Promise<StatusRow[]> {
413
+ const mig = migStore(config);
414
+ await mig.ensure(db, config.migrationsTable);
415
+ const applied = await mig.applied(db, config.migrationsTable);
416
+ const files = listMigrations(config.migrationsDir, migExt(config));
417
+ const fileTags = new Set(files.map((m) => m.tag));
418
+ const rows: StatusRow[] = files.map((m) => {
419
+ const appliedSum = applied.get(m.tag);
420
+ return {
421
+ tag: m.tag,
422
+ applied: appliedSum !== undefined,
423
+ drift:
424
+ appliedSum !== undefined && appliedSum !== currentChecksum(config, m),
425
+ };
426
+ });
427
+ // Orphans: recorded applied in the DB but the file no longer exists on disk.
428
+ for (const tag of applied.keys())
429
+ if (!fileTags.has(tag)) rows.push({ tag, applied: true, missing: true });
430
+ return rows.sort((a, b) => a.tag.localeCompare(b.tag));
431
+ }
432
+
433
+ /**
434
+ * Roll back applied migrations (newest first): the last `count`, or everything applied after
435
+ * `to` (leaving `to` as the latest applied). Takes the advisory lock.
436
+ */
437
+ export async function rollback(
438
+ db: unknown,
439
+ config: ResolvedConfig,
440
+ opts: { count?: number; to?: string } = {},
441
+ ): Promise<Migration[]> {
442
+ const mig = migStore(config);
443
+ const table = config.migrationsTable;
444
+ await mig.ensure(db, table);
445
+ const migrations = listMigrations(config.migrationsDir, migExt(config));
446
+ const applied = await mig.applied(db, table);
447
+ const appliedMigrations = migrations.filter((m) => applied.has(m.tag));
448
+
449
+ let toRevert: Migration[];
450
+ if (opts.to) {
451
+ const targetIdx = indexOfTag(migrations, opts.to);
452
+ const pos = new Map(migrations.map((m, i) => [m.tag, i]));
453
+ toRevert = appliedMigrations
454
+ .filter((m) => (pos.get(m.tag) ?? 0) > targetIdx)
455
+ .reverse();
456
+ } else {
457
+ toRevert = appliedMigrations.slice(-(opts.count ?? 1)).reverse();
458
+ }
459
+
460
+ await mig.lock(db, table);
461
+ try {
462
+ for (const m of toRevert) {
463
+ const content = readFileSync(join(config.migrationsDir, m.file), "utf8");
464
+ await mig.apply(db, table, {
465
+ content,
466
+ direction: "down",
467
+ record: { tag: m.tag, file: m.file, checksum: checksum(content) },
468
+ });
469
+ }
470
+ } finally {
471
+ await mig.unlock(db, table);
472
+ }
473
+ return toRevert;
474
+ }
475
+
476
+ /** Run the project's seed script (`config.seed` or `database/seed.ts`) with a connected client. */
477
+ export async function seed(db: unknown, config: ResolvedConfig): Promise<void> {
478
+ const path = config.seed
479
+ ? resolve(config.root, config.seed)
480
+ : resolve(config.root, "database/seed.ts");
481
+ if (!existsSync(path)) throw new Error(`Seed file not found: ${path}`);
482
+ const mod = (await makeJiti().import(path)) as Record<string, unknown> & {
483
+ default?: unknown;
484
+ };
485
+ // The seed callback is the user's own code against the live driver connection — they annotate it
486
+ // with their SDK's client type (e.g. `Surreal`); the orchestration hands it the opaque `db`.
487
+ const fn = (typeof mod.default === "function" ? mod.default : mod.seed) as
488
+ | ((db: unknown) => Promise<unknown>)
489
+ | undefined;
490
+ if (typeof fn !== "function") {
491
+ throw new Error("Seed file must export a default function `(db) => …`.");
492
+ }
493
+ await fn(db);
494
+ }
@@ -0,0 +1,88 @@
1
+ // The driver-parametric LIVE diff path. `sz diff --driver <name>` (or a non-default `config.driver`)
2
+ // routes here: author with that driver's `s.*`, lower via its kind registry, introspect the live DB,
3
+ // and show the DDL gap — all generic over the registry (core-v2). Each DB is its own world: you author
4
+ // and diff with the SAME driver (kinds aren't cross-driver), so there is no cross-dialect lowering.
5
+
6
+ import type { ResolvedConfig } from "@schemic/core";
7
+ import {
8
+ buildKindDiff,
9
+ type DiffItem,
10
+ formatItems,
11
+ getDriver,
12
+ loadDefs,
13
+ lowerSchema,
14
+ ok,
15
+ plural,
16
+ type PortableObject,
17
+ style,
18
+ } from "@schemic/core";
19
+
20
+ /** A loaded, opaque driver connection (each driver's `connect` returns its own type). */
21
+ type Conn = unknown;
22
+
23
+ /**
24
+ * Run `sz diff` against a driver's LIVE database. Authoring is that driver's `s.*` surface (exploded +
25
+ * lowered via its registry); the diff is the generic `buildKindDiff`.
26
+ */
27
+ export async function portableDiff(
28
+ config: ResolvedConfig,
29
+ driverName: string,
30
+ opts: { json?: boolean } = {},
31
+ ): Promise<void> {
32
+ const driver = getDriver(driverName);
33
+ const reg = driver.registry;
34
+
35
+ // Desired = the declared schema, authored with this driver's s.* -> exploded -> lowered.
36
+ const { tables, defs } = await loadDefs(config.schemaPath);
37
+ const desired: PortableObject[] = lowerSchema(reg, driver.explode(tables, defs));
38
+
39
+ // Live = the database, introspected through the driver, canonicalized identically to lowering.
40
+ const conn = (await driver.connect(config)) as Conn;
41
+ let live: PortableObject[];
42
+ try {
43
+ // Exclude the migration bookkeeping tables (and their `_lock` companion) — they're CLI-owned, not
44
+ // schema, so `diff` must not report them as "to remove". Same scoping as migrate's baseline read.
45
+ live = await driver.introspectAll(
46
+ conn as never,
47
+ new Set([config.migrationsTable, `${config.migrationsTable}_lock`]),
48
+ );
49
+ } finally {
50
+ await closeQuietly(conn);
51
+ }
52
+
53
+ // Generic kind-registry diff (field-level via each kind's overwrite/displayItems), so the
54
+ // `diff --driver` display matches exactly what `gen`/`migrate` emit for that database.
55
+ const diff = buildKindDiff(reg, live, desired);
56
+
57
+ if (opts.json) {
58
+ console.log(
59
+ JSON.stringify({ driver: driverName, up: diff.up, down: diff.down }),
60
+ );
61
+ return;
62
+ }
63
+
64
+ console.log(style.dim(` driver: ${driverName}`));
65
+ if (!diff.up.length) {
66
+ console.log(ok("Schema is in sync with the target database."));
67
+ return;
68
+ }
69
+ const items = diff.items ?? [];
70
+ console.log(formatItems(items));
71
+ const n = (op: DiffItem["op"]) => items.filter((i) => i.op === op).length;
72
+ console.log(
73
+ style.dim(
74
+ `\n${plural(items.length, "change")} — ${n("add")} added, ${n("change")} changed, ${n("remove")} removed.`,
75
+ ),
76
+ );
77
+ }
78
+
79
+ async function closeQuietly(conn: unknown): Promise<void> {
80
+ const close = (conn as { close?: () => Promise<void> } | null)?.close;
81
+ if (typeof close === "function") {
82
+ try {
83
+ await close.call(conn);
84
+ } catch {
85
+ // best-effort: a failed close shouldn't mask the diff result
86
+ }
87
+ }
88
+ }