@thomasfosterau/effect-drizzle-ext-pg 0.0.0 → 0.1.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,640 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * **The migration-drift gate**: fails when a committed Drizzle schema and its
5
+ * committed drizzle-kit `migrations/` snapshots disagree, without a database
6
+ * or a deployed stage. Ported from the applications'
7
+ * `packages/database/scripts/check-migration-drift.ts` (website #1200,
8
+ * #1471; preferenceflow's copy is the reference), as a bin-less Effect an
9
+ * application's `db:migration-drift` task runs with its own schema module.
10
+ *
11
+ * Three checks, each guarding "snapshot history is a DAG, not a chain" from a
12
+ * different side:
13
+ *
14
+ * 1. **DAG integrity.** drizzle-kit 1.0's snapshots chain via `prevIds`
15
+ * (plural — a migration generated after two branches merge names both
16
+ * tips), so history is a DAG. Divergence is normal while unmerged; it is
17
+ * broken when two branches both land without a later migration's
18
+ * `prevIds` reconverging them. Every snapshot id must be unique, every
19
+ * `prevIds` entry must resolve, and the lineage must have exactly one head
20
+ * from which everything is reachable, with no cycle.
21
+ * 2. **Zero drift.** The exact drizzle-kit calls a deploy-time regeneration
22
+ * makes (`generateDrizzleJson` / `generateMigration` from
23
+ * `drizzle-kit/api-postgres`) against the schema module and the
24
+ * **lexicographically-latest** migration directory's snapshot — the same
25
+ * "latest by directory name" selection alchemy's
26
+ * `Drizzle.Schema.regenerate` uses, NOT a DAG-aware pick — must produce
27
+ * zero statements. A diff drizzle-kit can only resolve interactively
28
+ * (rename-or-recreate) is drift too.
29
+ * 3. **Base ancestry (pull requests only).** A migration must be generated on
30
+ * a checkout whose snapshot chain descends from the base branch's CURRENT
31
+ * head; checks 1–2 are internal to the checkout, so a branch forked from
32
+ * week-old `trunk` passes both while carrying a migration whose regenerated
33
+ * constraints silently omit everything the base added since. Runs only
34
+ * when a base is supplied ({@link resolveBaseRef} reads one from
35
+ * `MIGRATION_BASE_REF` / `GITHUB_BASE_REF`), and only when the branch adds
36
+ * migration directories.
37
+ *
38
+ * Zero drift runs only on a structurally sound DAG (and a base-descended
39
+ * one): with two heads the latest-by-name snapshot is an arbitrary pick of
40
+ * one fork, so its diff would be noise next to the real report.
41
+ *
42
+ * @module tooling/migration-drift
43
+ */
44
+
45
+ import { generateDrizzleJson, generateMigration, reconstruct } from "drizzle-kit/api-postgres";
46
+ import * as Arr from "effect/Array";
47
+ import * as Effect from "effect/Effect";
48
+ import * as FileSystem from "effect/FileSystem";
49
+ import * as HashSet from "effect/HashSet";
50
+ import * as Option from "effect/Option";
51
+ import * as Order from "effect/Order";
52
+ import * as Path from "effect/Path";
53
+ import type { PlatformError } from "effect/PlatformError";
54
+ import * as Predicate from "effect/Predicate";
55
+ import * as R from "effect/Record";
56
+ import * as Schema from "effect/Schema";
57
+ import { ChildProcess } from "effect/process";
58
+ import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
59
+
60
+ /**
61
+ * drizzle-kit's sentinel for "no real parent" (its `originUUID`): the genesis
62
+ * migration's `prevIds` names this instead of being empty.
63
+ */
64
+ export const ROOT_SENTINEL_ID = "00000000-0000-0000-0000-000000000000";
65
+
66
+ /** The full snapshot shape drizzle-kit's API works with. */
67
+ type PostgresSnapshot = Awaited<ReturnType<typeof generateDrizzleJson>>;
68
+
69
+ /** The identity every snapshot must carry for the DAG checks. */
70
+ export interface Snapshot {
71
+ readonly id: string;
72
+ readonly prevIds: ReadonlyArray<string>;
73
+ }
74
+
75
+ /** One migration directory and its snapshot's identity. */
76
+ export interface SnapshotEntry {
77
+ readonly dir: string;
78
+ readonly snapshot: Snapshot;
79
+ }
80
+
81
+ const SnapshotIdentity = Schema.Struct({
82
+ id: Schema.String,
83
+ prevIds: Schema.optionalKey(Schema.Array(Schema.String)),
84
+ });
85
+
86
+ /**
87
+ * A **full** (version `"8"`) snapshot, which earlier drizzle-kit 1.0
88
+ * prereleases wrote: the members the zero-drift diff relies on beyond the
89
+ * identity, checked, not decoded, since the snapshot is handed to
90
+ * `generateMigration` unchanged.
91
+ */
92
+ const PostgresSnapshotContract = Schema.Struct({
93
+ version: Schema.Literal("8"),
94
+ dialect: Schema.Literal("postgres"),
95
+ ddl: Schema.Array(Schema.Unknown),
96
+ });
97
+
98
+ const isPostgresSnapshot = (raw: unknown): raw is PostgresSnapshot =>
99
+ Schema.is(PostgresSnapshotContract)(raw);
100
+
101
+ /**
102
+ * A version `"9"` snapshot, which drizzle-kit 1.0.0-rc.5 writes (and
103
+ * `drizzle-kit up` upgrades a v8 chain to): a `base` or a `diff` over its
104
+ * parents, so the state at one is rebuilt from the whole chain with
105
+ * drizzle-kit's own `reconstruct`.
106
+ */
107
+ const ChainSnapshotContract = Schema.Struct({
108
+ version: Schema.Literal("9"),
109
+ dialect: Schema.Literal("postgres"),
110
+ type: Schema.Literals(["base", "diff"]),
111
+ });
112
+
113
+ const isChainSnapshot = Schema.is(ChainSnapshotContract);
114
+
115
+ /** A snapshot.json could not be read as a drizzle-kit snapshot. */
116
+ export class MalformedSnapshot extends Schema.TaggedError<MalformedSnapshot>()(
117
+ "MalformedSnapshot",
118
+ { file: Schema.String, cause: Schema.Defect() },
119
+ ) {
120
+ override get message(): string {
121
+ return `${this.file} is not a drizzle-kit snapshot (malformed JSON, or no string \`id\` / string[] \`prevIds\`) — never hand-edit migrations/.`;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * drizzle-kit itself failed while generating or diffing a snapshot — broken
127
+ * input (a schema module drizzle-kit cannot read) rather than drift.
128
+ */
129
+ export class SchemaDiffError extends Schema.TaggedError<SchemaDiffError>()("SchemaDiffError", {
130
+ cause: Schema.Defect(),
131
+ }) {
132
+ override get message(): string {
133
+ return "drizzle-kit failed to generate or diff the schema snapshot";
134
+ }
135
+ }
136
+
137
+ /** Run one drizzle-kit API call, its rejection a {@link SchemaDiffError}. */
138
+ export const drizzleKit = <A>(run: () => Promise<A>): Effect.Effect<A, SchemaDiffError> =>
139
+ Effect.tryPromise({ try: run, catch: (cause) => new SchemaDiffError({ cause }) });
140
+
141
+ /** The gate found problems; `issues` holds one human-readable report per violation. */
142
+ export class MigrationDriftError extends Schema.TaggedError<MigrationDriftError>()(
143
+ "MigrationDriftError",
144
+ { issues: Schema.Array(Schema.String) },
145
+ ) {
146
+ override get message(): string {
147
+ return `Migration snapshot drift check failed (${this.issues.length} issue(s)):\n\n${this.issues.join("\n\n")}`;
148
+ }
149
+ }
150
+
151
+ // ── 1. DAG integrity (pure) ─────────────────────────────────────────────
152
+
153
+ /** Snapshots not named in any other snapshot's `prevIds` — the DAG's tips. */
154
+ export const findHeads = (entries: ReadonlyArray<SnapshotEntry>): ReadonlyArray<SnapshotEntry> => {
155
+ const referenced = HashSet.fromIterable(entries.flatMap((entry) => entry.snapshot.prevIds));
156
+ return entries.filter((entry) => !HashSet.has(referenced, entry.snapshot.id));
157
+ };
158
+
159
+ const findDuplicateIds = (entries: ReadonlyArray<SnapshotEntry>): ReadonlyArray<SnapshotEntry> =>
160
+ entries.filter(
161
+ (entry, index) =>
162
+ entries.findIndex((other) => other.snapshot.id === entry.snapshot.id) !== index,
163
+ );
164
+
165
+ const findDanglingParents = (
166
+ entries: ReadonlyArray<SnapshotEntry>,
167
+ ): ReadonlyArray<{ readonly dir: string; readonly prevId: string }> => {
168
+ const ids = HashSet.fromIterable(entries.map((entry) => entry.snapshot.id));
169
+ return entries.flatMap((entry) =>
170
+ entry.snapshot.prevIds
171
+ .filter((prevId) => prevId !== ROOT_SENTINEL_ID && !HashSet.has(ids, prevId))
172
+ .map((prevId) => ({ dir: entry.dir, prevId })),
173
+ );
174
+ };
175
+
176
+ /** What {@link traverse} found: every id reached, and the first cycle (as directory labels). */
177
+ interface Traversal {
178
+ readonly reachable: ReadonlySet<string>;
179
+ readonly cycle: Option.Option<ReadonlyArray<string>>;
180
+ }
181
+
182
+ /**
183
+ * DFS from `starts` backward through `prevIds`, three-state (visiting /
184
+ * done) so a back-edge (a cycle) is told apart from ordinary convergence.
185
+ * Returns everything reached and the first cycle found, as directory labels.
186
+ */
187
+ const traverse = (
188
+ starts: ReadonlyArray<SnapshotEntry>,
189
+ entries: ReadonlyArray<SnapshotEntry>,
190
+ ): Traversal => {
191
+ // oxlint-disable-next-line effect/avoid-native-object-helpers -- an id → entry index built once for the walk; never escapes
192
+ const byId = new Map(entries.map((entry) => [entry.snapshot.id, entry]));
193
+ // oxlint-disable-next-line effect/avoid-native-object-helpers -- the DFS colouring, shared across successive starts so a node left `done` stays done
194
+ const state = new Map<string, "visiting" | "done">();
195
+ // oxlint-disable-next-line effect/avoid-native-object-helpers -- accumulated across the walk and returned partially filled when a cycle stops it
196
+ const reachable = new Set<string>();
197
+ const label = (id: string): string => byId.get(id)?.dir ?? id;
198
+
199
+ const visit = (id: string, path: ReadonlyArray<string>): Option.Option<ReadonlyArray<string>> => {
200
+ if (state.get(id) === "visiting") return Option.some([...path, label(id)]);
201
+ if (state.get(id) === "done") return Option.none();
202
+ state.set(id, "visiting");
203
+ reachable.add(id);
204
+ const next = [...path, label(id)];
205
+ // oxlint-disable-next-line effect/imperative-loops -- stops the whole recursive walk at the first cycle, leaving this node `visiting`; a fold can neither short-circuit nor leave that state behind
206
+ for (const prevId of byId.get(id)?.snapshot.prevIds ?? []) {
207
+ const cycle = visit(prevId, next);
208
+ if (Option.isSome(cycle)) return cycle;
209
+ }
210
+ state.set(id, "done");
211
+ return Option.none();
212
+ };
213
+
214
+ // oxlint-disable-next-line effect/imperative-loops -- as above: stop at the first start whose walk finds a cycle
215
+ for (const start of starts) {
216
+ const cycle = visit(start.snapshot.id, []);
217
+ if (Option.isSome(cycle)) return { reachable, cycle };
218
+ }
219
+ return { reachable, cycle: Option.none() };
220
+ };
221
+
222
+ /**
223
+ * Every snapshot id is unique, every `prevIds` reference resolves (or is
224
+ * {@link ROOT_SENTINEL_ID}), the lineage has exactly one head and no cycle,
225
+ * and every snapshot is reachable from that head. One human-readable report
226
+ * per violation; empty when clean.
227
+ */
228
+ export const checkDagIntegrity = (entries: ReadonlyArray<SnapshotEntry>): ReadonlyArray<string> => {
229
+ const issues: Array<string> = [];
230
+
231
+ const duplicates = findDuplicateIds(entries);
232
+ if (Arr.isReadonlyArrayNonEmpty(duplicates)) {
233
+ issues.push(
234
+ `${duplicates.length} migration snapshot(s) share an \`id\` with an earlier snapshot — every ` +
235
+ `snapshot must have a unique id: ${duplicates.map((d) => d.dir).join(", ")}.\n` +
236
+ "`drizzle-kit generate` never produces this; check for a copied or hand-edited snapshot.json.",
237
+ );
238
+ }
239
+
240
+ const dangling = findDanglingParents(entries);
241
+ if (Arr.isReadonlyArrayNonEmpty(dangling)) {
242
+ issues.push(
243
+ `${dangling.length} \`prevIds\` entry(ies) name a parent snapshot id that no committed ` +
244
+ `snapshot has: ${dangling.map((d) => `${d.dir} -> ${d.prevId}`).join(", ")}.\n` +
245
+ "The migrations directory may be missing a file, or a snapshot.json was hand-edited.",
246
+ );
247
+ }
248
+
249
+ const heads = findHeads(entries);
250
+ if (heads.length !== 1) {
251
+ issues.push(
252
+ `Expected exactly 1 head in the migration snapshot DAG, found ${heads.length}` +
253
+ (Arr.isReadonlyArrayNonEmpty(heads) ? `: ${heads.map((h) => h.dir).join(", ")}` : "") +
254
+ ".\n" +
255
+ (Arr.isReadonlyArrayNonEmpty(heads)
256
+ ? "Two or more migrations were generated from the same parent snapshot and both merged " +
257
+ "without a later migration's `prevIds` reconverging them (a snapshot-chain fork). " +
258
+ "Fix: rebase and regenerate, so the new migration's `prevIds` lists every outstanding head."
259
+ : "Every snapshot is named as some other snapshot's parent: the lineage has no real root " +
260
+ "(see any reported cycle)."),
261
+ );
262
+ }
263
+
264
+ const { reachable } = traverse(heads, entries);
265
+ // From every entry, not just the heads, so a cycle isolated in its own
266
+ // component (whose nodes all reference each other, so none is a head) still
267
+ // gets its own report instead of folding into the orphan one.
268
+ const { cycle } = traverse(entries, entries);
269
+ if (Option.isSome(cycle)) {
270
+ issues.push(
271
+ `The migration snapshot DAG contains a cycle in its \`prevIds\` lineage: ${cycle.value.join(" -> ")}.\n` +
272
+ "History can never legitimately cycle back to an ancestor — this means corrupted or " +
273
+ "hand-edited snapshot.json files.",
274
+ );
275
+ }
276
+
277
+ const orphaned = entries.filter((entry) => !reachable.has(entry.snapshot.id));
278
+ if (Arr.isReadonlyArrayNonEmpty(orphaned)) {
279
+ issues.push(
280
+ `${orphaned.length} migration snapshot(s) are not reachable from the DAG head(s) via ` +
281
+ `\`prevIds\`: ${orphaned.map((o) => o.dir).join(", ")}.\n` +
282
+ "A deploy that regenerates against the lexicographically-latest snapshot would silently " +
283
+ "drop these branches' schema changes.",
284
+ );
285
+ }
286
+
287
+ return issues;
288
+ };
289
+
290
+ // ── 3. Base ancestry (pure) ─────────────────────────────────────────────
291
+
292
+ /** The base branch's migration state: its directory names and its latest-by-name snapshot. */
293
+ export interface BaseMigrations {
294
+ readonly ref: string;
295
+ readonly dirs: ReadonlyArray<string>;
296
+ readonly head: Option.Option<SnapshotEntry>;
297
+ }
298
+
299
+ /**
300
+ * Every migration this branch ADDS must descend from the base's current
301
+ * head. Presence of the base head's snapshot id in this checkout's entries is
302
+ * sufficient: {@link checkDagIntegrity} separately proves the local DAG has a
303
+ * single head from which everything is reachable, so containing the base head
304
+ * means the new tail grew on top of it. A branch that adds no migration
305
+ * directory passes.
306
+ */
307
+ export const checkBaseAncestry = (
308
+ entries: ReadonlyArray<SnapshotEntry>,
309
+ base: BaseMigrations,
310
+ ): ReadonlyArray<string> => {
311
+ const baseDirs = HashSet.fromIterable(base.dirs);
312
+ const added = entries.filter((entry) => !HashSet.has(baseDirs, entry.dir));
313
+ if (Arr.isReadonlyArrayEmpty(added) || Option.isNone(base.head)) return [];
314
+ const head = base.head.value;
315
+ if (entries.some((entry) => entry.snapshot.id === head.snapshot.id)) return [];
316
+ return [
317
+ `New migration(s) ${added.map((entry) => entry.dir).join(", ")} were generated on a checkout ` +
318
+ `that predates ${base.ref}'s current migration head (${head.dir}, snapshot id ` +
319
+ `${head.snapshot.id}) — the base head is not an ancestor of this branch's snapshot chain, so ` +
320
+ "the generated DDL was computed from a stale schema (a regenerated CHECK constraint silently " +
321
+ `reverting members the base added since the fork). Fix: rebase onto ${base.ref}, delete the ` +
322
+ "stale migration directory, and regenerate it.",
323
+ ];
324
+ };
325
+
326
+ /** The environment {@link resolveBaseRef} reads — the shape of `process.env`. */
327
+ export interface BaseRefEnvironment {
328
+ // oxlint-disable-next-line effect/prefer-option-over-null -- `process.env`'s shape (unset variables are missing keys), passed straight in
329
+ readonly [name: string]: string | undefined;
330
+ }
331
+
332
+ /**
333
+ * The base ref for the ancestry check, when one is knowable: an explicit
334
+ * `MIGRATION_BASE_REF` wins (local use: `MIGRATION_BASE_REF=origin/trunk`),
335
+ * else `origin/$GITHUB_BASE_REF` — which GitHub Actions sets only on
336
+ * `pull_request` events, so pushes and local runs skip the check.
337
+ */
338
+ export const resolveBaseRef = (env: BaseRefEnvironment): Option.Option<string> => {
339
+ const explicit = env.MIGRATION_BASE_REF ?? "";
340
+ if (explicit !== "") return Option.some(explicit);
341
+ const base = env.GITHUB_BASE_REF ?? "";
342
+ return base === "" ? Option.none() : Option.some(`origin/${base}`);
343
+ };
344
+
345
+ // ── Reading ─────────────────────────────────────────────────────────────
346
+
347
+ /** A migration directory name: drizzle-kit's all-digit prefix and a slug. */
348
+ const MIGRATION_DIR = /^\d+_/;
349
+
350
+ /**
351
+ * A parsed `snapshot.json`, checked (not decoded) against
352
+ * {@link SnapshotIdentity}: the value is the whole parsed document, every
353
+ * member drizzle-kit reads included, so it can be handed on unchanged.
354
+ */
355
+ type ParsedSnapshot = typeof SnapshotIdentity.Type;
356
+
357
+ const parseSnapshot = (
358
+ file: string,
359
+ text: string,
360
+ ): Effect.Effect<ParsedSnapshot, MalformedSnapshot> =>
361
+ Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe(
362
+ Effect.filterOrFail(Schema.is(SnapshotIdentity), (raw) => raw),
363
+ Effect.mapError((cause) => new MalformedSnapshot({ file, cause })),
364
+ );
365
+
366
+ /** A parsed snapshot and its identity; `raw` is handed to drizzle-kit unchanged. */
367
+ interface ReadSnapshot extends SnapshotEntry {
368
+ readonly raw: unknown;
369
+ }
370
+
371
+ const toEntry = (dir: string, raw: ParsedSnapshot): ReadSnapshot => ({
372
+ dir,
373
+ raw,
374
+ snapshot: { id: raw.id, prevIds: raw.prevIds ?? [] },
375
+ });
376
+
377
+ /**
378
+ * Every migration directory under `migrationsDir` (sorted by name) and the
379
+ * snapshot of each that has one. A directory without a snapshot (a
380
+ * hand-written bootstrap) is listed but carries no entry — exactly like
381
+ * alchemy's `readLatestSnapshot`.
382
+ */
383
+ const readLocal = (
384
+ migrationsDir: string,
385
+ ): Effect.Effect<
386
+ { readonly dirs: ReadonlyArray<string>; readonly entries: ReadonlyArray<ReadSnapshot> },
387
+ MalformedSnapshot | PlatformError,
388
+ FileSystem.FileSystem | Path.Path
389
+ > =>
390
+ Effect.gen(function* () {
391
+ const fs = yield* FileSystem.FileSystem;
392
+ const path = yield* Path.Path;
393
+ const names = Arr.sort(
394
+ (yield* fs.readDirectory(migrationsDir)).filter((name) => MIGRATION_DIR.test(name)),
395
+ Order.String,
396
+ );
397
+ const dirs = yield* Effect.filter(names, (name) =>
398
+ Effect.map(fs.stat(path.join(migrationsDir, name)), (info) => info.type === "Directory"),
399
+ );
400
+ const entries = yield* Effect.forEach(
401
+ dirs,
402
+ (dir) =>
403
+ Effect.gen(function* () {
404
+ const file = path.join(migrationsDir, dir, "snapshot.json");
405
+ if (!(yield* fs.exists(file))) return [];
406
+ const raw = yield* parseSnapshot(file, yield* fs.readFileString(file));
407
+ return [toEntry(dir, raw)];
408
+ }),
409
+ { concurrency: 1 },
410
+ );
411
+ return { dirs, entries: entries.flat() };
412
+ });
413
+
414
+ /** Where the base branch's migrations are read from, through `git`. */
415
+ export interface GitBase {
416
+ /** The base ref, e.g. `origin/trunk` — from {@link resolveBaseRef}. */
417
+ readonly ref: string;
418
+ /** The migrations directory relative to the repository root, e.g. `packages/database/migrations`. */
419
+ readonly migrationsPath: string;
420
+ /** The repository checkout to run `git` in. */
421
+ readonly cwd: string;
422
+ }
423
+
424
+ const git = (
425
+ base: GitBase,
426
+ args: ReadonlyArray<string>,
427
+ ): Effect.Effect<string, PlatformError, ChildProcessSpawner> =>
428
+ Effect.gen(function* () {
429
+ const spawner = yield* ChildProcessSpawner;
430
+ return yield* spawner.string(ChildProcess.make("git", [...args], { cwd: base.cwd }));
431
+ });
432
+
433
+ /**
434
+ * The latest-by-name base directory carrying a readable snapshot — the same
435
+ * selection the zero-drift check makes locally. A directory without one (a
436
+ * hand-written bootstrap) is passed over.
437
+ */
438
+ const findBaseHead = (
439
+ base: GitBase,
440
+ dirs: ReadonlyArray<string>,
441
+ ): Effect.Effect<Option.Option<SnapshotEntry>, never, ChildProcessSpawner> =>
442
+ // Newest first, stopping at the first readable snapshot: one `git show` per
443
+ // directory until then, not one per directory.
444
+ Effect.findFirstFilter(dirs.toReversed(), (dir) =>
445
+ Effect.result(
446
+ Effect.flatMap(
447
+ git(base, ["show", `${base.ref}:${base.migrationsPath}/${dir}/snapshot.json`]),
448
+ (text) => Effect.map(parseSnapshot(dir, text), (raw): SnapshotEntry => toEntry(dir, raw)),
449
+ ),
450
+ ),
451
+ );
452
+
453
+ /**
454
+ * The base branch's migration state, read with `git ls-tree` / `git show` so
455
+ * it reflects the base's CURRENT tip. Fetches the base first when the
456
+ * checkout lacks it (a shallow pull-request checkout does).
457
+ */
458
+ export const readBaseMigrations = (
459
+ base: GitBase,
460
+ ): Effect.Effect<BaseMigrations, PlatformError, ChildProcessSpawner> =>
461
+ Effect.gen(function* () {
462
+ const spawner = yield* ChildProcessSpawner;
463
+ const present = yield* spawner.exitCode(
464
+ ChildProcess.make("git", ["rev-parse", "--verify", "--quiet", `${base.ref}^{commit}`], {
465
+ cwd: base.cwd,
466
+ }),
467
+ );
468
+ if (present !== 0) {
469
+ yield* git(base, ["fetch", "--depth=1", "origin", base.ref.replace(/^origin\//, "")]);
470
+ }
471
+ const listing = yield* git(base, [
472
+ "ls-tree",
473
+ "--name-only",
474
+ base.ref,
475
+ `${base.migrationsPath}/`,
476
+ ]);
477
+ const dirs = Arr.sort(
478
+ listing
479
+ .split("\n")
480
+ .map((line) => line.trim().split("/").at(-1) ?? "")
481
+ .filter((name) => MIGRATION_DIR.test(name)),
482
+ Order.String,
483
+ );
484
+ return { ref: base.ref, dirs, head: yield* findBaseHead(base, dirs) };
485
+ });
486
+
487
+ // ── 2. Zero drift ───────────────────────────────────────────────────────
488
+
489
+ /**
490
+ * drizzle-kit's resolvers throw `… was called without a HintsHandler` when
491
+ * the diff holds a created+deleted pair it would interactively ask about
492
+ * (rename or recreate). That is proof of a non-empty diff, reported as drift;
493
+ * every other failure is broken input and stays a defect.
494
+ */
495
+ const isAmbiguousDiff = (cause: unknown): boolean =>
496
+ Predicate.isError(cause) && cause.message.includes("was called without a HintsHandler");
497
+
498
+ const checkZeroDrift = (
499
+ schema: MigrationDriftOptions["schema"],
500
+ entries: ReadonlyArray<ReadSnapshot>,
501
+ latest: Option.Option<ReadSnapshot>,
502
+ label: string,
503
+ ): Effect.Effect<ReadonlyArray<string>, SchemaDiffError> =>
504
+ Effect.gen(function* () {
505
+ const where = Option.match(latest, {
506
+ onNone: () => `${label} (no snapshot yet)`,
507
+ onSome: (entry) => `${label}/${entry.dir}/snapshot.json`,
508
+ });
509
+ // The state the latest snapshot records: a v8 snapshot is that state; a
510
+ // v9 one is rebuilt from the chain (every directory's snapshot, keyed by
511
+ // directory name, as drizzle-kit's own folder reader keys them).
512
+ const stateOf = (entry: ReadSnapshot): Option.Option<Effect.Effect<object, SchemaDiffError>> =>
513
+ isPostgresSnapshot(entry.raw)
514
+ ? Option.some(Effect.succeed(entry.raw))
515
+ : isChainSnapshot(entry.raw)
516
+ ? Option.some(
517
+ drizzleKit(() =>
518
+ reconstruct(
519
+ R.fromEntries(Arr.map(entries, (each) => [each.dir, each.raw])),
520
+ entry.snapshot.id,
521
+ ),
522
+ ),
523
+ )
524
+ : Option.none();
525
+ const prevState = Option.match(latest, {
526
+ onNone: () => Option.some(drizzleKit(() => generateDrizzleJson({}))),
527
+ onSome: stateOf,
528
+ });
529
+ if (Option.isNone(prevState)) {
530
+ return [
531
+ `${where} doesn't match a drizzle-kit snapshot contract \`generateMigration\` reads ` +
532
+ '(`version: "8"` with a `ddl` array, or `version: "9"` with `type` `base` / `diff`; `dialect: "postgres"`): a stale (pre-v8) or hand-edited snapshot.',
533
+ ];
534
+ }
535
+ const cur = yield* drizzleKit(() =>
536
+ generateDrizzleJson(
537
+ schema,
538
+ Option.getOrUndefined(Option.map(latest, (entry) => entry.snapshot.id)),
539
+ ),
540
+ );
541
+ const prev = yield* prevState.value;
542
+ return yield* drizzleKit(() => generateMigration(prev, cur)).pipe(
543
+ Effect.map((statements) =>
544
+ Arr.isReadonlyArrayNonEmpty(statements)
545
+ ? [
546
+ `Schema drift detected against the latest committed migration snapshot (${where}): ` +
547
+ `${statements.length} statement(s) would be generated:\n\n` +
548
+ statements.map((statement) => ` ${statement}`).join("\n") +
549
+ "\n\nFix: generate a migration (`drizzle-kit generate --name <slug>`) and commit it with its snapshot.",
550
+ ]
551
+ : [],
552
+ ),
553
+ Effect.catchTag("SchemaDiffError", ({ cause }) =>
554
+ isAmbiguousDiff(cause)
555
+ ? Effect.succeed([
556
+ `Schema drift detected against the latest committed migration snapshot (${where}), and ` +
557
+ "the diff is ambiguous: drizzle-kit found a created+deleted pair it would interactively " +
558
+ "ask to resolve as rename-or-recreate. An ambiguous diff is still a non-empty diff.\n" +
559
+ "Fix: generate a migration (`drizzle-kit generate --name <slug>`) and commit it with its snapshot.",
560
+ ])
561
+ : Effect.fail(new SchemaDiffError({ cause })),
562
+ ),
563
+ );
564
+ });
565
+
566
+ // ── The gate ────────────────────────────────────────────────────────────
567
+
568
+ /** The inputs {@link checkMigrationDrift} takes. */
569
+ export interface MigrationDriftOptions {
570
+ /** The schema module (`import * as schema from "./schema.ts"`), as drizzle-kit's `generateDrizzleJson` takes it. */
571
+ // oxlint-disable-next-line effect/no-unsafe-dictionary-type -- drizzle-kit's boundary: `generateDrizzleJson(imports: Record<string, unknown>)` takes a module namespace of arbitrary exports, which it sorts itself; this is that parameter, forwarded unchanged
572
+ readonly schema: Record<string, unknown>;
573
+ /** The committed drizzle-kit migrations directory. */
574
+ readonly migrationsDir: string;
575
+ /**
576
+ * The base branch to check ancestry against — `Option.none()` (or absent,
577
+ * or `undefined`) on pushes and local runs, which is what mapping
578
+ * {@link resolveBaseRef} gives outside a pull request.
579
+ */
580
+ // oxlint-disable-next-line effect/prefer-option-over-null -- the `?: T | undefined` spelling (AGENTS.md → Conventions) lets a caller under `exactOptionalPropertyTypes` forward its own optional; read through `??`, so `undefined` means absent
581
+ readonly base?: Option.Option<GitBase> | undefined;
582
+ }
583
+
584
+ /** What a passing run checked. */
585
+ export interface MigrationDriftReport {
586
+ readonly snapshots: number;
587
+ readonly baseRef: Option.Option<string>;
588
+ }
589
+
590
+ /**
591
+ * Run the three checks. Succeeds with a {@link MigrationDriftReport};
592
+ * fails with a {@link MigrationDriftError} carrying every issue found.
593
+ *
594
+ * @example
595
+ * ```ts
596
+ * // An application's `db:migration-drift` task, run with `NodeRuntime.runMain`.
597
+ * import * as NodeServices from "@effect/platform-node/NodeServices";
598
+ * import { checkMigrationDrift, resolveBaseRef } from "@thomasfosterau/effect-drizzle-ext-pg/tooling";
599
+ * import { integer, pgTable } from "drizzle-orm/pg-core";
600
+ * import * as Effect from "effect/Effect";
601
+ * import * as Option from "effect/Option";
602
+ * import * as path from "node:path";
603
+ *
604
+ * const schema = { widget: pgTable("widget", { id: integer().primaryKey() }) };
605
+ * const repoRoot = process.cwd();
606
+ * const base = Option.map(resolveBaseRef(process.env), (ref) => ({
607
+ * ref,
608
+ * migrationsPath: "packages/database/migrations",
609
+ * cwd: repoRoot,
610
+ * }));
611
+ * export const program = checkMigrationDrift({
612
+ * schema,
613
+ * migrationsDir: path.join(repoRoot, "packages/database/migrations"),
614
+ * base,
615
+ * }).pipe(Effect.provide(NodeServices.layer));
616
+ * ```
617
+ */
618
+ export const checkMigrationDrift = (
619
+ options: MigrationDriftOptions,
620
+ ): Effect.Effect<
621
+ MigrationDriftReport,
622
+ MigrationDriftError | MalformedSnapshot | SchemaDiffError | PlatformError,
623
+ FileSystem.FileSystem | Path.Path | ChildProcessSpawner
624
+ > =>
625
+ Effect.gen(function* () {
626
+ const { dirs, entries } = yield* readLocal(options.migrationsDir);
627
+ const base = yield* Effect.transposeOption(
628
+ Option.map(options.base ?? Option.none(), readBaseMigrations),
629
+ );
630
+ const structural = [
631
+ ...checkDagIntegrity(entries),
632
+ ...Option.match(base, { onNone: () => [], onSome: (b) => checkBaseAncestry(entries, b) }),
633
+ ];
634
+ const latest = Arr.findFirst(entries.toReversed(), (entry) => dirs.includes(entry.dir));
635
+ const issues = Arr.isReadonlyArrayNonEmpty(structural)
636
+ ? structural
637
+ : yield* checkZeroDrift(options.schema, entries, latest, options.migrationsDir);
638
+ if (Arr.isReadonlyArrayNonEmpty(issues)) return yield* new MigrationDriftError({ issues });
639
+ return { snapshots: entries.length, baseRef: Option.map(base, (b) => b.ref) };
640
+ });