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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ here.**

## [Unreleased]

### Fixed

- **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.**
With `--from-db`, a declared-`@unmanaged` view or one outside the scoped schemas whose live body
names the dropped column now blocks the `drop-column`, and the message names the view and the
column. Before, the migration was written and failed at apply on both Postgres (`cannot drop
column … other objects depend on it`) and SQLite (`error in view … after drop column`). A view with
no recorded body is not matched, and an offline run cannot see live views. Pass
`--allow drop-column-read-by-view` (config token `drop-column-read-by-view`) to proceed.
- **The SQLite and D1 down migration for `drop-column` re-adds the column.** It wrote a TODO comment
and still recreated the view with the old SQL, so the view failed with `no such column` after a
rollback. It now runs `ADD COLUMN` from the recorded column, as Postgres does; the data is not
restored, and a `NOT NULL` column with no default comes back nullable, which is all SQLite allows.
- **Docs: edit an `@sql` body when you remove a column it reads.** The tool never parses the body;
Postgres fails at apply and SQLite applies and leaves a broken view.

### Added

- **Contract emitters: JSON Schema and OpenAPI generators (FR-022).** `jsonSchemaFile()`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ walls it off (#208).** Author an `@sql` source under these constraints or the lo
- Today `meta migrate` **lowers `@sql` only on `@kind: view`**; on matview / storedProc /
tableFunction it is registered but not yet migrate-managed (mark those `@unmanaged`).

**Removing a column an `@sql` body reads: edit the body in the same change.** The tool never
parses an `@sql` body, so it cannot see that the body still names the column. The migration
is still written: on Postgres it fails at apply (`column "…" does not exist`); on SQLite it
applies and leaves a view that errors when queried. An `@unmanaged` view is not rewritten
either: with `--from-db`, `meta migrate` blocks a drop of a column such a view still reads
and names the view (`--allow drop-column-read-by-view` to proceed); fix the view first.

**A `passthrough` field must match its `@from` source's type.** A passthrough
forwards the source value unchanged, so the projection field's `field.<subType>`
and array-ness must be identical to the source field's — a `field.uuid` source
Expand Down
22 changes: 22 additions & 0 deletions docs/features/migrations-and-drift.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,28 @@ migrate` never creates, drops, or drift-checks it, and `meta verify --db` report
*external (declared)*. The two are mutually exclusive. See
[ADR-0043](../../spec/decisions/ADR-0043-ddl-ownership-escape-valves.md).

**Removing a column a view reads.** For a view the tool derives, `meta migrate` finds the
views that read the column and drops and recreates them around the change. Two kinds of view
are not rewritten, so you edit them yourself:

- **An `@sql` body.** The tool never parses it, so it cannot know the body still names a
column you removed. **Edit the body in the same change that removes the column.** Left
alone, the migration is still written. On Postgres it then fails at apply with `column
"…" does not exist`. On SQLite it applies and leaves a view that errors when queried.
- **An `@unmanaged` view, or a hand-made one.** With `--from-db`, `meta migrate` reads the
live view bodies. If one it does not own names the column the plan drops, the drop is
blocked at plan time and the message names the view and the column; otherwise Postgres
fails at apply (`cannot drop column … because other objects depend on it`) and SQLite
fails with `error in view … after drop column`. Change the view first, or pass `--allow
drop-column-read-by-view` to proceed anyway. A view with no recorded body cannot be
matched, and an offline run (no database) cannot see live views, so neither is checked.

A rollback of a dropped column re-adds it on every engine, with its type, but the data is
not restored. On SQLite a column that was `NOT NULL` with no default comes back nullable,
because SQLite cannot add it any other way. SQLite also cannot add a column with an
expression default (or a `uuid` identity default) to a populated table, so the rollback
omits that default and says so in a `NOTE`.

## Verified by

The following conformance fixtures gate this feature's behavior across ports:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ walls it off (#208).** Author an `@sql` source under these constraints or the lo
- Today `meta migrate` **lowers `@sql` only on `@kind: view`**; on matview / storedProc /
tableFunction it is registered but not yet migrate-managed (mark those `@unmanaged`).

**Removing a column an `@sql` body reads: edit the body in the same change.** The tool never
parses an `@sql` body, so it cannot see that the body still names the column. The migration
is still written: on Postgres it fails at apply (`column "…" does not exist`); on SQLite it
applies and leaves a view that errors when queried. An `@unmanaged` view is not rewritten
either: with `--from-db`, `meta migrate` blocks a drop of a column such a view still reads
and names the view (`--allow drop-column-read-by-view` to proceed); fix the view first.

**A `passthrough` field must match its `@from` source's type.** A passthrough
forwards the source value unchanged, so the projection field's `field.<subType>`
and array-ness must be identical to the source field's — a `field.uuid` source
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ walls it off (#208).** Author an `@sql` source under these constraints or the lo
- Today `meta migrate` **lowers `@sql` only on `@kind: view`**; on matview / storedProc /
tableFunction it is registered but not yet migrate-managed (mark those `@unmanaged`).

**Removing a column an `@sql` body reads: edit the body in the same change.** The tool never
parses an `@sql` body, so it cannot see that the body still names the column. The migration
is still written: on Postgres it fails at apply (`column "…" does not exist`); on SQLite it
applies and leaves a view that errors when queried. An `@unmanaged` view is not rewritten
either: with `--from-db`, `meta migrate` blocks a drop of a column such a view still reads
and names the view (`--allow drop-column-read-by-view` to proceed); fix the view first.

**A `passthrough` field must match its `@from` source's type.** A passthrough
forwards the source value unchanged, so the projection field's `field.<subType>`
and array-ness must be identical to the source field's — a `field.uuid` source
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ walls it off (#208).** Author an `@sql` source under these constraints or the lo
- Today `meta migrate` **lowers `@sql` only on `@kind: view`**; on matview / storedProc /
tableFunction it is registered but not yet migrate-managed (mark those `@unmanaged`).

**Removing a column an `@sql` body reads: edit the body in the same change.** The tool never
parses an `@sql` body, so it cannot see that the body still names the column. The migration
is still written: on Postgres it fails at apply (`column "…" does not exist`); on SQLite it
applies and leaves a view that errors when queried. An `@unmanaged` view is not rewritten
either: with `--from-db`, `meta migrate` blocks a drop of a column such a view still reads
and names the view (`--allow drop-column-read-by-view` to proceed); fix the view first.

**A `passthrough` field must match its `@from` source's type.** A passthrough
forwards the source value unchanged, so the projection field's `field.<subType>`
and array-ness must be identical to the source field's — a `field.uuid` source
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ walls it off (#208).** Author an `@sql` source under these constraints or the lo
- Today `meta migrate` **lowers `@sql` only on `@kind: view`**; on matview / storedProc /
tableFunction it is registered but not yet migrate-managed (mark those `@unmanaged`).

**Removing a column an `@sql` body reads: edit the body in the same change.** The tool never
parses an `@sql` body, so it cannot see that the body still names the column. The migration
is still written: on Postgres it fails at apply (`column "…" does not exist`); on SQLite it
applies and leaves a view that errors when queried. An `@unmanaged` view is not rewritten
either: with `--from-db`, `meta migrate` blocks a drop of a column such a view still reads
and names the view (`--allow drop-column-read-by-view` to proceed); fix the view first.

**A `passthrough` field must match its `@from` source's type.** A passthrough
forwards the source value unchanged, so the projection field's `field.<subType>`
and array-ness must be identical to the source field's — a `field.uuid` source
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ walls it off (#208).** Author an `@sql` source under these constraints or the lo
- Today `meta migrate` **lowers `@sql` only on `@kind: view`**; on matview / storedProc /
tableFunction it is registered but not yet migrate-managed (mark those `@unmanaged`).

**Removing a column an `@sql` body reads: edit the body in the same change.** The tool never
parses an `@sql` body, so it cannot see that the body still names the column. The migration
is still written: on Postgres it fails at apply (`column "…" does not exist`); on SQLite it
applies and leaves a view that errors when queried. An `@unmanaged` view is not rewritten
either: with `--from-db`, `meta migrate` blocks a drop of a column such a view still reads
and names the view (`--allow drop-column-read-by-view` to proceed); fix the view first.

**A `passthrough` field must match its `@from` source's type.** A passthrough
forwards the source value unchanged, so the projection field's `field.<subType>`
and array-ness must be identical to the source field's — a `field.uuid` source
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 @@ -176,7 +176,7 @@ Flags:
- `--shadow-db <url>` — Postgres scratch database the expected schema is applied to for `--from-db` / `--apply` drift (also `SHADOW_DATABASE_URL` or `migrate.shadowDatabaseUrl`). Read [How CHECK constraints and expression indexes are compared](../../../../docs/features/migrations-and-drift.md#how-check-constraints-and-expression-indexes-are-compared) for the three paths.
- `--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,drop-view-cascade,adopt-view,nullable-to-not-null,drop-identity-default,drop-unmanaged,drop-comment`
- `--allow <csv>` — destructive-change permissions: `drop-column,drop-table,type-change,drop-index,drop-fk,drop-check,drop-view,drop-view-cascade,drop-column-read-by-view,adopt-view,nullable-to-not-null,drop-identity-default,drop-unmanaged,drop-comment`
- `--on-ambiguous abort|rename|drop-add` (default `abort`) — non-interactive
- `--rename-table [schema.]old=new` / `--rename-column [schema.]table.old=new` — declare a rename (repeatable), so the migration is `RENAME` instead of drop+add whatever the rename heuristic makes of the names. Refused if nothing matches. See [Renames and populated tables](../../../../docs/features/migrations-and-drift.md#renames-and-populated-tables).
- `--dry-run` — print the SQL pair, write nothing (under `--format json|toon` the pair is the document's `sql` field, so stdout stays one parseable document)
Expand Down
2 changes: 1 addition & 1 deletion server/typescript/packages/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ MIGRATE FLAGS:
--slug <name> Required to WRITE a migration (not for --dry-run) (e.g., --slug add-user-shipping)
--allow <csv> Comma-separated destructive-change permissions:
drop-column,drop-table,type-change,drop-index,drop-fk,
drop-check,drop-view,drop-view-cascade,
drop-check,drop-view,drop-view-cascade,drop-column-read-by-view,
adopt-view,nullable-to-not-null,drop-identity-default,
drop-unmanaged,drop-comment
drop-column DELETES the column and every value in it. If
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 @@ -176,7 +176,7 @@ MIGRATE FLAGS:
--slug <name> Required when changes are present (e.g., add-user-shipping)
--allow <csv> Comma-separated destructive-change permissions:
drop-column,drop-table,type-change,drop-index,drop-fk,
drop-check,drop-view,drop-view-cascade,
drop-check,drop-view,drop-view-cascade,drop-column-read-by-view,
adopt-view,nullable-to-not-null,drop-identity-default,
drop-comment,drop-unmanaged
drop-column DELETES the column's data; for a renamed
Expand Down
7 changes: 6 additions & 1 deletion server/typescript/packages/cli/src/lib/allow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const ALLOW_TOKEN_MAP: Record<string, keyof AllowOptions> = {
"drop-view": "dropView",
// Gates DROP VIEW ... CASCADE. Additional to drop-view, never implied by it.
"drop-view-cascade": "dropViewCascade",
// Gates dropping a column a view this tool does not own still reads.
"drop-column-read-by-view": "dropColumnReadByView",
// Gates overwriting an unfingerprinted (hand-written or pre-fingerprint) view.
"adopt-view": "adoptView",
"nullable-to-not-null": "nullableToNotNull",
Expand Down Expand Up @@ -66,7 +68,10 @@ export function describeChange(c: Change): string {
case "drop-table": return c.table;
case "rename-table": return `${c.from} → ${c.to}`;
case "add-column": return `${c.table}.${c.column.name}`;
case "drop-column": return `${c.table}.${c.column}`;
case "drop-column":
return c.readBy === undefined || c.readBy.length === 0
? `${c.table}.${c.column}`
: `${c.table}.${c.column} (read by view ${c.readBy.join(", ")})`;
case "rename-column": return `${c.table}.${c.from} → ${c.table}.${c.to}`;
case "change-column-type": {
// A type change carries its column's default change, so the default is reported here.
Expand Down
4 changes: 4 additions & 0 deletions server/typescript/packages/cli/src/lib/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ export const ALLOW_TOKENS = [
// views and materialized views owned by OTHER applications, which this tool
// neither manages nor can restore. `--allow drop-view` alone never cascades.
"drop-view-cascade",
// drop-column-read-by-view permits dropping a column that a live view this tool
// does not own (`@unmanaged`, or outside the scoped schemas) still reads. The tool
// never rewrites such a view, so without this `migrate` refuses at plan time.
"drop-column-read-by-view",
// adopt-view permits overwriting an existing view that carries no MetaObjects
// fingerprint — i.e. taking ownership of a hand-written view, or of one created
// before fingerprinting existed. Every environment upgrading from an older
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ MIGRATE FLAGS:
--slug <name> Required when changes are present (e.g., add-user-shipping)
--allow <csv> Comma-separated destructive-change permissions:
drop-column,drop-table,type-change,drop-index,drop-fk,
drop-check,drop-view,drop-view-cascade,
drop-check,drop-view,drop-view-cascade,drop-column-read-by-view,
adopt-view,nullable-to-not-null,drop-identity-default,
drop-comment,drop-unmanaged
drop-column DELETES the column's data; for a renamed
Expand Down
15 changes: 15 additions & 0 deletions server/typescript/packages/cli/test/allow-hint-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,18 @@ describe("following the hints unblocks the change", () => {
expect(await blocked(scenario, ["drop-view-cascade", "adopt-view"])).toEqual([]);
});
});

describe("a drop-column held by a view the tool does not own", () => {
test("the blocked entry names the view that reads the column", async () => {
const r = await diff({
expected: snap([T({ columns: [T().columns[0]!] })]),
actual: snap([T()], [{ name: "v_hand", sql: "SELECT id, label FROM t" }]),
dialect: "sqlite",
allow: { dropColumn: true },
});
const entries = blockedEntriesFor(r.blocked, r.changes);
const entry = entries.find((e) => e.kind === "drop-column");
expect(entry?.description).toBe("t.label (read by view v_hand)");
expect(entry?.allowFlag).toBe("drop-column-read-by-view");
});
});
Loading
Loading