Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,26 @@ existing `--allow drop-table` policy, so nothing drops without explicit opt-in.
Previously-generated broken `*.queries.ts` / `*.routes.ts` files are **not** auto-pruned
(`meta gen` never deletes existing files) — remove them by hand.

### Fixed — migrate emits constraint/index DROPs before DROP COLUMN so referenced-column drops apply (#255)

**npm-only** (`migrate-ts`; PyPI / NuGet / Maven Central unchanged — schema migrations are
TS-owned, ADR-0015). The SQL emitter's stage ordering ran `DROP COLUMN` before `DROP CONSTRAINT`
(foreign key / check) and `DROP INDEX`, so dropping a column that a still-present foreign key
referenced — or that a still-present index backed — produced an **un-appliable** migration
(`cannot drop column … because other objects depend on it`). Both the Postgres and SQLite
emitters now hoist every constraint/index drop ahead of column mutation: `drop-fk`/`drop-check`
first, then `drop-index` (a foreign key depends on the unique/PK index backing its target, so the
FK must be dropped before that index), then the column ops; the matching adds
(`add-fk`/`add-check`/`add-index`) stay after column mutation (they reference columns that must
already exist). Reproduced against a real Postgres before fixing (emit → apply → introspect →
re-diff-empty), including the combined FK + backing-index + column-drop case.

Byte-identical for any migration that contains none of `drop-fk` / `drop-check` / `drop-index`. A
migration that combines one of those drops with a column change now emits the same statements in
the corrected order — **re-review any committed-but-not-yet-applied migration file** that drops a
foreign key / check / index alongside a column, since its statement order changes (and will now
apply where it previously failed).

## [0.20.9] — 2026-07-28

**npm-only** — `migrate-ts` + `codegen-ts` (schema migrations and projection-view codegen
Expand Down
33 changes: 27 additions & 6 deletions server/typescript/packages/migrate-ts/src/emit/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,39 @@ import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
import { renderFingerprintMarker, viewFingerprint } from "../view-fingerprint.js";
import { viewReplaceIsLegal } from "../view-column-types.js";

// Stages run low → high. drop-view + drop-fk run BEFORE drop-table so a view
// that depends on a soon-to-be-dropped table is removed first. create-view
// runs AFTER add-fk so the view can reference the new schema in full.
// Stages run low → high. drop-view runs BEFORE drop-table so a view that
// depends on a soon-to-be-dropped table is removed first. create-view runs
// AFTER add-fk so the view can reference the new schema in full.
//
// #255: a constraint/index DROP and its ADD counterpart share the same kind
// but need OPPOSITE ordering relative to column mutation — a drop must run
// BEFORE the column change (the constraint/index must be gone before its
// column is dropped, or Postgres refuses `DROP COLUMN` with "other objects
// depend on it" — this applies just as much to an index backing a UNIQUE/FK
// target as it does to the FK/CHECK constraint itself), while an add must run
// AFTER (the column it references must already exist). One stage can't
// satisfy both, so ALL drops — drop-fk/drop-check/drop-index — are hoisted
// ahead of any column mutation; their ADD counterparts (add-fk/add-check/
// add-index) stay at their later stages.
//
// Within that "drops" group, drop-fk/drop-check must ALSO run BEFORE
// drop-index: an FK constraint depends on the unique/PK index backing its
// target column (that's how Postgres enforces the target must be unique), so
// dropping the index first fails with the same "other objects depend on it"
// class of error, one level removed — "constraint … depends on index …".
// drop-index therefore gets its own stage (1.5) strictly between drop-fk/
// drop-check/create-table (1) and column mutation (2).
const STAGE_ORDER: Record<Change["kind"], number> = {
"drop-view": 0,
"drop-fk": 1, "drop-check": 1,
"create-table": 1,
"drop-index": 1.5,
"add-column": 2, "drop-column": 2,
"change-column-type": 2, "change-column-nullable": 2, "change-column-default": 2,
"rename-column": 3, "rename-table": 3,
"add-index": 4, "drop-index": 4,
"add-fk": 5, "drop-fk": 5,
"add-check": 5, "drop-check": 5,
"add-index": 4,
"add-fk": 5,
"add-check": 5,
"drop-table": 6,
"create-view": 7, "replace-view": 7,
};
Expand Down
32 changes: 29 additions & 3 deletions server/typescript/packages/migrate-ts/src/emit/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,45 @@ export interface CarryColumns { insertCols: string[]; selectCols: string[]; }

// Stage ordering similar to PG; recreate-and-copy bundles get inserted
// at their first triggering change's position in Task 23.
//
// #255: a constraint/index DROP and its ADD counterpart share the same kind
// but need OPPOSITE ordering relative to column mutation — a drop must run
// BEFORE the column change (the constraint/index must be gone before its
// column is dropped, or the DDL fails on the referenced-column dependency —
// this applies just as much to an index backing a UNIQUE/FK target as it does
// to the FK/CHECK constraint itself), while an add must run AFTER (the column
// it references must already exist). One stage can't satisfy both, so ALL
// drops — drop-fk/drop-check/drop-index — are hoisted ahead of any column
// mutation; their ADD counterparts (add-fk/add-check/add-index) stay at their
// later stages.
//
// Within that "drops" group, drop-fk/drop-check must ALSO run BEFORE
// drop-index — mirrors postgres.ts: an FK depends on the unique/PK index
// backing its target column, so dropping the index first can fail on that
// dependency, one level removed. drop-index gets its own stage (1.5) strictly
// between drop-fk/drop-check/create-table (1) and column mutation (2).
// (drop-fk/drop-check are always recreate-triggering on SQLite — see
// RECREATE_TRIGGERING_KINDS below — so this mainly orders a drop-fk's
// table-recreate ahead of a native drop-column on a DIFFERENT table it once
// referenced; within the SAME table's recreate bundle, tableChanges order
// doesn't affect the emitted recipe. drop-index is NOT recreate-triggering —
// it's a plain `DROP INDEX`, so this ordering directly controls emission
// order among native statements.)
const STAGE_ORDER: Record<Change["kind"], number> = {
// drop-view runs FIRST (mirrors postgres): a view that depends on a table about
// to be recreated-and-copied must be dropped before the DROP TABLE / RENAME, or
// SQLite's rename re-parses the dependent view and can error mid-recreate. The
// diff's Pass 2c injects exactly this drop(before)/create(after) pair.
"drop-view": 0,
"drop-fk": 1, "drop-check": 1,
"create-table": 1,
"drop-index": 1.5,
"add-column": 2, "drop-column": 2,
"change-column-type": 2, "change-column-nullable": 2, "change-column-default": 2,
"rename-column": 3, "rename-table": 3,
"add-index": 4, "drop-index": 4,
"add-fk": 5, "drop-fk": 5,
"add-check": 5, "drop-check": 5,
"add-index": 4,
"add-fk": 5,
"add-check": 5,
"drop-table": 6,
// create-view / replace-view run LAST — after every table change the view reads.
"create-view": 99, "replace-view": 99,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Real-Postgres gate for #255 — "a table whose column is referenced by
* another table's FK, where the metadata drops both the FK and the column."
*
* Root cause: the emitter's STAGE_ORDER put `drop-column` (stage 2) before
* `drop-fk`/`drop-check`/`drop-index` (originally stage 5/5/4), so the
* emitted UP SQL issued `ALTER TABLE "programs" DROP COLUMN "code"` while
* `weeks_program_code_fk` — another table's FK constraint referencing that
* column — AND `uniqueCode` — the UNIQUE index backing that same column
* (Postgres requires an FK target to be unique) — both still existed.
* Postgres refuses: "cannot drop column code of table programs because other
* objects depend on it." The fix hoists ALL drops that can have an external
* dependent — `drop-fk` / `drop-check` / `drop-index` — ahead of column
* mutation, while their ADD counterparts (`add-fk` / `add-check` /
* `add-index`) stay at their later, post-column stage. Within that group,
* `drop-fk`/`drop-check` ALSO run before `drop-index`: the FK constraint
* itself depends on the unique index backing its target column, so dropping
* the index first fails one level removed ("cannot drop index … because
* other objects depend on it" / "constraint … depends on index …"). This
* test proves the reordered SQL actually applies against a real engine, for
* the COMBINED scenario (a column that is BOTH FK-referenced AND
* index-backed, with the metadata dropping the FK, the index, and the column
* all in one change-set) — a unit assertion on statement order alone is not
* sufficient evidence; see emit-postgres.test.ts / emit-sqlite.test.ts for
* that half.
*
* Gated on MIGRATE_TS_PG_URL, like every other pg integration test in this
* package. Skips cleanly when unset.
*/

import { test, expect, beforeAll, afterAll, describe } from "bun:test";
import { Pool } from "pg";
import { Kysely, PostgresDialect, sql } from "kysely";
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
import { buildExpectedSchema } from "../../src/expected-schema.js";
import { introspectPostgres } from "../../src/introspect/postgres.js";
import { diff } from "../../src/diff/index.js";
import { emit } from "../../src/emit/index.js";

const PG_URL = process.env["MIGRATE_TS_PG_URL"];
const realDescribe = PG_URL ? describe : describe.skip;

// V1: Program (id PK, code UNIQUE) + Week (id PK, programCode FK -> Program.code).
const V1 = JSON.stringify({
"metadata.root": {
package: "acme",
children: [
{
"object.entity": {
name: "Program",
children: [
{ "source.rdb": {} },
{ "field.long": { name: "id" } },
{ "field.string": { name: "code", "@required": true } },
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
{ "identity.secondary": { name: "uniqueCode", "@fields": ["code"] } },
],
},
},
{
"object.entity": {
name: "Week",
children: [
{ "source.rdb": {} },
{ "field.long": { name: "id" } },
{ "field.string": { name: "programCode", "@required": true } },
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
{ "identity.reference": { name: "ref_program", "@fields": ["programCode"], "@references": "Program.code" } },
],
},
},
],
},
});

// V2: Program drops `code` (+ its identity.secondary index); Week drops the
// FK (identity.reference) but keeps its own `programCode` column (now a
// plain, un-constrained column) — the exact shape that produces a drop-fk +
// drop-index + drop-column TRIPLE where the dropped column is both the FK's
// REFERENCED side and the index's target.
const V2 = JSON.stringify({
"metadata.root": {
package: "acme",
children: [
{
"object.entity": {
name: "Program",
children: [
{ "source.rdb": {} },
{ "field.long": { name: "id" } },
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
],
},
},
{
"object.entity": {
name: "Week",
children: [
{ "source.rdb": {} },
{ "field.long": { name: "id" } },
{ "field.string": { name: "programCode", "@required": true } },
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
],
},
},
],
},
});

let k: Kysely<Record<string, unknown>>;
let pool: Pool;

if (PG_URL) {
beforeAll(() => {
pool = new Pool({ connectionString: PG_URL });
k = new Kysely<Record<string, unknown>>({ dialect: new PostgresDialect({ pool }) });
});
afterAll(async () => {
await cleanup();
await k.destroy();
});
}

async function cleanup(): Promise<void> {
await sql.raw(`DROP TABLE IF EXISTS "weeks" CASCADE`).execute(k);
await sql.raw(`DROP TABLE IF EXISTS "programs" CASCADE`).execute(k);
}

async function applyRaw(sqlText: string): Promise<void> {
for (const stmt of sqlText.split(";").map((s) => s.trim()).filter(Boolean)) {
await sql.raw(stmt).execute(k);
}
}

async function loadRoot(json: string) {
return (await new MetaDataLoader().load([new InMemoryStringSource(json)])).root;
}

realDescribe("PG #255 — drop-fk/drop-check/drop-index before drop-column applies cleanly", () => {
test("dropping an FK's referenced+indexed column, the FK, and the index together: emitted SQL applies, re-diff empty", async () => {
await cleanup();

// Establish v1 (Program + Week, FK referencing Program.code) against a real, empty DB.
const v1Root = await loadRoot(V1);
const expected1 = buildExpectedSchema(v1Root, { dialect: "postgres" });
const actual0 = await introspectPostgres(k);
const initial = await diff({ expected: expected1, actual: actual0, dialect: "postgres" });
expect(initial.blocked).toEqual([]);
const emit1 = emit(initial.changes, { dialect: "postgres", expectedSchema: expected1 });
await applyRaw(emit1.up);

// Sanity: the FK is live and both columns exist.
await sql.raw(`INSERT INTO "programs" ("id", "code") VALUES (1, 'P1')`).execute(k);
await sql.raw(`INSERT INTO "weeks" ("id", "program_code") VALUES (1, 'P1')`).execute(k);

// Evolve to v2: drop Program.code (the FK's referenced + indexed
// column), the FK, AND the backing unique index, all in one change-set —
// the COMBINED scenario. Pre-fix, this fails on either the FK dependency
// or the auto-cascaded index (whichever the (buggy) order hits first);
// post-fix, both drops run before the column drop and it all applies.
const v2Root = await loadRoot(V2);
const expected2 = buildExpectedSchema(v2Root, { dialect: "postgres" });
const actual1 = await introspectPostgres(k);
const evolve = await diff({
expected: expected2, actual: actual1, dialect: "postgres",
allow: { dropColumn: true, dropFk: true, dropIndex: true },
});
expect(evolve.blocked).toEqual([]);
const kinds = evolve.changes.map((c) => c.kind).sort();
expect(kinds).toEqual(["drop-column", "drop-fk", "drop-index"]);

const emit2 = emit(evolve.changes, { dialect: "postgres", expectedSchema: expected2 });

// The fix under test: DROP CONSTRAINT must precede DROP INDEX (the FK
// depends on the index backing its target column) and both must precede
// DROP COLUMN, in the emitted UP SQL — or the applyRaw below fails
// against the real engine.
const idxDropConstraint = emit2.up.indexOf("DROP CONSTRAINT");
const idxDropIndex = emit2.up.indexOf("DROP INDEX");
const idxDropColumn = emit2.up.indexOf("DROP COLUMN");
expect(idxDropConstraint).toBeGreaterThanOrEqual(0);
expect(idxDropIndex).toBeGreaterThanOrEqual(0);
expect(idxDropColumn).toBeGreaterThanOrEqual(0);
expect(idxDropConstraint).toBeLessThan(idxDropIndex);
expect(idxDropIndex).toBeLessThan(idxDropColumn);

// The real-engine gate: pre-fix (drop-fk/drop-index tied at the same
// stage, both after drop-column, or drop-index ordered before drop-fk),
// this throws one of:
// `error: cannot drop column code of table programs because other
// objects depend on it` (FK still referencing the column)
// `error: index "uniqueCode" does not exist` (index auto-cascaded away
// by an earlier DROP COLUMN)
// `error: cannot drop index "uniqueCode" because other objects depend
// on it` / `constraint weeks_program_code_fk … depends on index …`
// (index dropped before the FK constraint that depends on it)
// Post-fix, it applies cleanly.
await applyRaw(emit2.up);

// Idempotence: re-introspecting must show no drift against expected2.
const followup = await diff({ expected: expected2, actual: await introspectPostgres(k), dialect: "postgres" });
if (followup.changes.length > 0) {
console.error("ROUND-TRIP FAILURE — a second `meta migrate` would emit:");
for (const c of followup.changes) console.error(" -", JSON.stringify(c));
}
expect(followup.changes).toEqual([]);
});
});
Loading
Loading