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
15 changes: 15 additions & 0 deletions docs/features/migrations-and-drift.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ preserves the PK through `RENAME COLUMN`) is not mistaken for a move. The read-o
primary-key drift rather than throwing. Auto-migrating the move (adding the
`add-primary-key` / `drop-primary-key` change kinds) is a documented future follow-up.

#### A legacy `serial` primary key (adoption-time refusal)

When migrating against a live Postgres database whose primary key is a legacy
`serial` / `bigserial` column — one carrying a live `nextval(...)` default — and the
metadata declares that `identity.primary` **without** `@generation`, the diff would
otherwise emit `ALTER COLUMN … DROP DEFAULT`. That is destructive: every insert that
omits the id starts failing. The missing `@generation` is genuinely ambiguous — it reads
identically whether the author simply never declared it (and wants to keep
auto-increment) or deliberately dropped it (to move the column onto app-assigned ids) —
so `meta migrate` **refuses rather than guessing**, the same detect-and-refuse arc as the
primary-key move above. Declare `@generation: increment` on the identity to keep the
sequence, or pass `--allow drop-identity-default` if removing auto-increment is
intentional. An identity that *does* declare `@generation: increment` never reaches this
gate (its default diff is skipped), so only the undeclared case fires.

### Java

Schema migrations for Java projects are owned by the **TypeScript toolchain**
Expand Down
2 changes: 1 addition & 1 deletion server/typescript/packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Flags:
- `--dialect sqlite|postgres|d1` — auto-detected from URL scheme; use `d1` for Cloudflare D1
- `--out-dir <path>` (default `./.metaobjects/migrations`)
- `--slug <name>` — required when changes are pending (e.g., `add-user-shipping`)
- `--allow <csv>` — destructive-change permissions: `drop-column,drop-table,type-change,drop-index,drop-fk,drop-check,drop-view,nullable-to-not-null`
- `--allow <csv>` — destructive-change permissions: `drop-column,drop-table,type-change,drop-index,drop-fk,drop-check,drop-view,drop-view-cascade,adopt-view,nullable-to-not-null,drop-identity-default`
- `--on-ambiguous abort|rename|drop-add` (default `abort`) — non-interactive
- `--dry-run` — print SQL pair to stdout, write nothing
- `--apply` — after writing migration files, immediately apply all pending migrations against the DB (runs `up.sql` for each unapplied entry, tracked in the migration ledger). Mutually exclusive with `--rollback`. Postgres and SQLite only (D1 uses `--apply` to invoke `wrangler d1 migrations apply` instead).
Expand Down
3 changes: 2 additions & 1 deletion server/typescript/packages/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ MIGRATE FLAGS:
--allow <csv> Comma-separated destructive-change permissions:
drop-column,drop-table,type-change,drop-index,drop-fk,
drop-check,drop-view,drop-view-cascade,
adopt-view,nullable-to-not-null
adopt-view,nullable-to-not-null,drop-identity-default
--on-ambiguous abort|rename|drop-add
How to handle ambiguous renames (default: abort)
--from-db Introspect live DB instead of using the committed snapshot
Expand Down Expand Up @@ -204,6 +204,7 @@ function allowFlagFor(kind: string): string {
case "drop-fk": return "drop-fk";
case "change-column-type": return "type-change";
case "change-column-nullable": return "nullable-to-not-null";
case "change-column-default": return "drop-identity-default";
default: return kind;
}
}
Expand Down
2 changes: 1 addition & 1 deletion server/typescript/packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ MIGRATE FLAGS:
--allow <csv> Comma-separated destructive-change permissions:
drop-column,drop-table,type-change,drop-index,drop-fk,
drop-check,drop-view,drop-view-cascade,
adopt-view,nullable-to-not-null
adopt-view,nullable-to-not-null,drop-identity-default
--on-ambiguous abort|rename|drop-add Default abort
--d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
--remote Target remote D1 instead of local (only with --dialect d1)
Expand Down
12 changes: 11 additions & 1 deletion server/typescript/packages/cli/src/lib/allow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
import type { AllowOptions, Change } from "@metaobjectsdev/migrate-ts";

// Map CLI allow tokens → migrate-ts AllowOptions field names.
const ALLOW_TOKEN_MAP: Record<string, keyof AllowOptions> = {
// Exported (not just module-local) so allow-tokens-pinned.test.ts can pin its
// key set against ALLOW_TOKENS (args.ts). ALLOW_TOKENS is the *validator* —
// this map is what actually *grants* the permission; a token present in
// ALLOW_TOKENS but missing here would pass validation and silently grant
// nothing, on a destructive operation.
export const ALLOW_TOKEN_MAP: Record<string, keyof AllowOptions> = {
"drop-column": "dropColumn",
"drop-table": "dropTable",
"type-change": "typeChange",
Expand All @@ -23,6 +28,11 @@ const ALLOW_TOKEN_MAP: Record<string, keyof AllowOptions> = {
// Gates overwriting an unfingerprinted (hand-written or pre-fingerprint) view.
"adopt-view": "adoptView",
"nullable-to-not-null": "nullableToNotNull",
// Gates dropping a live Postgres auto-sequence default (a legacy `serial`/
// `bigserial` PK's `nextval(...)`) when the metadata declares no
// @generation at all — ambiguous between "never declared it" and
// "deliberately removing auto-increment", so migrate refuses without it.
"drop-identity-default": "dropIdentityDefault",
};

/** Translate parsed `--allow` tokens into the migrate-ts `AllowOptions` shape. */
Expand Down
12 changes: 11 additions & 1 deletion server/typescript/packages/cli/src/lib/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,12 @@ type Dialect = (typeof DIALECTS)[number];
export const MIGRATE_FORMATS = ["default", "flyway"] as const;
export type MigrateFormat = (typeof MIGRATE_FORMATS)[number];

const ALLOW_TOKENS = [
// Exported (not just module-local) so allow-tokens-pinned.test.ts can pin it
// against sdk's AllowTokenEnum (config.json's static migrate.allow validator)
// — the two lists drifted silently before that test existed: sdk's enum was
// missing 5 of these 11 tokens, so a token that worked fine on the CLI was
// REJECTED when set in .metaobjects/config.json.
export const ALLOW_TOKENS = [
"drop-column",
"drop-table",
"type-change",
Expand All @@ -187,6 +192,11 @@ const ALLOW_TOKENS = [
// toolchain needs this exactly once, to stamp its existing views.
"adopt-view",
"nullable-to-not-null",
// drop-identity-default permits dropping a live Postgres auto-sequence
// default (a legacy `serial`/`bigserial` PK's `nextval(...)`) when the
// metadata declares no @generation at all — ambiguous between "never
// declared it" and "deliberately removing auto-increment".
"drop-identity-default",
] as const;
type AllowToken = (typeof ALLOW_TOKENS)[number];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ MIGRATE FLAGS:
--allow <csv> Comma-separated destructive-change permissions:
drop-column,drop-table,type-change,drop-index,drop-fk,
drop-check,drop-view,drop-view-cascade,
adopt-view,nullable-to-not-null
adopt-view,nullable-to-not-null,drop-identity-default
--on-ambiguous abort|rename|drop-add Default abort
--d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
--remote Target remote D1 instead of local (only with --dialect d1)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Drift guard across the three token-bearing structures behind `--allow`:
*
* - `ALLOW_TOKENS` (`lib/args.ts`) — the CLI's authoritative list; what
* actually VALIDATES `--allow <csv>`.
* - `AllowTokenEnum` (`sdk`'s `config.ts`) — validates the STATIC
* `migrate.allow` array in `.metaobjects/config.json`.
* - `ALLOW_TOKEN_MAP` (`lib/allow.ts`) — what actually GRANTS the permission,
* translating a validated token into the `AllowOptions` field `diff()`
* reads.
*
* `ALLOW_TOKENS` and `AllowTokenEnum` silently drifted before this test
* existed: sdk's enum had only 6 of the 11 real tokens, missing
* `drop-check`, `drop-view`, `drop-view-cascade`, `adopt-view` and
* `drop-identity-default`. A user who set any of those five in
* `.metaobjects/config.json`'s `migrate.allow` got a schema rejection for a
* flag the CLI itself accepted fine on the command line — `adopt-view` had
* shipped since 0.20.4 and was affected the whole time.
*
* `ALLOW_TOKEN_MAP` is a distinct, worse failure mode if it drifts from
* `ALLOW_TOKENS`: a token present in `ALLOW_TOKENS` (and `AllowTokenEnum`)
* but missing from the map passes validation cleanly and then
* `tokensToAllowOptions` silently grants NOTHING for it — the user believes
* `--allow <token>` authorized a destructive drop; it didn't, and the diff
* blocks it anyway with no indication the flag was ever a no-op. That is a
* silent-failure mode on exactly the path this whole feature exists to
* protect.
*
* Import ALL of these rather than hardcoding a fourth "expected" list here —
* a hardcoded list would just be a fifth copy that can itself drift.
*
* Package-dependency direction: `cli` depends on `sdk` (`workspace:*`), not
* the other way around, so this test can only live in `cli` — `sdk` cannot
* import from `cli` without introducing a cycle. `sdk`'s `AllowTokenEnum`
* itself carries a doc comment pointing back at this test as the drift guard,
* since `sdk` has no test that can perform the comparison from its own side.
*/
import { test, expect, describe } from "bun:test";
import { ALLOW_TOKENS } from "../../src/lib/args.js";
import { ALLOW_TOKEN_MAP } from "../../src/lib/allow.js";
import { AllowTokenEnum } from "@metaobjectsdev/sdk";

describe("--allow token lists stay pinned across packages", () => {
test("sdk's AllowTokenEnum and the CLI's ALLOW_TOKENS validate the exact same token set", () => {
const cliTokens = new Set<string>(ALLOW_TOKENS);
const sdkTokens = new Set<string>(AllowTokenEnum.options);

const missingFromSdk = [...cliTokens].filter((t) => !sdkTokens.has(t));
const missingFromCli = [...sdkTokens].filter((t) => !cliTokens.has(t));

expect(missingFromSdk).toEqual([]);
expect(missingFromCli).toEqual([]);
expect(sdkTokens.size).toBe(cliTokens.size);
});

test("ALLOW_TOKEN_MAP grants a permission for every validated token, and nothing extra", () => {
const cliTokens = new Set<string>(ALLOW_TOKENS);
const mapKeys = new Set<string>(Object.keys(ALLOW_TOKEN_MAP));

const validatedButNotGranted = [...cliTokens].filter((t) => !mapKeys.has(t));
const grantedButNotValidated = [...mapKeys].filter((t) => !cliTokens.has(t));

expect(validatedButNotGranted).toEqual([]);
expect(grantedButNotValidated).toEqual([]);
expect(mapKeys.size).toBe(cliTokens.size);
});

test("ALLOW_TOKEN_MAP's AllowOptions fields are unique — no two tokens grant the same permission", () => {
const fields = Object.values(ALLOW_TOKEN_MAP);
const uniqueFields = new Set(fields);
expect(uniqueFields.size).toBe(fields.length);
});
});
33 changes: 32 additions & 1 deletion server/typescript/packages/migrate-ts/src/diff/status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Change, AllowOptions } from "../types.js";
import { isWidening } from "../sql-type.js";
import { isPgAutoSequenceDefault } from "../pg-identity-default.js";
import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";

/**
Expand Down Expand Up @@ -111,12 +112,42 @@ function blockedReasonFor(
if (c.from === false && c.to === true) return null;
return allow.nullableToNotNull ? null : "nullable→notnull requires existing data to satisfy (pass allow.nullableToNotNull)";

case "change-column-default": {
// Ordinary default changes (literal→literal, adding/removing a plain
// literal default, etc.) stay allowed unconditionally — only ONE narrow
// shape is gated here. `to === undefined` means the default is being
// DROPPED outright (see the ColumnDefault comment in types.ts), and
// when what's being dropped is a live Postgres auto-sequence default
// (`nextval(...)`, the shape a legacy `serial`/`bigserial` PK carries —
// isPgAutoSequenceDefault, shared with diff/index.ts and the
// introspector), reaching this point means the expected side declared
// NO identity at all: an `identity: "increment"` expected column never
// gets here, because diff/index.ts's skipIdentityDefaultDiff already
// suppressed the change for that exact live shape. So an undeclared
// `@generation` is the ONLY way this branch fires — and that silence is
// ambiguous (never-declared vs. deliberately-removed), so ask rather
// than guess. Anything else about change-column-default — including
// dropping a plain literal default — falls through to the unconditional
// `return null` below.
const droppingAutoSequence =
c.to === undefined && c.from?.kind === "expr" && isPgAutoSequenceDefault(c.from.value);
if (droppingAutoSequence && !allow.dropIdentityDefault) {
return `column "${c.table}"."${c.column}" has a live Postgres auto-increment default `
+ `(${c.from!.value}) but its metadata declares no @generation — this is ambiguous: it `
+ `could mean @generation was never declared, or that auto-increment is being removed on `
+ `purpose. Dropping the default is destructive (every insert that omits the column starts `
+ `failing), so this refuses rather than guessing. Declare @generation: increment on the `
+ `identity to keep the sequence, or pass --allow drop-identity-default if removing it is `
+ `intentional`;
}
return null;
}

// Always-allowed kinds
case "create-table":
case "rename-table":
case "add-column":
case "rename-column":
case "change-column-default":
case "add-index":
case "add-fk":
case "add-check":
Expand Down
15 changes: 15 additions & 0 deletions server/typescript/packages/migrate-ts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,21 @@ export interface AllowOptions {
adoptView?: boolean;
/** Existing data must satisfy NOT NULL; diff cannot verify this. */
nullableToNotNull?: boolean;
/**
* Gates dropping a live Postgres auto-sequence DEFAULT (the `nextval(...)`
* shape a legacy `serial`/`bigserial` column carries — see
* pg-identity-default.ts) when the expected side declares NO identity at
* all, i.e. `@generation` was never set. That silence is genuinely
* ambiguous: it reads identically whether the author simply never got
* around to declaring `@generation: increment`, or deliberately dropped it
* to move the column off auto-increment (e.g. onto app-assigned ULIDs).
* The diff cannot tell those apart, so it refuses instead of guessing —
* this flag is how the author confirms the second reading and lets the
* DROP DEFAULT through. (An expected side that DOES declare
* `@generation: increment` never reaches this gate at all — diff/index.ts
* skips the default-diff for a live auto-sequence default entirely.)
*/
dropIdentityDefault?: boolean;
}

export type AmbiguousChange =
Expand Down
Loading
Loading