/** * Phase 4c — Schema diff engine. * * Given an old `Snapshot` (or `null` for "no schema yet") and a next * `Snapshot`, produce a deterministic ordered list of `Change` entries. * The output feeds Agent A's `emitChange(change, provider)` to produce * the migration SQL body. * * # Rename policy * * This engine NEVER emits `rename-table` or `rename-column` — Appendix D.1 * of the RFC and the team plan §7 are explicit: rename auto-detection is * dangerous (cost of a false positive = silent data loss across an * ADD+DROP pair). The CLI (Agent E) prompts the user to reinterpret * consecutive drop+add pairs as renames; only AFTER user confirmation * are `rename-*` Change entries inserted (with `origin: "user-confirmed"`). * * # Determinism * * Two identical inputs MUST produce byte-identical `Change[]` across * runs. We achieve this with: * 1. Alphabetical sort at every partition step. * 2. Exhaustive ordering rules below — no "depends on iteration order". * * # Emit order (cross-table) * * 1. All `drop-index` sort by (resourceName, indexName) * 2. All `drop-table` sort by resourceName * 3. All `create-table` sort by resourceName (indexes included inline) * 4. Per-kept-table, in resourceName order: * a. drop-column * b. alter-column-type * c. alter-column-nullable * d. alter-column-default * e. add-column * 5. All `add-index` sort by (resourceName, indexName) * * # Rationale for this order * * Drop-index before drop-table: PG and MySQL allow `DROP TABLE` to * implicitly drop dependent indexes, but being explicit makes the * migration reversible when a reviewer edits the plan. Drop-column * before alter: dropping a column before altering its siblings avoids * "column not found" errors when the same table's shape is fluid. * Add-index last: the column(s) the index targets must already exist, * and a freshly-created table may also introduce new indexes — those * are emitted as part of `create-table` so this phase covers only * indexes added to tables that existed on both sides. * * # v1 non-goals * * - Rename auto-detection (always drop+add — see above) * - FK / CHECK / ENUM / composite PK diffing (not in scope) * - Cross-provider diff (thrown — a caller bug) * * References: * docs/bun/phase-4c-team-plan.md §3 Agent B * docs/rfcs/0001-db-resource-layer.md §7 (risk: rename false-positive) */ import type { ParsedResource } from "../parser"; import type { Change, DdlDefault, DdlIndex, DdlResource, Snapshot, } from "./types"; import { snapshotFromResources } from "./snapshot"; // ============================================ // Public API // ============================================ /** * Compute the ordered list of `Change` entries from `old` → `next`. * * `old === null` means "no schema has been applied yet" — every resource * in `next` becomes a `create-table`. * * `next === null` is NOT a valid input — it would mean "delete everything", * which is dangerous enough that we force the caller to build an empty * Snapshot (`{ version: 1, provider, resources: [], generatedAt }`) to * express it explicitly. This surfaces in tests (`diff.test.ts` TC-3). * * @throws TypeError if the two snapshots declare different providers — * a cross-provider diff is meaningless and always a caller bug. */ export function diffSnapshots(old: Snapshot | null, next: Snapshot): Change[] { if (next === null || next === undefined) { throw new TypeError( "diffSnapshots: `next` must be a Snapshot (use an empty-resources snapshot to express 'drop all')" ); } if (old !== null && old.provider !== next.provider) { throw new TypeError( `Cross-provider diff: old is "${old.provider}", next is "${next.provider}". Snapshots from different SQL providers cannot be diffed — migrating across providers requires a manual dump+load, not a diff.` ); } // Bucket every change by kind so we can sort within each kind and then // assemble in the canonical cross-table order at the end. const dropIndexes: Change[] = []; const dropTables: Change[] = []; const createTables: Change[] = []; // Per-kept-table changes — keyed by resourceName then ordered sub-lists // to maintain the within-table order invariant. const keptTables = new Map< string, { dropColumns: Change[]; alterColumnTypes: Change[]; alterColumnNullables: Change[]; alterColumnDefaults: Change[]; addColumns: Change[]; } >(); const addIndexes: Change[] = []; const oldByName = new Map((old?.resources ?? []).map((r) => [r.name, r])); const newByName = new Map(next.resources.map((r) => [r.name, r])); // Resources that disappear entirely → drop indexes, drop table. for (const [name, resource] of oldByName) { if (newByName.has(name)) continue; for (const idx of resource.indexes) { dropIndexes.push({ kind: "drop-index", resourceName: name, indexName: idx.name }); } dropTables.push({ kind: "drop-table", resourceName: name }); } // Resources that are new → create-table (indexes inline — Agent A's // `emitCreateTable` is responsible for emitting CREATE INDEX alongside). for (const [name, resource] of newByName) { if (oldByName.has(name)) continue; createTables.push({ kind: "create-table", resource }); } // Resources on both sides → drill into fields + indexes. for (const [name, newResource] of newByName) { const oldResource = oldByName.get(name); if (!oldResource) continue; const bucket = ensureKeptBucket(keptTables, name); diffFields(oldResource, newResource, bucket); const { dropped, added } = diffIndexes(oldResource, newResource); for (const idx of dropped) { dropIndexes.push({ kind: "drop-index", resourceName: name, indexName: idx.name }); } for (const idx of added) { addIndexes.push({ kind: "add-index", resourceName: name, index: idx }); } } // Sort each kind lexicographically. Sort is `stable` in Bun/Node (V8) so // ties fall to original insertion order; we never rely on that — every // bucket enters pre-sorted by the iteration structure above, and the // sort comparators below make the final result fully total. sortChanges(dropIndexes, (c) => indexKey(c)); sortChanges(dropTables, (c) => resourceKey(c)); sortChanges(createTables, (c) => resourceKey(c)); sortChanges(addIndexes, (c) => indexKey(c)); // Assemble in canonical order. const out: Change[] = []; out.push(...dropIndexes); out.push(...dropTables); out.push(...createTables); // Per-kept-table inner changes — iterate in alphabetical table order. const keptTableNames = [...keptTables.keys()].sort(); for (const name of keptTableNames) { const bucket = keptTables.get(name)!; sortChanges(bucket.dropColumns, fieldNameKey); sortChanges(bucket.alterColumnTypes, fieldNameKey); sortChanges(bucket.alterColumnNullables, fieldNameKey); sortChanges(bucket.alterColumnDefaults, fieldNameKey); sortChanges(bucket.addColumns, addColumnFieldNameKey); out.push(...bucket.dropColumns); out.push(...bucket.alterColumnTypes); out.push(...bucket.alterColumnNullables); out.push(...bucket.alterColumnDefaults); out.push(...bucket.addColumns); } out.push(...addIndexes); return out; } /** * Convenience: compute `next` snapshot from `ParsedResource[]` and diff it * against an already-applied snapshot. Returns both so callers can write * the next snapshot back to disk after `mandu db apply` succeeds. */ export function diffResources( resources: readonly ParsedResource[], applied: Snapshot | null ): { snapshot: Snapshot; changes: Change[] } { const snapshot = snapshotFromResources(resources); const changes = diffSnapshots(applied, snapshot); return { snapshot, changes }; } // ============================================ // Internals — per-resource field diff // ============================================ function diffFields( oldResource: DdlResource, newResource: DdlResource, bucket: { dropColumns: Change[]; alterColumnTypes: Change[]; alterColumnNullables: Change[]; alterColumnDefaults: Change[]; addColumns: Change[]; } ): void { const oldFields = new Map(oldResource.fields.map((f) => [f.name, f])); const newFields = new Map(newResource.fields.map((f) => [f.name, f])); const resourceName = newResource.name; // Fields removed in `next`. for (const [name] of oldFields) { if (newFields.has(name)) continue; bucket.dropColumns.push({ kind: "drop-column", resourceName, fieldName: name }); } // Fields added in `next`. for (const [name, field] of newFields) { if (oldFields.has(name)) continue; bucket.addColumns.push({ kind: "add-column", resourceName, field }); } // Fields present on both sides — inspect for type / nullable / default // divergence. Diff engine is intentionally coarse here: one Change per // dimension so the CLI/emit can surface each reason separately. for (const [name, newField] of newFields) { const oldField = oldFields.get(name); if (!oldField) continue; if (oldField.type !== newField.type) { bucket.alterColumnTypes.push({ kind: "alter-column-type", resourceName, fieldName: name, fromType: oldField.type, toType: newField.type, stub: true, }); } if (oldField.nullable !== newField.nullable) { bucket.alterColumnNullables.push({ kind: "alter-column-nullable", resourceName, fieldName: name, nullable: newField.nullable, }); } if (!defaultsEqual(oldField.default, newField.default)) { const change: Change = { kind: "alter-column-default", resourceName, fieldName: name, }; if (newField.default !== undefined) { change.default = newField.default; } bucket.alterColumnDefaults.push(change); } } } function ensureKeptBucket( keptTables: Map< string, { dropColumns: Change[]; alterColumnTypes: Change[]; alterColumnNullables: Change[]; alterColumnDefaults: Change[]; addColumns: Change[]; } >, name: string ) { let bucket = keptTables.get(name); if (!bucket) { bucket = { dropColumns: [], alterColumnTypes: [], alterColumnNullables: [], alterColumnDefaults: [], addColumns: [], }; keptTables.set(name, bucket); } return bucket; } // ============================================ // Internals — index diff // ============================================ function diffIndexes( oldResource: DdlResource, newResource: DdlResource ): { dropped: DdlIndex[]; added: DdlIndex[] } { const oldByName = new Map(oldResource.indexes.map((i) => [i.name, i])); const newByName = new Map(newResource.indexes.map((i) => [i.name, i])); const dropped: DdlIndex[] = []; const added: DdlIndex[] = []; for (const [name, idx] of oldByName) { if (!newByName.has(name)) dropped.push(idx); } for (const [name, idx] of newByName) { const prior = oldByName.get(name); if (!prior) { added.push(idx); continue; } // Same name, different shape → drop + add (v1; see `rename-*` policy). if (!indexShapeEqual(prior, idx)) { dropped.push(prior); added.push(idx); } } return { dropped, added }; } function indexShapeEqual(a: DdlIndex, b: DdlIndex): boolean { if (a.unique !== b.unique) return false; if (a.fields.length !== b.fields.length) return false; for (let i = 0; i < a.fields.length; i++) { if (a.fields[i] !== b.fields[i]) return false; } return true; } // ============================================ // Internals — default equality // ============================================ /** * Defaults compare "by value". We stringify the discriminated union and * compare the strings — cheap, correct, and side-steps JavaScript's * structural-equality blind spot. Two `undefined`s compare equal. */ function defaultsEqual(a: DdlDefault | undefined, b: DdlDefault | undefined): boolean { if (a === undefined && b === undefined) return true; if (a === undefined || b === undefined) return false; return JSON.stringify(a) === JSON.stringify(b); } // ============================================ // Internals — sort helpers // ============================================ function sortChanges(list: Change[], key: (c: Change) => string): void { list.sort((a, b) => { const ka = key(a); const kb = key(b); return ka < kb ? -1 : ka > kb ? 1 : 0; }); } function indexKey(c: Change): string { // Covers drop-index + add-index. if (c.kind === "drop-index") return `${c.resourceName}\u0000${c.indexName}`; if (c.kind === "add-index") return `${c.resourceName}\u0000${c.index.name}`; return ""; } function resourceKey(c: Change): string { if (c.kind === "drop-table") return c.resourceName; if (c.kind === "create-table") return c.resource.name; return ""; } function fieldNameKey(c: Change): string { // Covers drop-column / alter-column-*. if (c.kind === "drop-column" || c.kind === "alter-column-type" || c.kind === "alter-column-nullable" || c.kind === "alter-column-default") { return c.fieldName; } return ""; } function addColumnFieldNameKey(c: Change): string { return c.kind === "add-column" ? c.field.name : ""; }