diff --git a/CHANGELOG.md b/CHANGELOG.md index 3631b5903..2f4e88d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()` diff --git a/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md b/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md index c58b0e5ad..06d2ccf2e 100644 --- a/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -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.` and array-ness must be identical to the source field's — a `field.uuid` source diff --git a/docs/features/migrations-and-drift.md b/docs/features/migrations-and-drift.md index 790692ea0..7e38e0aea 100644 --- a/docs/features/migrations-and-drift.md +++ b/docs/features/migrations-and-drift.md @@ -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: diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index c58b0e5ad..06d2ccf2e 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -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.` and array-ness must be identical to the source field's — a `field.uuid` source diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index c58b0e5ad..06d2ccf2e 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -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.` and array-ness must be identical to the source field's — a `field.uuid` source diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index c58b0e5ad..06d2ccf2e 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -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.` and array-ness must be identical to the source field's — a `field.uuid` source diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index c58b0e5ad..06d2ccf2e 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -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.` and array-ness must be identical to the source field's — a `field.uuid` source diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index c58b0e5ad..06d2ccf2e 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -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.` and array-ness must be identical to the source field's — a `field.uuid` source diff --git a/server/typescript/packages/cli/README.md b/server/typescript/packages/cli/README.md index cae4c2ba8..25e779ad6 100644 --- a/server/typescript/packages/cli/README.md +++ b/server/typescript/packages/cli/README.md @@ -176,7 +176,7 @@ Flags: - `--shadow-db ` — 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 ` (default `./.metaobjects/migrations`) - `--slug ` — required when changes are pending (e.g., `add-user-shipping`) -- `--allow ` — 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 ` — 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) diff --git a/server/typescript/packages/cli/src/commands/migrate.ts b/server/typescript/packages/cli/src/commands/migrate.ts index ae661808d..a5d8bc30d 100644 --- a/server/typescript/packages/cli/src/commands/migrate.ts +++ b/server/typescript/packages/cli/src/commands/migrate.ts @@ -120,7 +120,7 @@ MIGRATE FLAGS: --slug Required to WRITE a migration (not for --dry-run) (e.g., --slug add-user-shipping) --allow 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 diff --git a/server/typescript/packages/cli/src/index.ts b/server/typescript/packages/cli/src/index.ts index a294da47d..d63cc6858 100644 --- a/server/typescript/packages/cli/src/index.ts +++ b/server/typescript/packages/cli/src/index.ts @@ -176,7 +176,7 @@ MIGRATE FLAGS: --slug Required when changes are present (e.g., add-user-shipping) --allow 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 diff --git a/server/typescript/packages/cli/src/lib/allow.ts b/server/typescript/packages/cli/src/lib/allow.ts index f1a7bc7c8..a6db94413 100644 --- a/server/typescript/packages/cli/src/lib/allow.ts +++ b/server/typescript/packages/cli/src/lib/allow.ts @@ -27,6 +27,8 @@ export const ALLOW_TOKEN_MAP: Record = { "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", @@ -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. diff --git a/server/typescript/packages/cli/src/lib/args.ts b/server/typescript/packages/cli/src/lib/args.ts index 0280813a8..715527104 100644 --- a/server/typescript/packages/cli/src/lib/args.ts +++ b/server/typescript/packages/cli/src/lib/args.ts @@ -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 diff --git a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap index 06921804f..6e8edb347 100644 --- a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap +++ b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap @@ -156,7 +156,7 @@ MIGRATE FLAGS: --slug Required when changes are present (e.g., add-user-shipping) --allow 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 diff --git a/server/typescript/packages/cli/test/allow-hint-tokens.test.ts b/server/typescript/packages/cli/test/allow-hint-tokens.test.ts index f0c9cf94a..2213ab9f0 100644 --- a/server/typescript/packages/cli/test/allow-hint-tokens.test.ts +++ b/server/typescript/packages/cli/test/allow-hint-tokens.test.ts @@ -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"); + }); +}); diff --git a/server/typescript/packages/integration-tests/test/drop-column-view-pg.test.ts b/server/typescript/packages/integration-tests/test/drop-column-view-pg.test.ts new file mode 100644 index 000000000..a56de0d04 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/drop-column-view-pg.test.ts @@ -0,0 +1,116 @@ +/** + * Dropping a column under a view the tool does not own, against a real Postgres. + * + * An `@unmanaged` view that still reads the dropped column is refused at plan time and + * named (Postgres otherwise fails at apply: "cannot drop column … other objects depend on + * it"); one that does not read it is not blocked, and applies. + */ +import { splitSqlStatements } from "@metaobjectsdev/migrate-ts"; +import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test"; +import { + buildExpectedSchema, collectUnmanagedNames, diff, emit, introspectPostgres, postgresViewReplaceProbe, + type AllowOptions, +} from "@metaobjectsdev/migrate-ts"; +import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { Pool } from "pg"; +import { startPostgres, type RunningPg } from "../src/postgres-container.ts"; + +function literalColumns(root: T): T { + (root as unknown as { physicalNaming: unknown }).physicalNaming = { columns: "literal", tables: "snake_case" }; + return root; +} + +const NICKNAME = `{ "field.string": { "name": "nickname" } }`; +const NICKNAME_VIEW = `{ "field.string": { "name": "nickname", "extends": "Account.nickname" } }`; + +function meta(withNickname: boolean, extra: string[] = []): string { + return `{ "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name" } }, + ${withNickname ? NICKNAME + "," : ""} + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + ] } }, + { "object.projection": { "name": "AccountView", "children": [ + { "source.rdb": { "@kind": "view", "@table": "v_account" } }, + { "identity.primary": { "name": "id", "extends": "Account.id", "@fields": "id" } }, + { "field.long": { "name": "id", "extends": "Account.id" } }, + { "field.string": { "name": "name", "extends": "Account.name" } } + ${withNickname ? "," + NICKNAME_VIEW : ""} + ] } } + ${extra.map((e) => "," + e).join("\n")} + ] } }`; +} + +// A view declared @unmanaged: someone else owns its DDL, so migrate never rewrites it. +const UNMANAGED_VIEW = `{ "object.projection": { "name": "HandView", "children": [ + { "source.rdb": { "@kind": "view", "@table": "v_hand", "@unmanaged": true } }, + { "identity.primary": { "name": "id", "extends": "Account.id", "@fields": "id" } }, + { "field.long": { "name": "id", "extends": "Account.id" } } +] } }`; + +let pg: RunningPg; +let k: Kysely>; + +beforeAll(async () => { + pg = await startPostgres(); + k = new Kysely>({ + dialect: new PostgresDialect({ pool: new Pool({ connectionString: pg.connectionUri }) }), + }); +}, 120_000); +afterAll(async () => { + await k.destroy(); + await pg.stop(); +}, 60_000); +beforeEach(async () => { + await sql.raw("DROP SCHEMA public CASCADE").execute(k); + await sql.raw("CREATE SCHEMA public").execute(k); +}); + +async function applyRaw(text: string): Promise { + for (const stmt of splitSqlStatements(text)) await sql.raw(stmt.endsWith(";") ? stmt : `${stmt};`).execute(k); +} + +async function migrate(metaJson: string, allow: AllowOptions = {}) { + const root = (await new MetaDataLoader().load([new InMemoryStringSource(metaJson)])).root; + const expected = buildExpectedSchema(literalColumns(root), { + views: buildProjectionViews(literalColumns(root), { dialect: "postgres" }), + }); + const result = await diff({ + expected, actual: await introspectPostgres(k), dialect: "postgres", + viewReplaceProbe: postgresViewReplaceProbe(k), allow, unmanagedNames: collectUnmanagedNames(root), + }); + const emittable = result.changes.filter((c) => c.status.state !== "blocked"); + const out = emittable.length === 0 ? { up: "", down: "" } : emit(emittable, { dialect: "postgres" }); + if (result.blocked.length === 0 && out.up.trim().length > 0) await applyRaw(out.up); + return { result, ...out }; +} + +describe("drop-column under a view the tool does not own — real Postgres", () => { + test("an unmanaged view that reads the dropped column is blocked and named; the DB is untouched", async () => { + await migrate(meta(true, [UNMANAGED_VIEW])); + await sql.raw(`CREATE VIEW "v_hand" AS SELECT "id", "nickname" FROM "accounts"`).execute(k); + + const { result } = await migrate(meta(false, [UNMANAGED_VIEW]), { dropColumn: true }); + const blocked = result.blocked.find((c) => c.kind === "drop-column"); + expect(blocked?.status).toMatchObject({ state: "blocked", blockedBy: "dropColumnReadByView" }); + expect(blocked?.status.blockedReason).toContain("v_hand"); + expect(blocked?.status.blockedReason).toContain("accounts.nickname"); + + // The plan it refused is the one Postgres fails at apply. + await expect(migrate(meta(false, [UNMANAGED_VIEW]), { dropColumn: true, dropColumnReadByView: true })) + .rejects.toThrow(/depend on it/); + }, 60_000); + + test("an unmanaged view that does not read the dropped column is not blocked", async () => { + await migrate(meta(true, [UNMANAGED_VIEW])); + await sql.raw(`CREATE VIEW "v_hand" AS SELECT "id", "name" FROM "accounts"`).execute(k); + + const { result } = await migrate(meta(false, [UNMANAGED_VIEW]), { dropColumn: true }); + expect(result.blocked).toEqual([]); + await sql.raw(`SELECT * FROM "v_hand"`).execute(k); + }, 60_000); +}); diff --git a/server/typescript/packages/integration-tests/test/drop-column-view-sqlite.test.ts b/server/typescript/packages/integration-tests/test/drop-column-view-sqlite.test.ts new file mode 100644 index 000000000..3d41581b3 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/drop-column-view-sqlite.test.ts @@ -0,0 +1,138 @@ +/** + * Dropping a column under a view, against a real SQLite. + * + * - A view the tool does not own (`@unmanaged`) that still reads the dropped column is + * refused at plan time and named; one that does not read it is not. + * - The down of a `drop-column` re-adds the column, so rolling back leaves the managed view + * the migration dropped and recreated queryable again. (D1 renders through the same path.) + */ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Kysely, sql } from "kysely"; +import { LibsqlDialect } from "@libsql/kysely-libsql"; +import { + buildExpectedSchema, collectUnmanagedNames, diff, emit, introspectSqlite, type AllowOptions, +} from "@metaobjectsdev/migrate-ts"; +import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; + +function literalColumns(root: T): T { + (root as unknown as { physicalNaming: unknown }).physicalNaming = { columns: "literal", tables: "snake_case" }; + return root; +} + +const TIMEOUT_MS = 60_000; +const NICKNAME = `{ "field.string": { "name": "nickname" } }`; +const NICKNAME_VIEW = `{ "field.string": { "name": "nickname", "extends": "Account.nickname" } }`; + +function meta(withNickname: boolean, extra: string[] = []): string { + return `{ "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name" } }, + ${withNickname ? NICKNAME + "," : ""} + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + ] } }, + { "object.projection": { "name": "AccountView", "children": [ + { "source.rdb": { "@kind": "view", "@table": "v_account" } }, + { "identity.primary": { "name": "id", "extends": "Account.id", "@fields": "id" } }, + { "field.long": { "name": "id", "extends": "Account.id" } }, + { "field.string": { "name": "name", "extends": "Account.name" } } + ${withNickname ? "," + NICKNAME_VIEW : ""} + ] } } + ${extra.map((e) => "," + e).join("\n")} + ] } }`; +} + +// A view declared @unmanaged: someone else owns its DDL, so migrate never rewrites it. +const UNMANAGED_VIEW = `{ "object.projection": { "name": "HandView", "children": [ + { "source.rdb": { "@kind": "view", "@table": "v_hand", "@unmanaged": true } }, + { "identity.primary": { "name": "id", "extends": "Account.id", "@fields": "id" } }, + { "field.long": { "name": "id", "extends": "Account.id" } } +] } }`; + +let tmpDir: string; +let k: Kysely>; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "drop-column-view-sqlite-")); + k = new Kysely({ dialect: new LibsqlDialect({ url: `file:${join(tmpDir, "test.db")}` }) }); +}); +afterEach(async () => { + await k.destroy(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +// libsql execute() is single-statement; no emitted body here carries an inner ";". +async function applyRaw(text: string): Promise { + for (const stmt of text.split("\n").filter((l) => !l.startsWith("--")).join("\n").split(";").map((s) => s.trim()).filter(Boolean)) { + await sql.raw(stmt).execute(k); + } +} + +async function migrate(metaJson: string, allow: AllowOptions = {}) { + const root = (await new MetaDataLoader().load([new InMemoryStringSource(metaJson)])).root; + const expected = buildExpectedSchema(literalColumns(root), { + dialect: "sqlite", + views: buildProjectionViews(literalColumns(root), { dialect: "sqlite" }), + }); + const actual = await introspectSqlite(k); + const result = await diff({ expected, actual, dialect: "sqlite", allow, unmanagedNames: collectUnmanagedNames(root) }); + const emittable = result.changes.filter((c) => c.status.state !== "blocked"); + const out = emittable.length === 0 + ? { up: "", down: "" } + : emit(emittable, { + dialect: "sqlite", expectedSchema: expected, actualSchema: actual, + ...(actual.meta !== undefined && { actualMeta: actual.meta }), + }); + if (result.blocked.length === 0 && out.up.trim().length > 0) await applyRaw(out.up); + return { result, ...out }; +} + +describe("drop-column under a view — real SQLite", () => { + test("an unmanaged view that reads the dropped column is blocked and named; the DB is untouched", async () => { + await migrate(meta(true, [UNMANAGED_VIEW])); + await sql.raw(`CREATE VIEW "v_hand" AS SELECT "id", "nickname" FROM "accounts"`).execute(k); + + const { result } = await migrate(meta(false, [UNMANAGED_VIEW]), { dropColumn: true }); + const blocked = result.blocked.find((c) => c.kind === "drop-column"); + expect(blocked?.status).toMatchObject({ state: "blocked", blockedBy: "dropColumnReadByView" }); + expect(blocked?.status.blockedReason).toContain("v_hand"); + expect(blocked?.status.blockedReason).toContain("accounts.nickname"); + + // With the flag the plan is allowed through. + await expect(migrate(meta(false, [UNMANAGED_VIEW]), { dropColumn: true, dropColumnReadByView: true })) + .rejects.toThrow(/v_hand/); // SQLite then refuses the drop at apply: the view still reads it + }, TIMEOUT_MS); + + test("an unmanaged view that does not read the dropped column is not blocked", async () => { + await migrate(meta(true, [UNMANAGED_VIEW])); + await sql.raw(`CREATE VIEW "v_hand" AS SELECT "id", "name" FROM "accounts"`).execute(k); + + const { result } = await migrate(meta(false, [UNMANAGED_VIEW]), { dropColumn: true }); + expect(result.blocked).toEqual([]); + await sql.raw(`SELECT * FROM "v_hand"`).execute(k); + }, TIMEOUT_MS); + + test("rolling back a drop-column re-adds it, and the managed view is queryable again", async () => { + await migrate(meta(true)); + await sql.raw(`INSERT INTO "accounts" ("id","name","nickname") VALUES (1,'a','n1'),(2,'b','n2')`).execute(k); + + const { result, down } = await migrate(meta(false), { dropColumn: true, dropView: true }); + expect(result.blocked).toEqual([]); + expect(down).toContain(`ADD COLUMN "nickname"`); + // Applied: the column and the view's column are gone. + await expect(sql.raw(`SELECT "nickname" FROM "v_account"`).execute(k)).rejects.toThrow(); + + await applyRaw(down); + const rows = await sql.raw(`SELECT * FROM "v_account" ORDER BY "id"`).execute(k); + expect(rows.rows).toHaveLength(2); + expect(Object.keys(rows.rows[0] as object)).toContain("nickname"); + // The data is not restored, and the migration says so. + expect((rows.rows[0] as { nickname: unknown }).nickname).toBeNull(); + expect(down).toMatch(/column data is not restored/i); + }, TIMEOUT_MS); +}); diff --git a/server/typescript/packages/migrate-ts/src/diff/index.ts b/server/typescript/packages/migrate-ts/src/diff/index.ts index d73e4b0c3..ef9640c4b 100644 --- a/server/typescript/packages/migrate-ts/src/diff/index.ts +++ b/server/typescript/packages/migrate-ts/src/diff/index.ts @@ -317,6 +317,12 @@ export async function diff( // that is exactly the object a cascade must not destroy silently. annotateViewDropDependents(args.actual.views, changes); + // A live view this tool neither rewrites nor drops stays in place and blocks a column drop + // at apply. Whether a planned drop-view/replace-view is itself blocked is decided by the + // statuses, so assign them first; applyStatus below assigns them again over the final list. + applyStatus(changes, args.allow ?? {}); + annotateUnownedColumnReaders(args.actual.views, changes); + // Pass 3: column renames (table renames were resolved in Pass 1c). await detectColumnRenames(changes, args.onAmbiguous, args.renames); // Pass 3b: a renamed column carries the constraints and indexes named after it. @@ -1249,6 +1255,38 @@ function annotateViewDropDependents(actual: readonly ViewDescriptor[], changes: } } +/** + * Mark each `drop-column` that a live view this migration leaves in place still reads. + * + * A view with a drop-view or replace-view in `changes` that is not blocked is dropped or + * rewritten around the change, so it does not strand the drop. Every other live view + * does: a declared-`@unmanaged` one, one in a schema the model does not scope, a hand-made + * one whose drop-view is blocked. A view with no recorded body cannot be matched, so it is + * not blocked on. + */ +function annotateUnownedColumnReaders( + actual: readonly ViewDescriptor[], + changes: Change[], +): void { + const handled = new Set(); + for (const c of changes) { + if (c.status.state === "blocked") continue; + if (c.kind === "drop-view") handled.add(viewIdentity({ name: c.view, ...schemaSpread(c.schema) })); + else if (c.kind === "replace-view") handled.add(viewIdentity({ name: c.view.name, ...schemaSpread(c.schema) })); + } + const unowned = actual.filter((v) => v.sql !== undefined && !handled.has(viewIdentity(v))); + if (unowned.length === 0) return; + const liveName = liveTableNames(changes); + for (const c of changes) { + if (c.kind !== "drop-column") continue; + const table = liveName(c.table); + const readBy = unowned + .filter((v) => sqlNamesIdentifier(v.sql!, table) && sqlNamesIdentifier(v.sql!, c.column)) + .map((v) => (v.schema !== undefined ? `${v.schema}.${v.name}` : v.name)); + if (readBy.length > 0) c.readBy = readBy; + } +} + /** * Postgres: a view reading a column blocks `ALTER … ALTER COLUMN TYPE`, a drop and a rename * of it, so those force the view to be dropped-before / recreated-after. A new default or a diff --git a/server/typescript/packages/migrate-ts/src/diff/status.ts b/server/typescript/packages/migrate-ts/src/diff/status.ts index 672bb42b1..a777fc390 100644 --- a/server/typescript/packages/migrate-ts/src/diff/status.ts +++ b/server/typescript/packages/migrate-ts/src/diff/status.ts @@ -55,8 +55,18 @@ function blockFor( recreatedViews: ReadonlySet, ): Block | null { switch (c.kind) { - case "drop-column": - return gate(allow, "dropColumn", "destructive: drop-column not allowed (pass allow.dropColumn)"); + case "drop-column": { + const blocked = gate(allow, "dropColumn", "destructive: drop-column not allowed (pass allow.dropColumn)"); + if (blocked !== null) return blocked; + const readBy = c.readBy ?? []; + if (readBy.length > 0) { + return gate(allow, "dropColumnReadByView", + `drop-column ${c.table}.${c.column} is still read by ${readBy.length} view(s) MetaObjects does not own ` + + `(${readBy.join(", ")}). It never rewrites them, so the drop fails at apply. ` + + `Change the view(s) first, or pass allow.dropColumnReadByView`); + } + return null; + } case "drop-table": return gate(allow, "dropTable", "destructive: drop-table not allowed (pass allow.dropTable)"); case "drop-index": diff --git a/server/typescript/packages/migrate-ts/src/emit/sqlite.ts b/server/typescript/packages/migrate-ts/src/emit/sqlite.ts index afeeb6811..9689dafbf 100644 --- a/server/typescript/packages/migrate-ts/src/emit/sqlite.ts +++ b/server/typescript/packages/migrate-ts/src/emit/sqlite.ts @@ -430,6 +430,33 @@ export function renderCreateView(v: ViewDescriptor): string { return `CREATE VIEW ${quote(v.name)} AS\n${v.sql};`; } +/** + * Re-add a dropped column from the descriptor the diff kept (`restore`). The data is not + * restored. SQLite refuses `ADD COLUMN … NOT NULL` with no default, so such a column comes + * back nullable rather than leaving the rollback unable to run. + */ +function renderDropColumnDown(c: Extract): string { + if (c.restore === undefined) { + return `-- WARNING: down migration cannot restore data\n-- TODO: re-add dropped column "${c.column}" manually with original type/nullable/default`; + } + const { default: dflt, identity, ...rest } = c.restore; + const nonConstant = dflt?.kind === "expr" || (dflt === undefined && identity === "uuid"); + const literal = dflt?.kind === "literal" ? dflt : undefined; + const loosen = !c.restore.nullable && literal === undefined; + const column: ColumnDescriptor = { + ...rest, + nullable: c.restore.nullable || loosen, + ...(literal !== undefined ? { default: literal } : {}), + ...(identity === "increment" ? { identity } : {}), + }; + return [ + `ALTER TABLE ${quote(c.table)} ADD COLUMN ${renderColumnInline(column)};`, + "-- NOTE: column data is not restored by this down migration.", + ...(nonConstant ? [`-- NOTE: ${quote(c.column)} had a non-constant default; SQLite cannot add a column with one to a populated table, so the default is omitted.`] : []), + ...(loosen ? [`-- NOTE: ${quote(c.column)} was NOT NULL with no default; SQLite cannot add it that way, so it returns nullable.`] : []), + ].join("\n"); +} + function renderDownNative(c: Change): string { switch (c.kind) { case "create-table": return `DROP TABLE ${quote(c.table.name)};`; @@ -439,7 +466,7 @@ function renderDownNative(c: Change): string { return `ALTER TABLE ${quote(c.to)} RENAME TO ${quote(c.from)};`; } case "add-column": return `ALTER TABLE ${quote(c.table)} DROP COLUMN ${quote(c.column.name)};`; - case "drop-column": return `-- WARNING: down migration cannot restore data\n-- TODO: re-add dropped column "${c.column}" manually`; + case "drop-column": return renderDropColumnDown(c); case "rename-column": { assertNoCarriedRenames(c); return `ALTER TABLE ${quote(c.table)} RENAME COLUMN ${quote(c.to)} TO ${quote(c.from)};`; diff --git a/server/typescript/packages/migrate-ts/src/types.ts b/server/typescript/packages/migrate-ts/src/types.ts index b08fb361d..a7527d5ce 100644 --- a/server/typescript/packages/migrate-ts/src/types.ts +++ b/server/typescript/packages/migrate-ts/src/types.ts @@ -252,7 +252,15 @@ export type Change = status: ChangeStatus; } | { kind: "add-column"; table: string; schema?: string; column: ColumnDescriptor; status: ChangeStatus } - | { kind: "drop-column"; table: string; schema?: string; column: string; restore?: ColumnDescriptor; status: ChangeStatus } + | { + kind: "drop-column"; table: string; schema?: string; column: string; restore?: ColumnDescriptor; status: ChangeStatus; + /** + * Live views this tool does not own (declared `@unmanaged`, or outside the scoped + * schemas) whose recorded body names the dropped column. The drop fails at apply + * while they stand, so it is blocked pending `allow.dropColumnReadByView`. + */ + readBy?: readonly string[]; + } | { kind: "rename-column"; table: string; schema?: string; from: string; to: string; /** @@ -471,6 +479,14 @@ export interface AllowOptions { * dependents snapshot rather than silently cascading. */ dropViewCascade?: boolean; + /** + * Gates dropping a column that a live view this tool does NOT own still reads (a view + * declared `@unmanaged`, or one outside the scoped schemas). The tool never rewrites + * such a view, so the drop fails at apply on Postgres (`cannot drop column … other + * objects depend on it`) and on SQLite (`error in view … after drop column`). Granting + * this lets the plan through, for when the view is dealt with first or in the same release. + */ + dropColumnReadByView?: boolean; /** * Gates overwriting an existing view that carries NO MetaObjects fingerprint — * i.e. taking ownership of it. It is either hand-written or predates diff --git a/server/typescript/packages/migrate-ts/test/drop-column-unowned-view.test.ts b/server/typescript/packages/migrate-ts/test/drop-column-unowned-view.test.ts new file mode 100644 index 000000000..8c76c483e --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/drop-column-unowned-view.test.ts @@ -0,0 +1,138 @@ +/** + * A view this tool does not own is never rewritten, so it strands a column drop at apply. + * The plan must refuse and name the view; a view that does not read the column, or has no + * recorded body, must not block. The SQLite down must re-add the dropped column. + */ +import { describe, test, expect } from "bun:test"; +import { Database } from "bun:sqlite"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import type { MetaData } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../src/expected-schema.js"; +import { diff } from "../src/diff/index.js"; +import { emit } from "../src/emit/index.js"; +import type { SchemaSnapshot, ViewDescriptor } from "../src/types.js"; + +async function load(fields: string): Promise { + return (await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify({ + "metadata.root": { children: [{ + "object.entity": { name: "Account", children: [ + { "field.long": { name: "id" } }, + { "field.string": { name: "name" } }, + ...JSON.parse(fields), + { "source.rdb": { name: "src", "@table": "accounts" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] }, + }] }, + }))])).root; +} + +const NICKNAME = '[{"field.string":{"name":"nickname"}}]'; + +function withViews(s: SchemaSnapshot, views: ViewDescriptor[]): SchemaSnapshot { + return { ...s, views }; +} + +for (const dialect of ["postgres", "sqlite"] as const) { + describe(`drop-column under a view the tool does not own (${dialect})`, () => { + async function plan(views: ViewDescriptor[], unmanagedNames: string[], allow = {}) { + const prior = withViews(buildExpectedSchema(await load(NICKNAME), { dialect }), views); + const next = buildExpectedSchema(await load("[]"), { dialect }); + return diff({ expected: next, actual: prior, dialect, allow: { dropColumn: true, ...allow }, unmanagedNames }); + } + const schema = dialect === "postgres" ? "public" : undefined; + const view = (sql: string | undefined): ViewDescriptor => ({ + name: "v_hand", ...(schema !== undefined ? { schema } : {}), ...(sql !== undefined ? { sql } : {}), + }); + const id = "public.v_hand"; // viewIdentity defaults an absent schema to "public" + + test("an @unmanaged view that reads the column blocks the drop and is named", async () => { + const r = await plan([view("SELECT id, nickname FROM accounts")], [id]); + expect(r.blocked.length).toBe(1); + const status = r.blocked[0]!.status; + expect(status).toMatchObject({ state: "blocked", blockedBy: "dropColumnReadByView" }); + expect((status as { blockedReason: string }).blockedReason).toContain("v_hand"); + expect((status as { blockedReason: string }).blockedReason).toContain("accounts.nickname"); + }); + + test("allow.dropColumnReadByView lifts the block", async () => { + const r = await plan([view("SELECT id, nickname FROM accounts")], [id], { dropColumnReadByView: true }); + expect(r.blocked).toEqual([]); + }); + + test("an @unmanaged view that does not read the column does not block", async () => { + const r = await plan([view("SELECT id, name FROM accounts")], [id]); + expect(r.blocked).toEqual([]); + }); + + test("a hand-made view (declared by no metadata) that reads the column blocks the drop while its drop-view is blocked", async () => { + const r = await plan([view("SELECT id, nickname FROM accounts")], []); + const dropColumn = r.changes.find((c) => c.kind === "drop-column")!; + expect(dropColumn.status).toMatchObject({ state: "blocked", blockedBy: "dropColumnReadByView" }); + expect((dropColumn.status as { blockedReason: string }).blockedReason).toContain("v_hand"); + }); + + test("a hand-made view whose drop-view is allowed is dropped around the change, so it does not block the drop", async () => { + const r = await plan([view("SELECT id, nickname FROM accounts")], [], { dropView: true }); + expect(r.blocked).toEqual([]); + }); + + test("a view with no recorded body does not block", async () => { + const r = await plan([view(undefined)], [id]); + expect(r.blocked).toEqual([]); + }); + }); +} + +describe("sqlite down of drop-column", () => { + test("re-adds the column with its type and says the data is not restored", async () => { + const prior = buildExpectedSchema(await load(NICKNAME), { dialect: "sqlite" }); + const next = buildExpectedSchema(await load("[]"), { dialect: "sqlite" }); + const r = await diff({ expected: next, actual: prior, dialect: "sqlite", allow: { dropColumn: true } }); + const { up, down } = emit(r.changes, { dialect: "sqlite" }); + expect(up).toContain('DROP COLUMN "nickname"'); + expect(down).toContain('ALTER TABLE "accounts" ADD COLUMN "nickname"'); + expect(down).toMatch(/column data is not restored/i); + expect(down).not.toMatch(/TODO/); + }); + + test("D1 renders the same down", async () => { + const prior = buildExpectedSchema(await load(NICKNAME), { dialect: "d1" }); + const next = buildExpectedSchema(await load("[]"), { dialect: "d1" }); + const r = await diff({ expected: next, actual: prior, dialect: "d1", allow: { dropColumn: true } }); + const { down } = emit(r.changes, { dialect: "d1", expectedSchema: next, actualSchema: prior }); + expect(down).toContain('ALTER TABLE "accounts" ADD COLUMN "nickname"'); + }); + + test("a NOT NULL column with no default returns nullable, since SQLite cannot add it otherwise", async () => { + const prior = buildExpectedSchema(await load('[{"field.string":{"name":"nickname","@required":true}}]'), { dialect: "sqlite" }); + const next = buildExpectedSchema(await load("[]"), { dialect: "sqlite" }); + const r = await diff({ expected: next, actual: prior, dialect: "sqlite", allow: { dropColumn: true } }); + const { down } = emit(r.changes, { dialect: "sqlite" }); + expect(down).toMatch(/ADD COLUMN "nickname" \w+(\(\d+\))?;/); + expect(down).not.toContain("NOT NULL;"); + expect(down).toMatch(/NOT NULL with no default/); + }); + test("rollback applies to a populated table when the dropped column had an expression or uuid default", async () => { + // Oracle: SQLite itself refuses ADD COLUMN with a non-constant default on a table holding rows. + const prior = buildExpectedSchema(await load( + '[{"field.timestamp":{"name":"createdAt","@required":true}},{"field.uuid":{"name":"token","@required":true}}]', + ), { dialect: "sqlite" }); + const createdAt = prior.tables[0]!.columns.find((c) => c.name === "created_at")!; + createdAt.default = { kind: "expr", value: "now()" }; + const token = prior.tables[0]!.columns.find((c) => c.name === "token")!; + token.identity = "uuid"; + delete token.default; + const next = buildExpectedSchema(await load("[]"), { dialect: "sqlite" }); + const r = await diff({ expected: next, actual: prior, dialect: "sqlite", allow: { dropColumn: true } }); + const { down } = emit(r.changes, { dialect: "sqlite" }); + + const db = new Database(":memory:"); + db.run('CREATE TABLE "accounts" ("id" INTEGER PRIMARY KEY, "name" TEXT)'); + db.run(`INSERT INTO "accounts" ("name") VALUES ('a')`); + expect(() => db.exec(down)).not.toThrow(); + const cols = (db.query('PRAGMA table_info("accounts")').all() as { name: string }[]).map((c) => c.name); + expect(cols).toContain("created_at"); + expect(cols).toContain("token"); + expect(down).toMatch(/non-constant default/); + }); +}); diff --git a/server/typescript/packages/sdk/src/config.ts b/server/typescript/packages/sdk/src/config.ts index c33e5397b..40d127843 100644 --- a/server/typescript/packages/sdk/src/config.ts +++ b/server/typescript/packages/sdk/src/config.ts @@ -45,6 +45,7 @@ export const AllowTokenEnum = z.enum([ "drop-check", "drop-view", "drop-view-cascade", + "drop-column-read-by-view", "adopt-view", "nullable-to-not-null", "drop-identity-default",