import type { Change, SchemaSnapshot, TableDescriptor } from "../types.js";
import {
renderCreateTable,
renderCreateIndex,
renderCreateView,
computeCarryColumns,
changeTable,
quote,
} from "./sqlite.js";
import { buildFkEdges, unionEdges, affectedSet, topoOrder } from "./fk-graph.js";
/**
* Prefix for the transient rebuild tables the cascade recipe creates
* (`__f_
`). Distinct from SQLite's own `__new_` recreate temp so
* the two recipes never collide.
*/
const TEMP_TABLE_PREFIX = "__f_";
/**
* Result of the D1 FK-cascade emitter: either a validated cascade (with the
* affected-table set so the dispatcher can partition the remaining changes) or a
* refusal because the affected tables form a foreign-key cycle.
*/
export type D1CascadeResult =
| { up: string; downWarning: string; affected: Set; handledViews: Set }
| { refuseCycle: string[] };
/**
* Emit the D1-legal FK-cascade rebuild for a set of recreated tables and every
* table transitively referencing them.
*
* On remote D1 the plain SQLite recreate-and-copy recipe fails: its `PRAGMA
* foreign_keys = OFF` is a no-op inside D1's implicit transaction, so dropping a
* referenced table raises "FOREIGN KEY constraint failed". This emitter instead
* rebuilds the WHOLE affected set inside one implicit transaction, deferring FK
* enforcement to commit via `PRAGMA defer_foreign_keys = ON`:
*
* 1. `PRAGMA defer_foreign_keys = ON;`
* 2. CREATE `__f_` for each affected `t` (FKs whose target is also affected
* are rewritten to the target's temp name).
* 3. INSERT the carried columns from each old table into its temp.
* 4. DROP the old tables referrers-first (reverse topological order).
* 5. RENAME each temp to the real name parents-first (topological order),
* recreating that table's indexes immediately after its rename.
*
* No `BEGIN/COMMIT` and no `foreign_keys = OFF/ON` bracket: D1 runs the file in
* one implicit transaction and the safety pass would strip the former anyway.
*
* `renderD1` only calls this when `actualSchema` is present, so it is required.
*/
export function emitD1Cascade(
changes: readonly Change[],
expectedSchema: SchemaSnapshot,
actualSchema: SchemaSnapshot,
recreatedTables: ReadonlySet,
): D1CascadeResult {
const edges = unionEdges(buildFkEdges(expectedSchema), buildFkEdges(actualSchema));
const affectedAll = affectedSet(recreatedTables, edges);
// Restrict to tables that EXIST in the actual DB. `affectedSet` walks the
// expected edges too, so a brand-new table (created this migration) with an FK
// into a rebuilt table is pulled in as a referrer — but it cannot be
// INSERT...SELECTed or DROPped. New tables flow through the native "rest" path,
// emitted after the cascade so the renamed parent already exists.
const actualNames = new Set(actualSchema.tables.map((t) => t.name));
const affected = new Set([...affectedAll].filter((t) => actualNames.has(t)));
const { order, cycle } = topoOrder(affected, edges);
if (cycle) return { refuseCycle: cycle };
const expectedByName = new Map(expectedSchema.tables.map((t) => [t.name, t] as const));
const expectedTable = (name: string): TableDescriptor => {
const d = expectedByName.get(name);
if (!d) throw new Error(`expectedSchema missing table "${name}" needed for D1 FK-cascade`);
return d;
};
const temp = (name: string): string => TEMP_TABLE_PREFIX + name;
// Views reading any affected table are stranded by the DROP/RENAME dance (a plain
// recreate-and-copy of a base table breaks a dependent view mid-transaction, #243).
// Drop them before the rebuild and recreate them after every table is back at its
// final name. The dispatcher filters any diff-injected view change for these from the
// native "rest" so they are not emitted twice / out of order.
const affectedViews = expectedSchema.views.filter((v) =>
(v.dependsOn ?? []).some((t) => affected.has(t)),
);
const handledViews = new Set(affectedViews.map((v) => v.name));
const stmts: string[] = [];
for (const v of affectedViews) stmts.push(`DROP VIEW IF EXISTS ${quote(v.name)};`);
// Defer FK enforcement to commit — the D1-legal alternative to the (no-op) OFF bracket.
stmts.push("PRAGMA defer_foreign_keys = ON;");
// CREATE temps. FK targets inside the affected set are rewritten to their temp
// name (forward-refs are fine — SQLite resolves FK targets lazily); targets
// outside the set keep their real name.
for (const t of order) {
const src = expectedTable(t);
const clone: TableDescriptor = {
...src,
name: temp(t),
foreignKeys: src.foreignKeys.map((fk) =>
affected.has(fk.refTable) ? { ...fk, refTable: temp(fk.refTable) } : fk,
),
};
stmts.push(renderCreateTable(clone));
}
// INSERT carried columns. A referrer-only table has no changes → carry every
// expected column (computeCarryColumns([], …)).
for (const t of order) {
const tableChanges = changes.filter((c) => changeTable(c) === t);
const { insertCols, selectCols } = computeCarryColumns(tableChanges, expectedTable(t));
if (insertCols.length > 0) {
stmts.push(
`INSERT INTO ${quote(temp(t))} (${insertCols.map(quote).join(", ")}) ` +
`SELECT ${selectCols.map(quote).join(", ")} FROM ${quote(t)};`,
);
}
}
// DROP referrers-first (reverse topological order).
for (const t of [...order].reverse()) {
stmts.push(`DROP TABLE ${quote(t)};`);
}
// RENAME parents-first; recreate each table's indexes immediately after rename.
for (const t of order) {
stmts.push(`ALTER TABLE ${quote(temp(t))} RENAME TO ${quote(t)};`);
for (const ix of expectedTable(t).indexes) {
stmts.push(renderCreateIndex(t, ix));
}
}
// Recreate the dependent views now that every affected table is back at its final name.
for (const v of affectedViews) stmts.push(renderCreateView(v));
const up = stmts.join("\n\n");
// Best-effort down, mirroring renderRecreate's WARNING block.
const downWarning = [
`-- WARNING: SQLite recreate-and-copy down migration is best-effort.`,
`-- Reverse the column type/nullable/default changes by hand if needed.`,
`-- Dropped data cannot be restored.`,
].join("\n");
return { up, downWarning, affected, handledViews };
}