Skip to content

Commit e6d4537

Browse files
dmealingclaude
andcommitted
test/docs(#241): prove mixed-migration splice; qualify D1 cascade production-safety note (#243)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJRi8FtEW24z9HKGUL1xh8
1 parent 3262910 commit e6d4537

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

docs/features/migrations-and-drift.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ expected (target) schemas' foreign-key graphs**, so it also covers the case a
8686
target-schema-only check would miss: a single migration that both rebuilds a
8787
referenced table *and* drops the referencing foreign key in the same run.
8888

89+
One known exception: a table with a **dependent projection/view** that gets rebuilt
90+
by the cascade (or by any CHECK/FK/enum-values rebuild) may need that view
91+
hand-managed — the diff layer only auto-drops/recreates a dependent view for
92+
column-altering changes, not the CHECK/FK/enum-values class the cascade exists to
93+
handle. Tracked as [#243](https://github.com/metaobjectsdev/metaobjects/issues/243).
94+
The common case — no dependent view on a rebuilt table — applies cleanly as described
95+
above.
96+
8997
The one case still hand-written: a **multi-table foreign-key cycle** (table A
9098
references B references … references A, two or more tables). A cycle has no
9199
parents-first rebuild order, so `meta migrate --dialect d1` still **refuses at

server/typescript/packages/migrate-ts/test/integration/d1-cascade.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
* (3) multiple children of one parent, (4) self-referential, (5) the #226 residual
2020
* gap (rebuild parent AND drop the child's FK in one migration), (6) a multi-table
2121
* A↔B cycle → refuse (emitter-level), (7) a no-referenced-rebuild → byte-identical to
22-
* the pre-#241 path.
22+
* the pre-#241 path, (8) a MIXED migration — cascade + unrelated create-table/add-column
23+
* in the same batch, proving `renderD1`'s splice with a NON-empty `rest` (every other
24+
* scenario has every change on an affected table, so `rest.up` is always empty there).
2325
*/
2426
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2527
import { mkdtempSync, rmSync } from "node:fs";
@@ -496,4 +498,89 @@ describe("#241 D1 FK-cascade — real-engine gate (libSQL, one transaction = rem
496498
expect(String((await queryRows("SELECT level FROM logs"))[0]!.level)).toBe("info");
497499
expect(await reDiffChanges(expected2)).toEqual([]);
498500
});
501+
502+
// Scenario 8 — mixed migration: cascade + unrelated native changes → non-empty `rest` ---
503+
test("mixed migration: cascade (referenced parent) PLUS unrelated create-table/add-column flow through `rest`, re-diff EMPTY", async () => {
504+
const parentV = (withEnum: boolean): unknown =>
505+
entity("Parent", [
506+
{ "field.long": { name: "id" } },
507+
{ "field.string": { name: "name", "@required": true } },
508+
...(withEnum ? [ENUM_KIND] : []),
509+
ID_PK,
510+
]);
511+
const child = entity("Note", [
512+
{ "field.long": { name: "id" } },
513+
{ "field.long": { name: "parentId", "@required": true } },
514+
ID_PK,
515+
{ "identity.reference": { name: "ref_parent", "@fields": ["parentId"], "@references": "Parent" } },
516+
]);
517+
// Unrelated to the Parent/Note cascade set — no FK to or from either. Exists in
518+
// BOTH versions; v2 adds a column, exercising `rest`'s add-column path.
519+
const otherV = (withNote: boolean): unknown =>
520+
entity("Other", [
521+
{ "field.long": { name: "id" } },
522+
{ "field.string": { name: "tag", "@required": true } },
523+
...(withNote ? [{ "field.string": { name: "note" } }] : []),
524+
ID_PK,
525+
]);
526+
// Brand-new in v2 — exercises `rest`'s create-table path.
527+
const widget = entity("Widget", [
528+
{ "field.long": { name: "id" } },
529+
{ "field.string": { name: "label", "@required": true } },
530+
ID_PK,
531+
]);
532+
const v1 = rootMeta([parentV(false), child, otherV(false)]);
533+
const v2 = rootMeta([parentV(true), child, otherV(true), widget]);
534+
535+
await applyV1(v1);
536+
await execEach([
537+
"INSERT INTO parents (id, name) VALUES (1, 'root')",
538+
"INSERT INTO notes (id, parent_id) VALUES (1, 1)",
539+
"INSERT INTO others (id, tag) VALUES (1, 'x')",
540+
]);
541+
542+
const { res, em, changes, expected2 } = await migrateV2(v2);
543+
expect(res.ok).toBe(true);
544+
545+
// The construction under test: an FK-referenced-parent rebuild (cascade) PLUS
546+
// unrelated native changes on tables outside the affected set — proves
547+
// `nonAffected`/`rest` is non-empty for this migration.
548+
expect(changes.some((c) => c.kind === "add-check" && c.table === "parents")).toBe(true);
549+
expect(changes.some((c) => c.kind === "create-table" && c.table.name === "widgets")).toBe(true);
550+
expect(
551+
changes.some((c) => c.kind === "add-column" && c.table === "others" && c.column.name === "note"),
552+
).toBe(true);
553+
554+
// The emitted `up` contains BOTH the cascade block (temp `__f_` tables for the
555+
// referenced parent + its referrer) AND the spliced-in `rest` (the unrelated
556+
// create-table and add-column) — proving the splice really ran with a
557+
// non-empty `rest`, not the trivially-empty case every other scenario exercises.
558+
expect(em.up).toContain('CREATE TABLE "__f_parents"');
559+
expect(em.up).toContain('CREATE TABLE "__f_notes"');
560+
expect(em.up).toContain('CREATE TABLE "widgets"');
561+
expect(em.up).toContain('ALTER TABLE "others" ADD COLUMN "note"');
562+
563+
// Seeded row data intact — across the cascaded tables AND the `rest`-altered table.
564+
const parents = await queryRows("SELECT id, name, kind FROM parents ORDER BY id");
565+
expect(parents.length).toBe(1);
566+
expect(String(parents[0]!.name)).toBe("root");
567+
expect(parents[0]!.kind).toBeNull();
568+
const notes = await queryRows("SELECT id, parent_id FROM notes ORDER BY id");
569+
expect(notes.length).toBe(1);
570+
expect(Number(notes[0]!.parent_id)).toBe(1);
571+
const others = await queryRows("SELECT id, tag, note FROM others ORDER BY id");
572+
expect(others.length).toBe(1);
573+
expect(String(others[0]!.tag)).toBe("x");
574+
expect(others[0]!.note).toBeNull();
575+
576+
// The brand-new native table is live (created AFTER the cascade, per the splice order).
577+
await execEach(["INSERT INTO widgets (id, label) VALUES (1, 'w1')"]);
578+
expect((await queryRows("SELECT label FROM widgets"))[0]!.label).toBe("w1");
579+
580+
// The rebuilt FK is LIVE, not silently dropped.
581+
expect(await insertIsRejected("INSERT INTO notes (id, parent_id) VALUES (2, 999)")).toBe(true);
582+
583+
// Convergence — the whole point: a mixed migration re-diffs EMPTY too.
584+
expect(await reDiffChanges(expected2)).toEqual([]);
585+
});
499586
});

0 commit comments

Comments
 (0)