From 4c620517ca8bc15390b9a65d7449de5d9ecdb732 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 09:47:43 +0200 Subject: [PATCH 01/55] docs(projects): slice 2 spec, rename-table migration operation driven by a migration new flag Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/rename-table-operation/spec.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md diff --git a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md new file mode 100644 index 000000000000..40bb2abd7598 --- /dev/null +++ b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md @@ -0,0 +1,52 @@ +# Slice spec — Rename-table migration operation + +**Project:** `projects/psl-verbatim-table-names/` · **Slice 2** · **Branch:** `psl-verbatim-rename-table` (from `main`) + +## At a glance + +Today a model whose table name changes plans as `DropTable` plus `CreateTable`, and the rows are gone. After this slice: + +```bash +prisma migration new --name rename-user-profile --rename-table userProfile=UserProfile +``` + +plans one `renameTable` operation followed by whatever else the diff needs, `migrate` runs `ALTER TABLE "userProfile" RENAME TO "UserProfile"`, and the rows survive. The `MIGRATION.TABLE_NAME_CASE_CHANGED` guard's second remedy points at this flag instead of a by-hand `ALTER TABLE`. + +## Chosen design + +**Intent comes from the CLI, never from inference.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The operator states it: `--rename-table =`, repeatable, on `migration new` and on `migration plan` (so a plan can be previewed with the same flags). Either side may be schema-qualified, `auth.userProfile=UserProfile`; an unqualified name means the target's default namespace. Nothing is added to the schema; there is no attribute and no prompt. + +**Rename intents are applied to the previous schema before the diff runs.** Rather than pairing issues after the fact, the planner takes the previous state (the prior contract's schema IR, or the introspected live schema) and renames the stated tables in it, then runs the ordinary diff against the next contract, then prepends one `RenameTableCall` per intent to the operations. The differ therefore sees the table under its new name and plans any column, index or constraint changes on it normally, and the case-change guard never sees a drop and create pair for it. Existing index and check rename pairing continues to work on the renamed table, since it pairs by wire-name hash. + +**An intent that does not match is a planning failure**, reported through the planner's existing failure result as a conflict: `from` must exist in the previous state, `to` must not, and `to` must exist in the next contract. Silent acceptance of a stale flag would hide a mistake. + +**The operation.** `renameTable` in the Postgres and SQLite operation sets, following the `renameCheckConstraint` precedent (PR #29894) end to end: op builder with precheck (`from` exists, `to` absent), execute (`ALTER TABLE ... RENAME TO ...`, schema-qualified on Postgres), postcheck; `RenameTableCall` in the op-factory call union with `renderTypeScript` and import requirements so a hand-written migration can call it; the migration facade method; the public op-factory export. Operation class `widening`, idempotency class per ADR 038 (effect-idempotent with equivalence check: pre-state compatible if `from` or `to` already holds the table). Under an `additive`-only policy the op is refused the same way other `widening` ops are, not degraded to drop and create. + +**Guard remedy.** `table-name-case-guard.ts` second way out becomes: `prisma migration new --rename-table "="` (Postgres and SQLite), keeping the by-hand `ALTER TABLE` sentence only for Mongo. The error reference entry and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` are updated to match. + +## Scope + +**In:** op builder, call, facade, export, TypeScript rendering for Postgres and SQLite; the pre-diff rename application and the unmatched-intent conflict in the shared SQL family planner code, wired in both target planners; CLI flag parsing and threading for `migration new` and `migration plan`; guard remedy text; error reference; fragment text; tests below. + +**Out:** `db update` and `db init` (no flag; the guard still protects them); Mongo; any rename inference; renaming columns; a schema attribute. + +## Tests, all red before their change + +- Op level, both targets: `renameTable` renders the expected SQL; precheck fails when `from` is missing or `to` exists; `renderTypeScript` round-trips through the migration file parser. +- Planner, both targets: with one intent and otherwise identical tables, the plan is exactly one rename op; with an intent and an added column, the plan is the rename op followed by the add-column op on the new name; an intent whose `from` is absent, or whose `to` already exists, yields a failure conflict naming it; the case-change guard does not fire when the pair is covered by an intent and still fires when it is not; an `additive`-only policy refuses the rename as it refuses other widening ops. +- CLI: `--rename-table` parses repeated and schema-qualified values, rejects a malformed value with a clear error, and reaches the planner options on both commands. +- End to end, Postgres (PGlite) and SQLite, under `test/integration/test/cli-journeys/`: create a table with rows under the old name, change the model's table name, run `migration new --rename-table` then `migrate`, and prove the rows are present under the new name and `db verify --schema-only` is clean. +- Guard text: the existing guard tests pin the new remedy string. + +## Done conditions + +- Every test above is green; `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm lint`, `pnpm lint:deps`, `pnpm lint:casts` (delta 0), `pnpm fixtures:check`, `pnpm check:error-reference` pass on the final tree. +- `pnpm check:upgrade-coverage --mode pr --prev origin/main --head HEAD` passes; if it demands a fragment for the app audience (the flag is additive, so probably not), write a truthful one. +- The project DoD line "a model rename in PSL plans as a single rename-table operation on Postgres and SQLite, and the rows survive" is met by the end-to-end test. + +## Dispatch plan + +1. Red tests: op-level, planner, CLI parsing, and the two journey tests, committed alone with their red logs. +2. The operation and its wiring in both targets, plus TypeScript rendering; op-level tests green. +3. Pre-diff rename application, unmatched-intent conflict, guard interaction, CLI flag threading; planner and CLI tests green. +4. Journeys green, guard remedy and docs and fragments updated, full gate, push (`git push -u bot psl-verbatim-rename-table`). The orchestrator opens the PR. From 4cf49905a4fc821474499028a5d1559529bdfadd Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 10:11:35 +0200 Subject: [PATCH 02/55] test: red tests for the rename-table migration operation and --rename-table flag Op-level tests for renameTable on Postgres and SQLite, planner tests for the pre-diff rename intents and the unmatched-intent conflict, family tests for the intent application, CLI tests for --rename-table parsing and threading on migration plan and migration new, journeys for both targets, and the guard tests now pin the --rename-table remedy. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../orm/migration-rename-table-flag.test.ts | 193 +++++++++++ .../cli/test/utils/rename-table-flag.test.ts | 81 +++++ .../test/table-rename-intents.test.ts | 326 ++++++++++++++++++ .../planner.table-name-case-guard.test.ts | 5 +- .../test/migrations/rename-table-ops.test.ts | 120 +++++++ .../migrations/rename-table-planner.test.ts | 284 +++++++++++++++ .../test/migrations/render-typescript.test.ts | 2 + .../planner.table-name-case-guard.test.ts | 5 +- .../test/migrations/rename-table-ops.test.ts | 89 +++++ .../migrations/rename-table-planner.test.ts | 254 ++++++++++++++ test/integration/test/cli-journeys/README.md | 2 + .../rename-table-migration.e2e.test.ts | 208 +++++++++++ .../rename-table-migration.sqlite.e2e.test.ts | 176 ++++++++++ .../contract-rename-table-from.prisma | 8 + .../contract-rename-table-to.prisma | 6 + .../test/utils/journey-test-helpers.ts | 2 + 16 files changed, 1759 insertions(+), 2 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts create mode 100644 packages/2-sql/9-family/test/table-rename-intents.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts create mode 100644 test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts create mode 100644 test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-from.prisma create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-to.prisma diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts new file mode 100644 index 000000000000..8a45e11dcbed --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts @@ -0,0 +1,193 @@ +/** + * `--rename-table` is parsed once, the same way, on `migration plan` and + * `migration new`, and the parsed intents reach each command's control + * operation. The operations themselves are doubles here; what they do with the + * intents is covered by the planner tests and the journeys. + */ + +import { rmSync, writeFileSync } from 'node:fs'; +import { ok } from '@internal/utils/result'; +import type { StreamEvent } from '@prisma/cli-engine'; +import { createTestCli } from '@prisma/cli-engine/testing'; +import { join } from 'pathe'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ControlClient } from '../../src/control-api/types'; +import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; + +const mocks = vi.hoisted(() => ({ + executeMigrationPlanCommand: vi.fn(), + executeMigrationNewCommand: vi.fn(), +})); + +vi.mock('../../src/control-api/operations/migration-plan', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, executeMigrationPlanCommand: mocks.executeMigrationPlanCommand }; +}); + +vi.mock('../../src/control-api/operations/migration-new', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, executeMigrationNewCommand: mocks.executeMigrationNewCommand }; +}); + +const { BIN_GROUPS, createBinCommands } = await import('../../src/orm/cli'); + +const commands = createBinCommands(() => ({}) as unknown as ControlClient); + +const DESCRIPTOR = { familyId: 'sql', targetId: 'postgres', version: '1.0.0', create: () => ({}) }; +const TO_HASH = 'b'.repeat(64); + +let projectDir: string; +const projectDirs: string[] = []; + +afterAll(() => { + for (const dir of projectDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +beforeEach(() => { + projectDir = createTestProjectDir('orm-migration-rename-table'); + projectDirs.push(projectDir); + writeProjectManifest(projectDir); + writeFileSync( + join(projectDir, 'contract.json'), + JSON.stringify({ storage: { storageHash: TO_HASH } }), + ); + mocks.executeMigrationPlanCommand.mockReset().mockResolvedValue( + ok({ + ok: true, + noOp: true, + from: null, + to: TO_HASH, + operations: [], + emittedExtensionDirs: [], + summary: 'No changes detected between contracts', + timings: { total: 1 }, + }), + ); + mocks.executeMigrationNewCommand.mockReset().mockResolvedValue( + ok({ + ok: true, + dir: 'migrations/app/20260101T0000_x', + from: null, + to: TO_HASH, + summary: 'Scaffolded migration at migrations/app/20260101T0000_x', + }), + ); +}); + +function ormConfig(): Record { + return { + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '1.0.0', + emission: {}, + create: () => ({}), + }, + target: { ...DESCRIPTOR, kind: 'target', id: 'postgres', migrations: {} }, + adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, + driver: { ...DESCRIPTOR, kind: 'driver', id: 'pg-driver' }, + contract: { + source: { format: 'typescript', inputs: [], load: async () => ({}) }, + output: join(projectDir, 'contract.json'), + }, + }; +} + +function harness() { + return createTestCli({ commands, groups: BIN_GROUPS, config: { orm: ormConfig() } }); +} + +function envelopeOf(json: readonly StreamEvent[]): unknown { + const terminal = json.at(-1); + return terminal?.kind === 'result' ? terminal.envelope : undefined; +} + +const EXPECTED_RENAMES = [ + { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, + { from: { namespaceId: 'auth', name: 'orderLine' }, to: { name: 'OrderLine' } }, +]; + +describe('migration plan --rename-table', () => { + it('passes every parsed intent to the plan operation', async () => { + const run = await harness().run( + [ + 'migration', + 'plan', + '--json', + '--rename-table', + 'userProfile=UserProfile', + '--rename-table', + 'auth.orderLine=OrderLine', + ], + { cwd: projectDir }, + ); + + expect(run.exitCode).toBe(0); + expect(mocks.executeMigrationPlanCommand).toHaveBeenCalledTimes(1); + expect(mocks.executeMigrationPlanCommand.mock.calls[0]?.[0]).toMatchObject({ + renames: EXPECTED_RENAMES, + }); + }); + + it('passes no intents when the flag is absent', async () => { + const run = await harness().run(['migration', 'plan', '--json'], { cwd: projectDir }); + + expect(run.exitCode).toBe(0); + expect(mocks.executeMigrationPlanCommand.mock.calls[0]?.[0]).not.toHaveProperty('renames'); + }); + + it('rejects a malformed value before the operation runs', async () => { + const run = await harness().run( + ['migration', 'plan', '--json', '--rename-table', 'userProfile'], + { cwd: projectDir }, + ); + + expect(run.exitCode).not.toBe(0); + expect(envelopeOf(run.json)).toMatchObject({ + ok: false, + error: { code: 'CLI.INVALID_RENAME_TABLE_FLAG' }, + }); + expect(mocks.executeMigrationPlanCommand).not.toHaveBeenCalled(); + }); +}); + +describe('migration new --rename-table', () => { + it('passes every parsed intent to the scaffold operation', async () => { + const run = await harness().run( + [ + 'migration', + 'new', + '--json', + '--rename-table', + 'userProfile=UserProfile', + '--rename-table', + 'auth.orderLine=OrderLine', + ], + { cwd: projectDir }, + ); + + expect(run.exitCode).toBe(0); + expect(mocks.executeMigrationNewCommand).toHaveBeenCalledTimes(1); + expect(mocks.executeMigrationNewCommand.mock.calls[0]?.[0]).toMatchObject({ + renames: EXPECTED_RENAMES, + }); + }); + + it('rejects a malformed value before the operation runs', async () => { + const run = await harness().run(['migration', 'new', '--json', '--rename-table', 'a=b=c'], { + cwd: projectDir, + }); + + expect(run.exitCode).not.toBe(0); + expect(envelopeOf(run.json)).toMatchObject({ + ok: false, + error: { code: 'CLI.INVALID_RENAME_TABLE_FLAG' }, + }); + expect(mocks.executeMigrationNewCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts b/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts new file mode 100644 index 000000000000..9c50e5d30ffb --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { parseRenameTableFlags } from '../../src/utils/rename-table-flag'; + +function failureOf(values: readonly string[]) { + const result = parseRenameTableFlags(values); + expect(result.ok).toBe(false); + return result.ok ? undefined : result.failure.toEnvelope(); +} + +describe('parseRenameTableFlags', () => { + it('parses an unqualified pair', () => { + expect(parseRenameTableFlags(['userProfile=UserProfile'])).toEqual({ + ok: true, + value: [{ from: { name: 'userProfile' }, to: { name: 'UserProfile' } }], + }); + }); + + it('parses a namespace qualifier on either side', () => { + expect( + parseRenameTableFlags(['auth.userProfile=UserProfile', 'orderLine=sales.OrderLine']), + ).toEqual({ + ok: true, + value: [ + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, + { from: { name: 'orderLine' }, to: { namespaceId: 'sales', name: 'OrderLine' } }, + ], + }); + }); + + it('keeps repeated values in the order given', () => { + const result = parseRenameTableFlags(['a=b', 'c=d']); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.map((intent) => `${intent.from.name}>${intent.to.name}`)).toEqual([ + 'a>b', + 'c>d', + ]); + }); + + it('returns no intents for no values', () => { + expect(parseRenameTableFlags([])).toEqual({ ok: true, value: [] }); + }); + + it.each([ + ['no separator', 'userProfile'], + ['empty left side', '=UserProfile'], + ['empty right side', 'userProfile='], + ['two separators', 'a=b=c'], + ['empty namespace', '.userProfile=UserProfile'], + ['empty name after the namespace', 'auth.=UserProfile'], + ['blank value', ' '], + ])('rejects a malformed value (%s) with CLI.INVALID_RENAME_TABLE_FLAG', (_label, value) => { + const envelope = failureOf([value]); + expect(envelope).toMatchObject({ + code: 'CLI.INVALID_RENAME_TABLE_FLAG', + summary: expect.stringContaining(value.trim() === '' ? '--rename-table' : value), + }); + expect(envelope?.fix).toContain('='); + }); + + it('rejects a value whose two sides are the same table', () => { + expect(failureOf(['userProfile=userProfile'])).toMatchObject({ + code: 'CLI.INVALID_RENAME_TABLE_FLAG', + summary: expect.stringContaining('userProfile=userProfile'), + }); + }); + + it('rejects two values that rename the same table', () => { + expect(failureOf(['userProfile=UserProfile', 'userProfile=Profile'])).toMatchObject({ + code: 'CLI.INVALID_RENAME_TABLE_FLAG', + summary: expect.stringContaining('userProfile'), + }); + }); + + it('rejects two values that rename to the same table', () => { + expect(failureOf(['userProfile=UserProfile', 'profile=UserProfile'])).toMatchObject({ + code: 'CLI.INVALID_RENAME_TABLE_FLAG', + summary: expect.stringContaining('UserProfile'), + }); + }); +}); diff --git a/packages/2-sql/9-family/test/table-rename-intents.test.ts b/packages/2-sql/9-family/test/table-rename-intents.test.ts new file mode 100644 index 000000000000..7429bc4adf22 --- /dev/null +++ b/packages/2-sql/9-family/test/table-rename-intents.test.ts @@ -0,0 +1,326 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { StorageEntityRename } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + isMaterializedSqlNamespace, + SqlStorage, + StorageTable, + type StorageTableInput, + toStorageTypeInstance, +} from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; +import { + applyTableRenameIntents, + TABLE_RENAME_UNMATCHED_CODE, +} from '../src/core/migrations/table-rename-intents'; + +const idColumn = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; + +function table(extra: Partial = {}): StorageTable { + return new StorageTable({ + columns: { id: idColumn }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + ...extra, + }); +} + +function contractOf( + namespaces: Readonly>>>, + hashSeed = 'seed', +): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + types: { Money: toStorageTypeInstance({ codecId: 'pg/numeric@1', nativeType: 'numeric' }) }, + namespaces: Object.fromEntries( + Object.entries(namespaces).map(([id, tables]) => [ + id, + createTestSqlNamespace({ id, entries: { table: tables } }), + ]), + ), + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +const rename = (from: string, to: string): StorageEntityRename => ({ + from: { name: from }, + to: { name: to }, +}); + +function tablesOf(contract: Contract, namespaceId: string): readonly string[] { + return Object.keys(contract.storage.namespaces[namespaceId]?.entries.table ?? {}); +} + +describe('applyTableRenameIntents', () => { + it('re-keys the table under its new name and retargets every foreign key that named it', () => { + const fromContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { + userProfile: table({ + columns: { id: idColumn, parentId: { ...idColumn, nullable: true } }, + foreignKeys: [ + { + source: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['parentId'], + }, + target: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['id'], + }, + name: 'userProfile_parent_fkey', + }, + ], + }), + post: table({ + columns: { id: idColumn, authorId: idColumn }, + foreignKeys: [ + { + source: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'post', + columns: ['authorId'], + }, + target: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['id'], + }, + }, + ], + }), + }, + }); + const toContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), post: table() }, + }); + + const result = applyTableRenameIntents({ + fromContract, + toContract, + intents: [rename('userProfile', 'UserProfile')], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.renames).toEqual([ + { namespaceId: UNBOUND_NAMESPACE_ID, from: 'userProfile', to: 'UserProfile' }, + ]); + const renamed = result.value.contract; + expect(tablesOf(renamed, UNBOUND_NAMESPACE_ID)).toEqual(['UserProfile', 'post']); + const renamedTable = + renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.UserProfile; + expect(renamedTable).toBeInstanceOf(StorageTable); + expect(Object.keys(renamedTable?.columns ?? {})).toEqual(['id', 'parentId']); + expect( + renamedTable?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName, fk.name]), + ).toEqual([['UserProfile', 'UserProfile', 'userProfile_parent_fkey']]); + const post = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.post; + expect(post?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName])).toEqual([ + ['post', 'UserProfile'], + ]); + }); + + it('keeps the namespace class, the storage hash and types, and leaves the input untouched', () => { + const fromContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { userProfile: table() }, + audit: { log: table() }, + }); + const toContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table() }, + audit: { log: table() }, + }); + + const result = applyTableRenameIntents({ + fromContract, + toContract, + intents: [rename('userProfile', 'UserProfile')], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const renamed = result.value.contract; + const namespace = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]; + expect(isMaterializedSqlNamespace(namespace)).toBe(true); + expect(Object.getPrototypeOf(namespace)).toBe( + Object.getPrototypeOf(fromContract.storage.namespaces[UNBOUND_NAMESPACE_ID]), + ); + expect(namespace?.id).toBe(UNBOUND_NAMESPACE_ID); + expect(renamed.storage.namespaces.audit).toBe(fromContract.storage.namespaces.audit); + expect(renamed.storage.storageHash).toBe(fromContract.storage.storageHash); + expect(renamed.storage.types).toEqual(fromContract.storage.types); + expect(renamed.profileHash).toBe(fromContract.profileHash); + expect(tablesOf(fromContract, UNBOUND_NAMESPACE_ID)).toEqual(['userProfile']); + }); + + it('resolves an unqualified name in whichever namespace declares it', () => { + const fromContract = contractOf({ auth: { userProfile: table() }, app: { post: table() } }); + const toContract = contractOf({ auth: { UserProfile: table() }, app: { post: table() } }); + + const result = applyTableRenameIntents({ + fromContract, + toContract, + intents: [rename('userProfile', 'UserProfile')], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.renames).toEqual([ + { namespaceId: 'auth', from: 'userProfile', to: 'UserProfile' }, + ]); + expect(tablesOf(result.value.contract, 'auth')).toEqual(['UserProfile']); + }); + + it('honours a namespace qualifier on either side', () => { + const fromContract = contractOf({ + auth: { userProfile: table() }, + app: { userProfile: table() }, + }); + const toContract = contractOf({ + auth: { UserProfile: table() }, + app: { userProfile: table() }, + }); + + const result = applyTableRenameIntents({ + fromContract, + toContract, + intents: [ + { + from: { namespaceId: 'auth', name: 'userProfile' }, + to: { namespaceId: 'auth', name: 'UserProfile' }, + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.renames).toEqual([ + { namespaceId: 'auth', from: 'userProfile', to: 'UserProfile' }, + ]); + expect(tablesOf(result.value.contract, 'app')).toEqual(['userProfile']); + }); + + it('returns nothing to do when there are no intents', () => { + const fromContract = contractOf({ [UNBOUND_NAMESPACE_ID]: { userProfile: table() } }); + + const result = applyTableRenameIntents({ fromContract, toContract: fromContract, intents: [] }); + + expect(result).toEqual({ ok: true, value: { contract: fromContract, renames: [] } }); + }); + + describe('unmatched intents', () => { + const fromContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { userProfile: table(), Account: table() }, + }); + const toContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), Account: table() }, + }); + + function conflictsFor(intents: readonly StorageEntityRename[]) { + const result = applyTableRenameIntents({ fromContract, toContract, intents }); + expect(result.ok).toBe(false); + return result.ok ? [] : result.failure; + } + + it('reports a previous table that does not exist', () => { + expect(conflictsFor([rename('ghost', 'UserProfile')])).toEqual([ + expect.objectContaining({ + kind: 'tableRenameUnmatched', + summary: expect.stringContaining('table "ghost" does not exist in the previous contract'), + meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: 'ghost', to: 'UserProfile' }, + }), + ]); + }); + + it('reports a new name that already exists in the previous state', () => { + expect(conflictsFor([rename('userProfile', 'Account')])).toEqual([ + expect.objectContaining({ + kind: 'tableRenameUnmatched', + summary: expect.stringContaining( + 'table "Account" already exists in the previous contract', + ), + }), + ]); + }); + + it('reports a new name that is absent from the next contract', () => { + expect(conflictsFor([rename('userProfile', 'Profile')])).toEqual([ + expect.objectContaining({ + kind: 'tableRenameUnmatched', + summary: expect.stringContaining('table "Profile" does not exist in the next contract'), + }), + ]); + }); + + it('reports a name whose namespace qualifier the contract does not have', () => { + expect( + conflictsFor([ + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, + ]), + ).toEqual([ + expect.objectContaining({ + kind: 'tableRenameUnmatched', + summary: expect.stringContaining('"auth.userProfile"'), + }), + ]); + }); + + it('reports every unmatched intent, one conflict each, and applies none', () => { + const conflicts = conflictsFor([ + rename('userProfile', 'UserProfile'), + rename('ghost', 'UserProfile'), + rename('userProfile', 'Profile'), + ]); + expect(conflicts.map((conflict) => conflict.meta)).toEqual([ + { code: TABLE_RENAME_UNMATCHED_CODE, from: 'ghost', to: 'UserProfile' }, + { code: TABLE_RENAME_UNMATCHED_CODE, from: 'userProfile', to: 'Profile' }, + ]); + }); + + it('reports an unqualified name declared in more than one namespace', () => { + const ambiguousFrom = contractOf({ + auth: { userProfile: table() }, + app: { userProfile: table() }, + }); + const ambiguousTo = contractOf({ + auth: { UserProfile: table() }, + app: { userProfile: table() }, + }); + const result = applyTableRenameIntents({ + fromContract: ambiguousFrom, + toContract: ambiguousTo, + intents: [rename('userProfile', 'UserProfile')], + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure[0]?.summary).toContain('more than one namespace'); + }); + + it('rejects intents without a prior contract to apply them to', () => { + const result = applyTableRenameIntents({ + fromContract: null, + toContract, + intents: [rename('userProfile', 'UserProfile')], + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.map((conflict) => conflict.kind)).toEqual(['unsupportedOperation']); + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index 99908e5ac51a..769b0bc6b820 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -167,7 +167,10 @@ describe('Postgres planner table-name case guard', () => { }), ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); - expect(result.conflicts[0]?.why).toContain('ALTER TABLE "userProfile" RENAME TO "UserProfile"'); + expect(result.conflicts[0]?.why).toContain( + 'migration plan --rename-table "userProfile=UserProfile"', + ); + expect(result.conflicts[0]?.why).not.toContain('ALTER TABLE'); }); it('still refuses when UserProfile also gained a column', () => { diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts new file mode 100644 index 000000000000..91b598116b3d --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts @@ -0,0 +1,120 @@ +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { tableExistsAst } from '../../src/contract-free/checks'; +import { RenameTableCall } from '../../src/core/migrations/op-factory-call'; +import { renameTable } from '../../src/core/migrations/operations/tables'; + +function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { + const received: unknown[] = []; + const lowerer: ExecuteRequestLowerer = { + lower: () => Object.freeze({ sql: 'UNUSED', params: Object.freeze([]) }), + lowerToExecuteRequest: async (ast) => { + received.push(ast); + return Object.freeze({ + sql: `LOWERED ${received.length}`, + params: Object.freeze([`p${received.length}`]), + }); + }, + }; + return { lowerer, received }; +} + +describe('renameTable (postgres)', () => { + it('renders a schema-qualified ALTER TABLE ... RENAME TO', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await renameTable('auth', 'userProfile', 'UserProfile', lowerer); + + expect(op).toMatchObject({ + id: 'renameTable.userProfile', + label: 'Rename table "userProfile" to "UserProfile"', + operationClass: 'widening', + target: { + id: 'postgres', + details: { schema: 'auth', objectType: 'table', name: 'UserProfile' }, + }, + execute: [ + { + description: 'rename table "userProfile" to "UserProfile"', + sql: 'ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"', + }, + ], + }); + }); + + it('renders an unqualified statement for the unbound namespace', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await renameTable(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile', lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "userProfile" RENAME TO "UserProfile"', + ]); + }); + + it('prechecks that the old table exists and the new one does not, then postchecks the new one', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await renameTable('public', 'userProfile', 'UserProfile', lowerer); + + expect(received).toEqual([ + tableExistsAst('public', 'userProfile').tablePresent(), + tableExistsAst('public', 'UserProfile').tableAbsent(), + tableExistsAst('public', 'UserProfile').tablePresent(), + ]); + expect(op.precheck).toEqual([ + { description: 'ensure table "userProfile" exists', sql: 'LOWERED 1', params: ['p1'] }, + { + description: 'ensure table "UserProfile" does not exist', + sql: 'LOWERED 2', + params: ['p2'], + }, + ]); + expect(op.postcheck).toEqual([ + { description: 'verify table "UserProfile" exists', sql: 'LOWERED 3', params: ['p3'] }, + ]); + }); +}); + +describe('RenameTableCall (postgres)', () => { + it('is a widening renameTable call whose contract-side identity is the new name', () => { + const call = new RenameTableCall('public', 'userProfile', 'UserProfile'); + + expect(call).toMatchObject({ + factoryName: 'renameTable', + operationClass: 'widening', + schemaName: 'public', + oldTableName: 'userProfile', + tableName: 'UserProfile', + label: 'Rename table "userProfile" to "UserProfile"', + }); + }); + + it('toOp() delegates to renameTable', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await new RenameTableCall('auth', 'userProfile', 'UserProfile').toOp(lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"', + ]); + }); + + it('toOp() without a lowerer reports MIGRATION.POSTGRES_CONTROL_STACK_MISSING', async () => { + const call = new RenameTableCall('public', 'userProfile', 'UserProfile'); + await expect(call.toOp()).rejects.toMatchObject({ + code: 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', + meta: { factory: 'RenameTableCall' }, + }); + }); + + it('renderTypeScript() emits the facade call, schema-qualified only when bound', () => { + expect(new RenameTableCall('auth', 'userProfile', 'UserProfile').renderTypeScript()).toBe( + 'this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', + ); + expect( + new RenameTableCall(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile').renderTypeScript(), + ).toBe('this.renameTable({ table: "userProfile", to: "UserProfile" })'); + }); + + it('needs no facade import because the call is a method on the migration', () => { + expect(new RenameTableCall('public', 'a', 'b').importRequirements()).toEqual([]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts new file mode 100644 index 000000000000..6cdfbab5ad4b --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts @@ -0,0 +1,284 @@ +/** + * `--rename-table` intents on the Postgres planner: the stated tables are + * renamed in the previous contract before the diff, one `renameTable` op is + * prepended per intent, and an intent that matches neither side is a + * planning failure rather than a warning. + */ + +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import type { + MigrationOperationClass, + StorageEntityRename, +} from '@internal/framework-components/control'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { postgresResolveDefault } from '../../src/core/default-normalizer'; +import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { type PostgresContract, postgresCreateNamespace } from '../../src/core/postgres-schema'; +import { postgresRenderDefault } from '../../src/exports/control'; + +const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const ALL_CLASSES = ['additive', 'widening', 'destructive'] as const; +const ADDITIVE_ONLY = ['additive'] as const; + +interface TableSpec { + readonly extraColumn?: string; +} + +function storageTable(spec: TableSpec, tableName: string): StorageTable { + return new StorageTable({ + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + email: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + ...(spec.extraColumn === undefined + ? {} + : { [spec.extraColumn]: { nativeType: 'text', codecId: 'pg/text@1', nullable: true } }), + }, + primaryKey: { columns: ['id'], name: `${tableName}_pkey` }, + foreignKeys: [], + uniques: [], + indexes: [], + }); +} + +function contractOf( + tables: Readonly>, + hashSeed: string, + namespaceId: string = UNBOUND_NAMESPACE_ID, +): PostgresContract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [namespaceId]: postgresCreateNamespace({ + id: namespaceId, + entries: { + table: Object.fromEntries( + Object.entries(tables).map(([name, spec]) => [name, storageTable(spec, name)]), + ), + policy: {}, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +function schemaOf(contract: Contract | null) { + return contractToPostgresDatabaseSchemaNode( + contract === null ? null : { ...contract, target: 'postgres' as const }, + { + annotationNamespace: 'pg', + renderDefault: postgresRenderDefault, + resolveDefault: postgresResolveDefault, + }, + ); +} + +function plan(input: { + readonly from: PostgresContract | null; + readonly to: PostgresContract; + readonly renames?: readonly StorageEntityRename[]; + readonly policy?: readonly MigrationOperationClass[]; +}) { + const planner = createPostgresMigrationPlanner(stubLowerer); + return planner.plan({ + contract: input.to, + schema: schemaOf(input.from), + policy: { allowedOperationClasses: [...(input.policy ?? ALL_CLASSES)] }, + fromContract: input.from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + ...(input.renames === undefined ? {} : { renames: input.renames }), + }); +} + +const rename = (from: string, to: string): StorageEntityRename => ({ + from: { name: from }, + to: { name: to }, +}); + +describe('Postgres planner rename-table intents', () => { + it('plans exactly one rename op when the tables are otherwise identical', async () => { + const from = contractOf({ userProfile: {} }, 'from'); + const to = contractOf({ UserProfile: {} }, 'to'); + + const result = plan({ from, to, renames: [rename('userProfile', 'UserProfile')] }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const ops = await Promise.all(result.plan.operations); + expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); + expect(ops[0]?.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "userProfile" RENAME TO "UserProfile"', + ]); + expect(ops[0]?.operationClass).toBe('widening'); + expect(result.plan.describe()).toEqual({ + from: from.storage.storageHash, + to: to.storage.storageHash, + }); + }); + + it('renders the rename as a facade call in the migration file', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + expect(result.plan.renderTypeScript()).toContain( + 'this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); + }); + + it('plans the rename first, then the added column on the new name', async () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: { extraColumn: 'nickname' } }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const ids = (await Promise.all(result.plan.operations)).map((op) => op.id); + expect(ids).toHaveLength(2); + expect(ids[0]).toBe('renameTable.userProfile'); + expect(ids[1]).toMatch(/^column\..*UserProfile\.nickname$/); + }); + + it('qualifies the statement with the schema when the intent names a bound namespace', async () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from', 'auth'), + to: contractOf({ UserProfile: {} }, 'to', 'auth'), + renames: [ + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, + ], + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const ops = await Promise.all(result.plan.operations); + expect(ops.map((op) => op.execute.map((step) => step.sql))).toEqual([ + ['ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"'], + ]); + }); + + it('applies several intents, one rename op each, in the order given', async () => { + const result = plan({ + from: contractOf({ userProfile: {}, orderLine: {} }, 'from'), + to: contractOf({ UserProfile: {}, OrderLine: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile'), rename('orderLine', 'OrderLine')], + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const ids = (await Promise.all(result.plan.operations)).map((op) => op.id); + expect(ids).toEqual(['renameTable.userProfile', 'renameTable.orderLine']); + }); + + it('fails when the intent names a previous table that does not exist', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('ghost', 'UserProfile')], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts).toEqual([ + expect.objectContaining({ + kind: 'tableRenameUnmatched', + summary: expect.stringContaining('"ghost"'), + meta: expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + from: 'ghost', + to: 'UserProfile', + }), + }), + ]); + }); + + it('fails when the new name already exists in the previous state', () => { + const result = plan({ + from: contractOf({ userProfile: {}, UserProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); + expect(result.conflicts[0]?.summary).toContain('already exists'); + }); + + it('fails when the new name is absent from the next contract', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ Account: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); + expect(result.conflicts[0]?.summary).toContain('"UserProfile"'); + }); + + it('fails when intents are given without a prior contract to apply them to', () => { + const result = plan({ + from: null, + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['unsupportedOperation']); + }); + + it('still fires the case-change guard when the pair is not covered by an intent', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableNameCaseChanged']); + }); + + it('refuses the rename under an additive-only policy like any other widening op', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + policy: ADDITIVE_ONLY, + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.summary)).toEqual([ + expect.stringContaining('requires class "widening"'), + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts index 05f896f58b0d..b1c96133087d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts @@ -44,6 +44,7 @@ import { RenameCheckConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, + RenameTableCall, SetDefaultCall, SetNotNullCall, } from '../../src/core/migrations/op-factory-call'; @@ -200,6 +201,7 @@ describe('renderCallsToTypeScript (postgres) — facade import surface', () => { ], ), new DropTableCall('public', 'stale'), + new RenameTableCall('public', 'stale', 'archived'), new AddColumnCall('public', 'note', col('nickname', 'text')), new DropColumnCall('public', 'note', 'nickname'), new AlterColumnTypeCall('public', 'note', 'kind', { diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts index 47b8190364c0..1e7d7f88382a 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts @@ -137,7 +137,10 @@ describe('SQLite planner table-name case guard', () => { }), ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); - expect(result.conflicts[0]?.why).toContain('ALTER TABLE "userProfile" RENAME TO "UserProfile"'); + expect(result.conflicts[0]?.why).toContain( + 'migration plan --rename-table "userProfile=UserProfile"', + ); + expect(result.conflicts[0]?.why).not.toContain('ALTER TABLE'); }); it('still refuses when UserProfile also gained a column', () => { diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts new file mode 100644 index 000000000000..8095aabf9d6b --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts @@ -0,0 +1,89 @@ +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { describe, expect, it } from 'vitest'; +import { tableExistsAst } from '../../src/contract-free/checks'; +import { RenameTableCall } from '../../src/core/migrations/op-factory-call'; + +function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { + const received: unknown[] = []; + const lowerer: ExecuteRequestLowerer = { + lower: () => Object.freeze({ sql: 'UNUSED', params: Object.freeze([]) }), + lowerToExecuteRequest: async (ast) => { + received.push(ast); + return Object.freeze({ + sql: `LOWERED ${received.length}`, + params: Object.freeze([`p${received.length}`]), + }); + }, + }; + return { lowerer, received }; +} + +describe('RenameTableCall (sqlite)', () => { + it('is a widening renameTable call whose contract-side identity is the new name', () => { + const call = new RenameTableCall('userProfile', 'UserProfile'); + + expect(call).toMatchObject({ + factoryName: 'renameTable', + operationClass: 'widening', + oldTableName: 'userProfile', + tableName: 'UserProfile', + label: 'Rename table userProfile to UserProfile', + }); + }); + + it('renders ALTER TABLE ... RENAME TO with existence prechecks and a postcheck', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); + + expect(received).toEqual([ + tableExistsAst('userProfile').tablePresent(), + tableExistsAst('UserProfile').tableAbsent(), + tableExistsAst('UserProfile').tablePresent(), + ]); + expect(op).toEqual({ + id: 'renameTable.userProfile', + label: 'Rename table userProfile to UserProfile', + summary: 'Renames table userProfile to UserProfile, keeping its rows', + operationClass: 'widening', + target: { + id: 'sqlite', + details: { schema: 'main', objectType: 'table', name: 'UserProfile' }, + }, + precheck: [ + { description: 'ensure table "userProfile" exists', sql: 'LOWERED 1', params: ['p1'] }, + { + description: 'ensure table "UserProfile" does not exist', + sql: 'LOWERED 2', + params: ['p2'], + }, + ], + execute: [ + { + description: 'rename table "userProfile" to "UserProfile"', + sql: 'ALTER TABLE "userProfile" RENAME TO "UserProfile"', + }, + ], + postcheck: [ + { description: 'verify table "UserProfile" exists', sql: 'LOWERED 3', params: ['p3'] }, + ], + }); + }); + + it('toOp() without a lowerer reports MIGRATION.SQLITE_CONTROL_STACK_MISSING', async () => { + const call = new RenameTableCall('userProfile', 'UserProfile'); + await expect(call.toOp()).rejects.toMatchObject({ + code: 'MIGRATION.SQLITE_CONTROL_STACK_MISSING', + meta: { factory: 'renameTable' }, + }); + }); + + it('renderTypeScript() emits the facade call', () => { + expect(new RenameTableCall('userProfile', 'UserProfile').renderTypeScript()).toBe( + 'this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); + }); + + it('needs no facade import because the call is a method on the migration', () => { + expect(new RenameTableCall('a', 'b').importRequirements()).toEqual([]); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts new file mode 100644 index 000000000000..c9dabd8418b2 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts @@ -0,0 +1,254 @@ +/** + * `--rename-table` intents on the SQLite planner: the stated tables are + * renamed in the previous contract before the diff, one `renameTable` op is + * prepended per intent, and an intent that matches neither side is a + * planning failure rather than a warning. + */ + +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import type { + MigrationOperationClass, + StorageEntityRename, +} from '@internal/framework-components/control'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; +import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; + +const stubLowerer: ExecuteRequestLowerer = { + lower: () => { + throw new Error('lower() called on stubLowerer — planner must use lowerToExecuteRequest()'); + }, + lowerToExecuteRequest: async () => ({ sql: '', params: [] }), +}; + +const ALL_CLASSES = ['additive', 'widening', 'destructive'] as const; +const ADDITIVE_ONLY = ['additive'] as const; + +interface TableSpec { + readonly extraColumn?: string; +} + +function storageTable(spec: TableSpec, tableName: string): StorageTable { + return new StorageTable({ + columns: { + id: { nativeType: 'integer', codecId: 'sqlite/integer@1', nullable: false }, + email: { nativeType: 'text', codecId: 'sqlite/text@1', nullable: false }, + ...(spec.extraColumn === undefined + ? {} + : { + [spec.extraColumn]: { nativeType: 'text', codecId: 'sqlite/text@1', nullable: true }, + }), + }, + primaryKey: { columns: ['id'], name: `${tableName}_pkey` }, + foreignKeys: [], + uniques: [], + indexes: [], + }); +} + +function contractOf( + tables: Readonly>, + hashSeed: string, +): Contract { + return { + target: 'sqlite', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: Object.fromEntries( + Object.entries(tables).map(([name, spec]) => [name, storageTable(spec, name)]), + ), + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +function plan(input: { + readonly from: Contract | null; + readonly to: Contract; + readonly renames?: readonly StorageEntityRename[]; + readonly policy?: readonly MigrationOperationClass[]; +}) { + const planner = createSqliteMigrationPlanner(stubLowerer); + return planner.plan({ + contract: input.to, + schema: sqliteContractToSchema(input.from), + policy: { allowedOperationClasses: [...(input.policy ?? ALL_CLASSES)] }, + fromContract: input.from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + ...(input.renames === undefined ? {} : { renames: input.renames }), + }); +} + +const rename = (from: string, to: string): StorageEntityRename => ({ + from: { name: from }, + to: { name: to }, +}); + +describe('SQLite planner rename-table intents', () => { + it('plans exactly one rename op when the tables are otherwise identical', async () => { + const from = contractOf({ userProfile: {} }, 'from'); + const to = contractOf({ UserProfile: {} }, 'to'); + + const result = plan({ from, to, renames: [rename('userProfile', 'UserProfile')] }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const ops = await Promise.all(result.plan.operations); + expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); + expect(ops[0]?.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "userProfile" RENAME TO "UserProfile"', + ]); + expect(ops[0]?.operationClass).toBe('widening'); + expect(result.plan.describe()).toEqual({ + from: from.storage.storageHash, + to: to.storage.storageHash, + }); + }); + + it('renders the rename as a facade call in the migration file', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + expect(result.plan.renderTypeScript()).toContain( + 'this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); + }); + + it('plans the rename first, then the added column on the new name', async () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: { extraColumn: 'nickname' } }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const ids = (await Promise.all(result.plan.operations)).map((op) => op.id); + expect(ids).toEqual(['renameTable.userProfile', 'column.UserProfile.nickname']); + }); + + it('fails when the intent names a previous table that does not exist', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('ghost', 'UserProfile')], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts).toEqual([ + expect.objectContaining({ + kind: 'tableRenameUnmatched', + summary: expect.stringContaining('"ghost"'), + meta: expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + from: 'ghost', + to: 'UserProfile', + }), + }), + ]); + }); + + it('fails when the new name already exists in the previous state', () => { + const result = plan({ + from: contractOf({ userProfile: {}, UserProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); + expect(result.conflicts[0]?.summary).toContain('already exists'); + }); + + it('fails when the new name is absent from the next contract', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ Account: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); + }); + + it('fails when the intent is qualified with a namespace SQLite does not have', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [ + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, + ], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); + }); + + it('fails when intents are given without a prior contract to apply them to', () => { + const result = plan({ + from: null, + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['unsupportedOperation']); + }); + + it('still fires the case-change guard when the pair is not covered by an intent', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableNameCaseChanged']); + }); + + it('refuses the rename under an additive-only policy like any other widening op', () => { + const result = plan({ + from: contractOf({ userProfile: {} }, 'from'), + to: contractOf({ UserProfile: {} }, 'to'), + renames: [rename('userProfile', 'UserProfile')], + policy: ADDITIVE_ONLY, + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts.map((conflict) => conflict.summary)).toEqual([ + expect.stringContaining('requires class "widening"'), + ]); + }); +}); diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 814ccaa777ca..45dd24c15a16 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -30,6 +30,8 @@ pnpm test:journeys | `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column, tests `--no-interactive` rejection, `--json` error envelope, and `--json -y` auto-accept. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | +| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile`, drop the model's `@@map`, confirm the bare plan is refused by the case-change guard and a stale `--rename-table` intent fails planning, then `migration plan --rename-table userProfile=UserProfile` plans exactly one `renameTable`, `migrate` applies it, the rows are present under `UserProfile`, and `db verify --schema-only` is clean. Second journey: the same rename through `migration new --rename-table`, whose scaffold carries the `renameTable` call and self-emits the SQL | +| `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the rename journey, driven through a file database and the `@internal/sqlite/config` facade config | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts new file mode 100644 index 000000000000..8c80d4a41376 --- /dev/null +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -0,0 +1,208 @@ +/** + * Renaming a table keeps its rows (Postgres). + * + * A model whose table name changes used to plan as `dropTable` plus + * `createTable`. With `--rename-table =` the operator states the + * rename and the plan carries one `renameTable` operation instead. + * + * Journey R1 (`migration plan`): create `userProfile` with rows, drop the + * `@@map` so the model names `UserProfile`, and confirm that planning without + * the flag is refused by the case-change guard, that a stale intent is + * refused as a planning failure, and that the stated intent plans exactly one + * rename, applies, keeps the rows, and verifies clean. + * + * Journey R2 (`migration new`): the same rename authored by hand. The flag + * pre-fills the scaffolded `migration.ts` with the rename call; running the + * file self-emits the op, and `migrate` applies it. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { withTempDir } from '../utils/cli-test-helpers'; +import { + engineError, + getLatestMigrationDir, + getMigrationDirs, + type JourneyContext, + latestMigrationDirName, + parseJsonOutput, + planMigrationAndSelfEmit, + runContractEmit, + runDbVerify, + runMigrate, + runMigrationNew, + runMigrationPlan, + selfEmitMigration, + setupJourney, + sql, + swapPslContract, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +interface PlanDocument { + readonly operations: readonly { id: string; label: string; operationClass: string }[]; +} + +interface OpsJson { + readonly id: string; + readonly execute: readonly { sql: string }[]; +} + +const SEED_ROWS = `INSERT INTO "public"."userProfile" (id, email) VALUES (1, 'alice@example.com'), (2, 'bob@example.com')`; + +async function seedRenamedTable( + ctx: JourneyContext, + connectionString: string, + label: string, +): Promise { + swapPslContract(ctx, 'contract-rename-table-from'); + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `${label}.01: emit userProfile: ${emit.stderr}`).toBe(0); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(plan.exitCode, `${label}.02: plan initial: ${plan.stderr}`).toBe(0); + const apply = await runMigrate(ctx); + expect(apply.exitCode, `${label}.03: migrate initial: ${apply.stderr}`).toBe(0); + await sql(connectionString, SEED_ROWS); + + swapPslContract(ctx, 'contract-rename-table-to'); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `${label}.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); + return latestMigrationDirName(ctx); +} + +async function expectRowsSurvived(connectionString: string, label: string): Promise { + const rows = await sql( + connectionString, + `SELECT id, email FROM "public"."UserProfile" ORDER BY id`, + ); + expect(rows.rows, `${label}: rows present under the new name`).toEqual([ + { id: 1, email: 'alice@example.com' }, + { id: 2, email: 'bob@example.com' }, + ]); + const old = await sql(connectionString, `SELECT to_regclass('"public"."userProfile"') AS old`); + expect(old.rows[0]?.old, `${label}: old name is gone`).toBeNull(); +} + +withTempDir(({ createTempDir }) => { + describe('Journey R1: rename a table with migration plan --rename-table', () => { + const db = useDevDatabase(); + + it( + 'guard refuses the bare plan; stale intent fails; stated intent plans one rename, keeps the rows, verifies clean', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + const origin = await seedRenamedTable(ctx, db.connectionString, 'R1'); + + const bare = await runMigrationPlan(ctx, ['--name', 'bare', '--from', origin, '--json']); + expect(bare.exitCode, 'R1.05: bare plan is refused').not.toBe(0); + const bareError = engineError(bare); + expect(bareError?.code, 'R1.05: guard code').toBe('MIGRATION.PLANNING_FAILED'); + expect(bareError?.why, 'R1.05: guard names the case change').toContain( + 'MIGRATION.TABLE_NAME_CASE_CHANGED', + ); + expect( + `${bareError?.why}\n${bareError?.summary}\n${JSON.stringify(bareError?.nextActions)}`, + 'R1.05: guard points at the flag', + ).toContain('--rename-table'); + expect(getMigrationDirs(ctx), 'R1.05: nothing written').toHaveLength(1); + + const stale = await runMigrationPlan(ctx, [ + '--name', + 'stale', + '--from', + origin, + '--rename-table', + 'userProfile=Nope', + '--json', + ]); + expect(stale.exitCode, 'R1.06: stale intent is refused').not.toBe(0); + const staleError = engineError(stale); + expect(staleError?.code, 'R1.06: planning failure').toBe('MIGRATION.PLANNING_FAILED'); + expect(staleError?.why, 'R1.06: names the unmatched intent').toContain( + 'MIGRATION.TABLE_RENAME_UNMATCHED', + ); + expect(getMigrationDirs(ctx), 'R1.06: nothing written').toHaveLength(1); + + const plan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'rename-user-profile', + '--from', + origin, + '--rename-table', + 'userProfile=UserProfile', + '--json', + ]); + expect(plan.exitCode, `R1.07: plan with intent: ${plan.stderr}`).toBe(0); + const document = parseJsonOutput(plan); + expect( + document.operations.map((op) => ({ id: op.id, operationClass: op.operationClass })), + 'R1.07: exactly one rename op', + ).toEqual([{ id: 'renameTable.userProfile', operationClass: 'widening' }]); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `R1.08: migrate: ${apply.stderr}`).toBe(0); + await expectRowsSurvived(db.connectionString, 'R1.09'); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `R1.10: db verify --schema-only: ${verify.stderr}`).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey R2: rename a table with migration new --rename-table', () => { + const db = useDevDatabase(); + + it( + 'the scaffold carries the rename call; self-emit renders the SQL; migrate keeps the rows', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await seedRenamedTable(ctx, db.connectionString, 'R2'); + + const scaffold = await runMigrationNew(ctx, [ + '--name', + 'rename-user-profile', + '--rename-table', + 'userProfile=UserProfile', + ]); + expect(scaffold.exitCode, `R2.05: migration new: ${scaffold.stderr}`).toBe(0); + const latest = getLatestMigrationDir(ctx); + expect(latest, 'R2.05: scaffold dir').toBeDefined(); + const packageDir = join(ctx.testDir, 'migrations', 'app', latest!); + expect(readFileSync(join(packageDir, 'migration.ts'), 'utf-8'), 'R2.05: call').toContain( + 'this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); + + const emitted = await selfEmitMigration(ctx, ['--dir', `migrations/app/${latest}`]); + expect(emitted.exitCode, `R2.06: self-emit: ${emitted.stderr}`).toBe(0); + const ops = JSON.parse(readFileSync(join(packageDir, 'ops.json'), 'utf-8')) as OpsJson[]; + expect( + ops.map((op) => op.id), + 'R2.06: one rename op', + ).toEqual(['renameTable.userProfile']); + expect( + ops[0]?.execute.map((step) => step.sql), + 'R2.06: rendered SQL', + ).toEqual(['ALTER TABLE "userProfile" RENAME TO "UserProfile"']); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `R2.07: migrate: ${apply.stderr}`).toBe(0); + await expectRowsSurvived(db.connectionString, 'R2.08'); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `R2.09: db verify --schema-only: ${verify.stderr}`).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts new file mode 100644 index 000000000000..8b0feb602a1c --- /dev/null +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -0,0 +1,176 @@ +/** + * Renaming a table keeps its rows (SQLite). + * + * The SQLite twin of `rename-table-migration.e2e.test.ts`: a file database + * driven through the `@internal/sqlite/config` facade config. Create + * `userProfile` with rows, drop the `@@map` so the model names + * `UserProfile`, and confirm that `migration plan --rename-table` plans + * exactly one rename, applies, keeps the rows, and verifies clean. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import { withTempDir, writeProjectManifest } from '../utils/cli-test-helpers'; +import { + engineError, + getMigrationDirs, + type JourneyContext, + latestMigrationDirName, + parseJsonOutput, + planMigrationAndSelfEmit, + runContractEmit, + runDbVerify, + runMigrate, + runMigrationPlan, + timeouts, +} from '../utils/journey-test-helpers'; + +const FROM_PSL = `// use prisma-8 + +model UserProfile { + id Int @id + email String + + @@map("userProfile") +} +`; + +const TO_PSL = `// use prisma-8 + +model UserProfile { + id Int @id + email String +} +`; + +interface PlanDocument { + readonly operations: readonly { id: string; label: string; operationClass: string }[]; +} + +function sqliteConfig(dbPath: string): string { + return `import { defineConfig as ormConfig } from '@internal/sqlite/config'; +import { defineConfig } from '@prisma/cli-engine'; + +export default defineConfig({ + orm: ormConfig({ + contract: './contract.prisma', + db: { connection: ${JSON.stringify(dbPath)} }, + migrations: { dir: 'migrations' }, + }), +}); +`; +} + +function setupSqliteJourney(createTempDir: () => string): JourneyContext & { dbPath: string } { + const testDir = createTempDir(); + const dbPath = join(testDir, 'journey.db'); + const configPath = join(testDir, 'prisma.config.ts'); + writeFileSync(configPath, sqliteConfig(dbPath), 'utf-8'); + writeFileSync(join(testDir, 'contract.prisma'), FROM_PSL, 'utf-8'); + writeProjectManifest(testDir); + return { testDir, configPath, outputDir: testDir, dbPath }; +} + +function withDatabase(dbPath: string, run: (db: DatabaseSync) => T): T { + const db = new DatabaseSync(dbPath); + try { + return run(db); + } finally { + db.close(); + } +} + +withTempDir(({ createTempDir }) => { + describe('Journey R3 (SQLite): rename a table with migration plan --rename-table', () => { + it( + 'stale intent fails; stated intent plans one rename, keeps the rows, verifies clean', + async () => { + const ctx = setupSqliteJourney(createTempDir); + + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `R3.01: emit userProfile: ${emit.stderr}`).toBe(0); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `R3.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `R3.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + const origin = latestMigrationDirName(ctx); + withDatabase(ctx.dbPath, (db) => { + db.exec( + `INSERT INTO "userProfile" (id, email) VALUES (1, 'alice@example.com'), (2, 'bob@example.com')`, + ); + }); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), TO_PSL, 'utf-8'); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `R3.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); + + const bare = await runMigrationPlan(ctx, ['--name', 'bare', '--from', origin, '--json']); + expect(bare.exitCode, 'R3.05: bare plan is refused by the guard').not.toBe(0); + expect(engineError(bare)?.why, 'R3.05: guard names the case change').toContain( + 'MIGRATION.TABLE_NAME_CASE_CHANGED', + ); + + const stale = await runMigrationPlan(ctx, [ + '--name', + 'stale', + '--from', + origin, + '--rename-table', + 'userProfile=Nope', + '--json', + ]); + expect(stale.exitCode, 'R3.06: stale intent is refused').not.toBe(0); + expect(engineError(stale)?.why, 'R3.06: names the unmatched intent').toContain( + 'MIGRATION.TABLE_RENAME_UNMATCHED', + ); + expect(getMigrationDirs(ctx), 'R3.06: nothing written').toHaveLength(1); + + const plan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'rename-user-profile', + '--from', + origin, + '--rename-table', + 'userProfile=UserProfile', + '--json', + ]); + expect(plan.exitCode, `R3.07: plan with intent: ${plan.stderr}`).toBe(0); + const document = parseJsonOutput(plan); + expect( + document.operations.map((op) => ({ id: op.id, operationClass: op.operationClass })), + 'R3.07: exactly one rename op', + ).toEqual([{ id: 'renameTable.userProfile', operationClass: 'widening' }]); + const opsJson = readFileSync( + join(ctx.testDir, 'migrations', 'app', getMigrationDirs(ctx).at(-1)!, 'ops.json'), + 'utf-8', + ); + expect(opsJson, 'R3.07: rendered SQL').toContain( + 'ALTER TABLE \\"userProfile\\" RENAME TO \\"UserProfile\\"', + ); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `R3.08: migrate: ${apply.stderr}`).toBe(0); + const state = withDatabase(ctx.dbPath, (db) => ({ + rows: db.prepare(`SELECT id, email FROM "UserProfile" ORDER BY id`).all(), + tables: db + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%serProfile'`, + ) + .all() + .map((row) => row.name), + })); + expect(state.rows, 'R3.09: rows present under the new name').toEqual([ + { id: 1, email: 'alice@example.com' }, + { id: 2, email: 'bob@example.com' }, + ]); + expect(state.tables, 'R3.09: old name is gone').toEqual(['UserProfile']); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `R3.10: db verify --schema-only: ${verify.stderr}`).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-from.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-from.prisma new file mode 100644 index 000000000000..db26a7676ee3 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-from.prisma @@ -0,0 +1,8 @@ +// use prisma-8 + +model UserProfile { + id Int @id + email String + + @@map("userProfile") +} diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-to.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-to.prisma new file mode 100644 index 000000000000..79eac84583ef --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-to.prisma @@ -0,0 +1,6 @@ +// use prisma-8 + +model UserProfile { + id Int @id + email String +} diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index d7128dbcf0e5..62d49986c1ee 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -218,6 +218,8 @@ export const pslContractFixtures = { ), 'contract-rls-adopted': join(JOURNEY_FIXTURES_DIR, 'contract-rls-adopted.prisma'), 'contract-rls-wire': join(JOURNEY_FIXTURES_DIR, 'contract-rls-wire.prisma'), + 'contract-rename-table-from': join(JOURNEY_FIXTURES_DIR, 'contract-rename-table-from.prisma'), + 'contract-rename-table-to': join(JOURNEY_FIXTURES_DIR, 'contract-rename-table-to.prisma'), } as const; export type PslContractVariant = keyof typeof pslContractFixtures; From 638fd7f446c67f18d1654f3eb7fb0f23b66c4dac Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 10:15:27 +0200 Subject: [PATCH 03/55] feat(sql): renameTable migration operation for Postgres and SQLite One widening operation per target: precheck that the old table exists and the new name is free, ALTER TABLE ... RENAME TO (schema-qualified on Postgres), postcheck the new name. RenameTableCall joins both op-factory call unions with renderTypeScript, the migration facades gain renameTable({ table, to }), and the call classes are exported. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/issue-planner.ts | 1 + .../src/core/migrations/op-factory-call.ts | 51 ++++++++++++++- .../src/core/migrations/operations/tables.ts | 33 ++++++++++ .../src/core/migrations/postgres-migration.ts | 13 ++++ .../postgres/src/exports/op-factory-call.ts | 1 + .../src/core/migrations/op-factory-call.ts | 63 +++++++++++++++++++ .../src/core/migrations/sqlite-migration.ts | 7 +++ .../sqlite/src/exports/op-factory-call.ts | 1 + 8 files changed, 169 insertions(+), 1 deletion(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 79ea69cd6505..b4aa945c667c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -161,6 +161,7 @@ function classifyCall(call: PostgresOpFactoryCall): CallCategory { case 'renameCheckConstraint': return 'unique'; // after uniques, before indexes case 'createTable': + case 'renameTable': return 'table'; case 'enableRowLevelSecurity': case 'disableRowLevelSecurity': diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 23999c305276..63d6e55a7106 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -93,7 +93,7 @@ import { } from './operations/rls'; import type { ForeignKeySpec } from './operations/shared'; import { step, targetDetails } from './operations/shared'; -import { dropTable } from './operations/tables'; +import { dropTable, renameTable } from './operations/tables'; import { buildAddNotNullColumnWithTemporaryDefaultOperation } from './planner-recipes'; import type { PostgresPlanTargetDetails } from './planner-target-details'; @@ -363,6 +363,54 @@ export class DropTableCall extends PostgresOpFactoryCallNode { } } +export class RenameTableCall extends PostgresOpFactoryCallNode { + readonly factoryName = 'renameTable' as const; + // `widening` for the same reason as `RenameCheckConstraintCall`: a rename is + // neither additive creation nor destructive, and the class vocabulary has no + // neutral middle class, so this is the class that plans under every + // allowance set except additive-only init. + readonly operationClass = 'widening' as const; + readonly schemaName: string; + readonly oldTableName: string; + /** The new name: the table's contract-side identity after the rename. */ + readonly tableName: string; + readonly label: string; + + constructor(schemaName: string, oldTableName: string, tableName: string) { + super(); + this.schemaName = schemaName; + this.oldTableName = oldTableName; + this.tableName = tableName; + this.label = `Rename table "${oldTableName}" to "${tableName}"`; + this.freeze(); + } + + async toOp(lowerer?: ExecuteRequestLowerer): Promise { + if (lowerer === undefined) { + throw postgresError( + 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', + `RenameTableCall.toOp: a lowerer is required on the Postgres planner path (table "${this.oldTableName}"). Pass the control adapter to createPostgresMigrationPlanner.`, + { meta: { factory: 'RenameTableCall' } }, + ); + } + return renameTable(this.schemaName, this.oldTableName, this.tableName, lowerer); + } + + renderTypeScript(): string { + const opts: string[] = []; + if (this.schemaName !== UNBOUND_NAMESPACE_ID) { + opts.push(`schema: ${jsonToTsSource(this.schemaName)}`); + } + opts.push(`table: ${jsonToTsSource(this.oldTableName)}`); + opts.push(`to: ${jsonToTsSource(this.tableName)}`); + return `this.renameTable({ ${opts.join(', ')} })`; + } + + override importRequirements(): readonly ImportRequirement[] { + return []; + } +} + // ============================================================================ // Column // ============================================================================ @@ -1959,6 +2007,7 @@ export class RenamePostgresRlsPolicyCall extends PostgresOpFactoryCallNode { export type PostgresOpFactoryCall = | CreateTableCall | DropTableCall + | RenameTableCall | AddColumnCall | DropColumnCall | AlterColumnTypeCall diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts index a9e2fe9d36a0..95335a591765 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts @@ -1,5 +1,6 @@ import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import { tableExistsAst } from '../../../contract-free/checks'; +import { quoteIdentifier } from '../../sql-utils'; import { qualifyTableName } from '../planner-sql-checks'; import { type Op, step, targetDetails } from './shared'; @@ -22,3 +23,35 @@ export async function dropTable( postcheck: [step(`verify table "${tableName}" does not exist`, absent.sql, absent.params)], }; } + +export async function renameTable( + schemaName: string, + fromName: string, + toName: string, + lowerer: ExecuteRequestLowerer, +): Promise { + const qualified = qualifyTableName(schemaName, fromName); + const from = await lowerer.lowerToExecuteRequest( + tableExistsAst(schemaName, fromName).tablePresent(), + ); + const toChecks = tableExistsAst(schemaName, toName); + const toAbsent = await lowerer.lowerToExecuteRequest(toChecks.tableAbsent()); + const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); + return { + id: `renameTable.${fromName}`, + label: `Rename table "${fromName}" to "${toName}"`, + operationClass: 'widening', + target: targetDetails('table', toName, schemaName), + precheck: [ + step(`ensure table "${fromName}" exists`, from.sql, from.params), + step(`ensure table "${toName}" does not exist`, toAbsent.sql, toAbsent.params), + ], + execute: [ + step( + `rename table "${fromName}" to "${toName}"`, + `ALTER TABLE ${qualified} RENAME TO ${quoteIdentifier(toName)}`, + ), + ], + postcheck: [step(`verify table "${toName}" exists`, toPresent.sql, toPresent.params)], + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 5a8a0d51193e..16e74be5a168 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts @@ -38,6 +38,7 @@ import { RenameCheckConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, + RenameTableCall, SetDefaultCall, SetNotNullCall, } from './op-factory-call'; @@ -328,6 +329,18 @@ export abstract class PostgresMigration< ).toOp(this.controlAdapterFor('dropConstraint')); } + protected renameTable(options: { + readonly schema?: string; + readonly table: string; + readonly to: string; + }): Promise> { + return new RenameTableCall( + options.schema ?? UNBOUND_NAMESPACE_ID, + options.table, + options.to, + ).toOp(this.controlAdapterFor('renameTable')); + } + protected dropTable(options: { readonly schema: string; readonly table: string; diff --git a/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts index 2b23535b3990..9673dcad2665 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts @@ -29,6 +29,7 @@ export { RenameCheckConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, + RenameTableCall, SetDefaultCall, SetNotNullCall, } from '../core/migrations/op-factory-call'; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index 1ce21951915c..2c3b71fdb5bd 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -262,6 +262,68 @@ export class DropTableCall extends SqliteOpFactoryCallNode { } } +export class RenameTableCall extends SqliteOpFactoryCallNode { + readonly factoryName = 'renameTable' as const; + // `widening`: a rename is neither additive creation nor destructive, and the + // class vocabulary has no neutral middle class, so this is the class that + // plans under every allowance set except additive-only init. + readonly operationClass = 'widening' as const; + readonly oldTableName: string; + /** The new name: the table's contract-side identity after the rename. */ + readonly tableName: string; + readonly label: string; + + constructor(oldTableName: string, tableName: string) { + super(); + this.oldTableName = oldTableName; + this.tableName = tableName; + this.label = `Rename table ${oldTableName} to ${tableName}`; + this.freeze(); + } + + async toOp(lowerer?: ExecuteRequestLowerer): Promise { + if (lowerer === undefined) { + throw sqliteError( + 'MIGRATION.SQLITE_CONTROL_STACK_MISSING', + `RenameTableCall.toOp: a lowerer is required on the SQLite planner path (table "${this.oldTableName}"). Pass the control adapter to createSqliteMigrationPlanner.`, + { meta: { factory: this.factoryName, tableName: this.oldTableName } }, + ); + } + const from = await lowerer.lowerToExecuteRequest( + tableExistsAst(this.oldTableName).tablePresent(), + ); + const toChecks = tableExistsAst(this.tableName); + const toAbsent = await lowerer.lowerToExecuteRequest(toChecks.tableAbsent()); + const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); + return { + id: `renameTable.${this.oldTableName}`, + label: this.label, + summary: `Renames table ${this.oldTableName} to ${this.tableName}, keeping its rows`, + operationClass: 'widening', + target: { id: 'sqlite', details: buildTargetDetails('table', this.tableName) }, + precheck: [ + step(`ensure table "${this.oldTableName}" exists`, from.sql, from.params), + step(`ensure table "${this.tableName}" does not exist`, toAbsent.sql, toAbsent.params), + ], + execute: [ + step( + `rename table "${this.oldTableName}" to "${this.tableName}"`, + `ALTER TABLE ${quoteIdentifier(this.oldTableName)} RENAME TO ${quoteIdentifier(this.tableName)}`, + ), + ], + postcheck: [step(`verify table "${this.tableName}" exists`, toPresent.sql, toPresent.params)], + }; + } + + renderTypeScript(): string { + return `this.renameTable({ table: ${jsonToTsSource(this.oldTableName)}, to: ${jsonToTsSource(this.tableName)} })`; + } + + override importRequirements(): readonly ImportRequirement[] { + return []; + } +} + export class RecreateTableCall extends SqliteOpFactoryCallNode { readonly factoryName = 'recreateTable' as const; readonly operationClass: MigrationOperationClass; @@ -688,6 +750,7 @@ export type SqliteOpFactoryCall = | CreateTableCall | DropTableCall | RecreateTableCall + | RenameTableCall | AddColumnCall | DropColumnCall | CreateIndexCall diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts index 804de4f5d970..5105c14c0996 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts @@ -20,6 +20,7 @@ import { DropIndexCall, DropTableCall, RecreateTableCall, + RenameTableCall, } from './op-factory-call'; import type { SqliteColumnSpec, SqliteIndexSpec, SqliteTableSpec } from './operations/shared'; import type { SqlitePlanTargetDetails } from './planner-target-details'; @@ -125,6 +126,12 @@ export abstract class SqliteMigration< return new DropTableCall(options.table).toOp(this.controlAdapterFor('dropTable')); } + protected renameTable(options: { readonly table: string; readonly to: string }): Promise { + return new RenameTableCall(options.table, options.to).toOp( + this.controlAdapterFor('renameTable'), + ); + } + protected addColumn(options: { readonly table: string; readonly column: SqliteColumnSpec; diff --git a/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts index df8c3afe917b..6c9d44e812e6 100644 --- a/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts @@ -8,5 +8,6 @@ export { DropTableCall, RawSqlCall, RecreateTableCall, + RenameTableCall, type SqliteOpFactoryCall, } from '../core/migrations/op-factory-call'; From 0d3f07a58838f754fc90945c1f792e90e2ed79e9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 10:29:32 +0200 Subject: [PATCH 04/55] feat(migration): --rename-table intents on migration plan and migration new The CLI parses --rename-table = (repeatable, either side may be .) into framework StorageEntityRename intents. On migration plan the SQL family applies them to the previous contract before the diff (applyTableRenameIntents: old name must exist, new name must not, new name must exist in the next contract; anything else is a MIGRATION.TABLE_RENAME_UNMATCHED planner conflict), both target planners re-derive the from tree from the renamed contract and prepend one renameTable op per intent, refusing it under a policy without widening. On migration new the scaffold starts with one renameTable call per intent and its manifest is attested over those ops. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-migration-types.ts | 40 ++- .../src/exports/control.ts | 2 + .../control-api/operations/migration-new.ts | 62 ++-- .../control-api/operations/migration-plan.ts | 12 + .../3-tooling/cli/src/orm/migration/new.ts | 12 + .../3-tooling/cli/src/orm/migration/plan.ts | 12 + .../cli/src/utils/rename-table-flag.ts | 83 +++++ .../cli/test/orm/fixtures/offline-project.ts | 2 +- .../cli/test/utils/rename-table-flag.test.ts | 28 +- .../core/migrations/table-rename-intents.ts | 287 ++++++++++++++++++ .../9-family/src/core/migrations/types.ts | 10 +- .../2-sql/9-family/src/exports/control.ts | 10 + .../test/table-rename-intents.test.ts | 11 +- .../postgres/src/core/migrations/planner.ts | 88 +++++- .../migrations/postgres-contract-to-schema.ts | 41 +++ .../3-targets/postgres/src/exports/control.ts | 29 +- .../migrations/rename-table-planner.test.ts | 47 +-- .../src/core/migrations/issue-planner.ts | 2 +- .../sqlite/src/core/migrations/planner.ts | 66 +++- .../migrations/rename-table-planner.test.ts | 43 +-- 20 files changed, 758 insertions(+), 129 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts create mode 100644 packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index 2a1cf1db2063..1d8648725fc1 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -427,6 +427,28 @@ export interface SchemaOwnership { * @template TFamilyId - The family ID (e.g., 'sql', 'document') * @template TTargetId - The target ID (e.g., 'postgres', 'mysql') */ +/** + * One side of a {@link StorageEntityRename}: a storage entity named within a + * namespace. `namespaceId` is omitted when the operator did not qualify the + * name; the family resolves it against the contract. + */ +export interface StorageEntityRenameCoordinate { + readonly namespaceId?: string; + readonly name: string; +} + +/** + * An operator-stated rename of one storage entity (a SQL table, a document + * collection) between the previous state and the next contract. The planner + * cannot infer a rename from a diff — the entity under the old name is gone + * and one under the new name appeared — so the operator states it and the + * planner applies it to the previous state before diffing. + */ +export interface StorageEntityRename { + readonly from: StorageEntityRenameCoordinate; + readonly to: StorageEntityRenameCoordinate; +} + export interface MigrationPlanner< TFamilyId extends string = string, TTargetId extends string = string, @@ -488,14 +510,22 @@ export interface MigrationPlanner< * `@internal/migration-tools`. */ readonly snapshotsImportPath: string; + /** + * Operator-stated renames to apply to the previous state before the + * diff, in the order given. Each becomes one rename operation at the + * head of the plan; one that matches neither side is a planning + * failure. Absent or empty means no renames. + */ + readonly renames?: readonly StorageEntityRename[]; }): MigrationPlannerResult; /** * Produce an empty migration with the target's authoring conventions. * * Used by `migration new` to scaffold a fresh `migration.ts`. The - * returned plan has no operations; its `renderTypeScript()` yields a - * stub the user can edit. + * returned plan carries only the operations `context.renames` asks for + * (none by default); its `renderTypeScript()` yields a stub the user can + * edit. * * `spaceId` is stamped onto the produced plan; reconciliation flows * (`db init`, `db update`) and authoring flows (`migration new`) all @@ -661,4 +691,10 @@ export interface MigrationScaffoldContext { * through to their renderer's `RenderMigrationMeta.snapshotsImportPath`. */ readonly snapshotsImportPath: string; + /** + * Operator-stated renames the scaffold starts with: one rename operation + * per entry, in the order given, so a hand-authored migration that only + * renames needs no editing. Absent or empty scaffolds an empty body. + */ + readonly renames?: readonly StorageEntityRename[]; } diff --git a/packages/1-framework/1-core/framework-components/src/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts index f557c9a4e746..71759b8be5e8 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/control.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/control.ts @@ -58,6 +58,8 @@ export type { OpFactoryCall, SchemaEntityCoordinate, SchemaOwnership, + StorageEntityRename, + StorageEntityRenameCoordinate, TargetMigrationsCapability, } from '../control/control-migration-types'; export type { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index 489c9c00f6cb..9f3f1563743b 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -5,7 +5,11 @@ import { readFile } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; -import { APP_SPACE_ID, createControlStack } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + createControlStack, + type StorageEntityRename, +} from '@internal/framework-components/control'; import { loadContractSpaceAggregate } from '@internal/migration-tools/aggregate'; import { contractSnapshotDir, @@ -17,6 +21,7 @@ import { formatMigrationDirName, writeMigrationPackage } from '@internal/migrati import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { findLatestMigration } from '@internal/migration-tools/migration-graph'; import { writeMigrationTs } from '@internal/migration-tools/migration-ts'; +import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { join, relative } from 'pathe'; import { @@ -44,6 +49,12 @@ export interface MigrationNewOptions { readonly configPath?: string; readonly name?: string; readonly from?: string; + /** + * Operator-stated renames (`--rename-table`): the scaffold starts with one + * rename call per entry and its manifest is attested over those ops, so a + * migration that only renames is complete as written. + */ + readonly renames?: readonly StorageEntityRename[]; /** Renders the declarations of the destination snapshot from its `contract.json`. */ readonly client: Pick; } @@ -165,21 +176,6 @@ export async function executeMigrationNewCommand( const dirName = formatMigrationDirName(timestamp, slug); const packageDir = join(appMigrationsDir, dirName); - // `migration new` scaffolds an empty `migration.ts` for the user to - // fill, so we attest over `ops: []`. Re-running self-emit after the - // user adds operations will produce a different `migrationHash` (over - // the real ops). This is intentional — there is no on-disk draft. - const baseMetadata: Omit = { - from: fromHash, - to: toStorageHash, - providedInvariants: [], - createdAt: timestamp.toISOString(), - }; - const metadata: MigrationMetadata = { - ...baseMetadata, - migrationHash: computeMigrationHash(baseMetadata, []), - }; - const migrations = getTargetMigrations(config.target); if (!migrations) { return notOk( @@ -210,24 +206,42 @@ export async function executeMigrationNewCommand( return notOk(declarations.failure); } - await writeMigrationPackage(packageDir, metadata, []); - await writeContractSnapshot(migrationsDir, toStorageHash, { - contractJson: parsedContract, - contractDts: declarations.value, - }); - const planner = migrations.createPlanner(controlAdapter); - const emptyPlan = planner.emptyMigration( + const scaffold = planner.emptyMigration( { packageDir, contractJsonPath: join(contractSnapshotDir(migrationsDir, toStorageHash), 'contract.json'), fromHash, toHash: toStorageHash, snapshotsImportPath: snapshotsImportPathFrom(packageDir, migrationsDir), + ...ifDefined('renames', options.renames), }, APP_SPACE_ID, ); - await writeMigrationTs(packageDir, emptyPlan.renderTypeScript(resolveSpecifier)); + + // The scaffold carries only the operations the operator stated on the + // command line (none by default), and the manifest is attested over + // exactly those. Re-running self-emit after the user adds operations + // produces a different `migrationHash` (over the real ops). This is + // intentional — there is no on-disk draft. + const scaffoldOps = await Promise.all(scaffold.operations); + const baseMetadata: Omit = { + from: fromHash, + to: toStorageHash, + providedInvariants: [], + createdAt: timestamp.toISOString(), + }; + const metadata: MigrationMetadata = { + ...baseMetadata, + migrationHash: computeMigrationHash(baseMetadata, scaffoldOps), + }; + + await writeMigrationPackage(packageDir, metadata, scaffoldOps); + await writeContractSnapshot(migrationsDir, toStorageHash, { + contractJson: parsedContract, + contractDts: declarations.value, + }); + await writeMigrationTs(packageDir, scaffold.renderTypeScript(resolveSpecifier)); return ok({ ok: true as const, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index 48f7bec8a894..8540d0b3c62e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -11,6 +11,7 @@ import { type MigrationPlanOperation, type OperationPreview, type SchemaOwnership, + type StorageEntityRename, } from '@internal/framework-components/control'; import { snapshotsImportPathFrom, @@ -68,6 +69,13 @@ export interface MigrationPlanOptions { readonly name?: string; readonly from?: string; readonly to?: string; + /** + * Operator-stated renames (`--rename-table`), applied by the planner to the + * origin contract before it diffs; each becomes the first operations of + * the planned migration. Only the delta leg receives them: an auto-baseline + * leg plans the origin from nothing and has no previous state to rename. + */ + readonly renames?: readonly StorageEntityRename[]; /** Renders the declarations of the destination snapshot from its `contract.json`. */ readonly client: Pick; } @@ -90,6 +98,7 @@ async function runPlannerLeg( ownership: SchemaOwnership, snapshotsImportPath: string, resolveImportSpecifier: ImportSpecifierResolver, + renames: readonly StorageEntityRename[] = [], ): Promise> { const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents); const plannerResult = planner.plan({ @@ -99,6 +108,7 @@ async function runPlannerLeg( fromContract, frameworkComponents, spaceId, + ...(renames.length === 0 ? {} : { renames }), // Offline `migration plan` is the aggregate-of-(possibly one) degenerate // case: the same ownership consultation the live aggregate flow uses. A // from→to extra (a table removed from the contract) is not declared by any @@ -578,6 +588,7 @@ async function executeMigrationPlanCommandInner( aggregate, snapshotsImportPathFrom(deltaPackageDir, migrationsDir), resolveImportSpecifier, + options.renames, ); if (!deltaLeg.ok) { return notOk(deltaLeg.failure); @@ -649,6 +660,7 @@ async function executeMigrationPlanCommandInner( aggregate, snapshotsImportPathFrom(packageDir, migrationsDir), resolveImportSpecifier, + options.renames, ); if (!deltaLeg.ok) { return notOk(deltaLeg.failure); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts index 52a1ebc63bc6..c56e515ad75b 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts @@ -9,6 +9,7 @@ import type { MigrationNewResult } from '../../control-api/operations/migration- import { executeMigrationNewCommand } from '../../control-api/operations/migration-new'; import type { CreateControlClient } from '../../control-api/types'; import { runCommandAction } from '../../utils/next-actions'; +import { parseRenameTableFlags } from '../../utils/rename-table-flag'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -83,16 +84,27 @@ export function createMigrationNewCommand(createClient: CreateControlClient) { brief: 'Starting contract hash (default: latest migration target)', placeholder: 'hash', }), + // biome-ignore lint/plugin/no-family-vocabulary: the flag is the SQL family's user-facing grammar for a stated rename + renameTable: flag.repeated({ + brief: + 'A table this migration renames, as = (either side may be .); the scaffold starts with one rename call per value', + placeholder: 'from=to', + }), }, }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { + const renames = parseRenameTableFlags(args.flags.renameTable); + if (!renames.ok) { + return notOk(normalizeError(renames.failure)); + } const scaffolded = await executeMigrationNewCommand({ config: ctx.config, cwd: ctx.cwd, configPath: projectConfigPathFor(ctx.cwd), ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), + ...(renames.value.length === 0 ? {} : { renames: renames.value }), client: createClient({ family: ctx.config.family, target: ctx.config.target, diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index f531ae53b285..c6649ec58323 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -11,6 +11,7 @@ import { executeMigrationPlanCommand } from '../../control-api/operations/migrat import type { CreateControlClient } from '../../control-api/types'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; +import { parseRenameTableFlags } from '../../utils/rename-table-flag'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -233,10 +234,20 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { 'Destination contract reference; defaults to the emitted contract. Same grammar as --from', placeholder: 'contract', }), + // biome-ignore lint/plugin/no-family-vocabulary: the flag is the SQL family's user-facing grammar for a stated rename + renameTable: flag.repeated({ + brief: + 'A table the contract change renames, as = (either side may be .); repeat per table', + placeholder: 'from=to', + }), }, }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { + const renames = parseRenameTableFlags(args.flags.renameTable); + if (!renames.ok) { + return notOk(normalizeError(renames.failure)); + } const seeded = (record: ContractSpaceSeedPhaseRecord): void => { if (record.action !== 'updated') { return; @@ -260,6 +271,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), ...ifDefined('to', args.flags.to), + ...(renames.value.length === 0 ? {} : { renames: renames.value }), client: createClient({ family: ctx.config.family, target: ctx.config.target, diff --git a/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts b/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts new file mode 100644 index 000000000000..34c7756b870c --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts @@ -0,0 +1,83 @@ +import { CliStructuredError } from '@internal/errors/control'; +import type { + StorageEntityRename, + StorageEntityRenameCoordinate, +} from '@internal/framework-components/control'; +import { notOk, ok, type Result } from '@internal/utils/result'; + +const GRAMMAR = '=, each side optionally qualified as .'; + +function invalid(value: string, reason: string): CliStructuredError { + return new CliStructuredError( + 'CLI.INVALID_RENAME_TABLE_FLAG', + `Invalid --rename-table value "${value}": ${reason}.`, + { + why: `--rename-table names one storage entity to rename, as ${GRAMMAR}.`, + fix: `Write the flag as --rename-table ${GRAMMAR}, once per renamed entity.`, + meta: { value }, + }, + ); +} + +function parseCoordinate( + value: string, + side: string, +): Result { + const dot = side.indexOf('.'); + if (dot === -1) { + return side.length === 0 ? notOk(invalid(value, 'a name is missing')) : ok({ name: side }); + } + const namespaceId = side.slice(0, dot); + const name = side.slice(dot + 1); + if (namespaceId.length === 0) return notOk(invalid(value, 'the namespace qualifier is empty')); + if (name.length === 0) return notOk(invalid(value, `no name follows "${namespaceId}."`)); + return ok({ namespaceId, name }); +} + +function coordinateKey(coordinate: StorageEntityRenameCoordinate): string { + return `${coordinate.namespaceId ?? ''}.${coordinate.name}`; +} + +/** + * Parses every `--rename-table =` value the operator gave, in the + * order given. A malformed value, a value whose two sides are the same + * entity, and two values that share an old or a new name are each refused + * with `CLI.INVALID_RENAME_TABLE_FLAG`. + */ +export function parseRenameTableFlags( + values: readonly string[] | undefined, +): Result { + const renames: StorageEntityRename[] = []; + const seenFrom = new Set(); + const seenTo = new Set(); + for (const raw of values ?? []) { + const value = raw.trim(); + const parts = value.split('='); + if (parts.length !== 2) { + return notOk( + invalid( + value, + parts.length < 2 ? 'expected one "=" between the two names' : 'expected exactly one "="', + ), + ); + } + const [left = '', right = ''] = parts.map((part) => part.trim()); + const from = parseCoordinate(value, left); + if (!from.ok) return from; + const to = parseCoordinate(value, right); + if (!to.ok) return to; + const fromKey = coordinateKey(from.value); + const toKey = coordinateKey(to.value); + if (fromKey === toKey) return notOk(invalid(value, 'the old and new names are the same')); + if (seenFrom.has(fromKey)) { + return notOk(invalid(value, `"" is renamed more than once`)); + } + if (seenTo.has(toKey)) { + return notOk(invalid(value, `more than one value renames to ""`)); + } + seenFrom.add(fromKey); + seenTo.add(toKey); + renames.push({ from: from.value, to: to.value }); + } + return ok(renames); +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts index d2eaa31c61cd..9ef5d8a92ec9 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts @@ -161,7 +161,7 @@ function fakePlanner(script: FakePlannerScript): Record { }, } : { kind: 'failure', conflicts: script.conflicts }, - emptyMigration: () => ({ renderTypeScript: () => '// empty migration\n' }), + emptyMigration: () => ({ operations: [], renderTypeScript: () => '// empty migration\n' }), }; } diff --git a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts b/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts index 9c50e5d30ffb..cb89cd5d63f5 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; import { parseRenameTableFlags } from '../../src/utils/rename-table-flag'; +function parsedValue(values: readonly string[]) { + const result = parseRenameTableFlags(values); + expect(result.ok).toBe(true); + return result.ok ? result.value : undefined; +} + function failureOf(values: readonly string[]) { const result = parseRenameTableFlags(values); expect(result.ok).toBe(false); @@ -9,22 +15,16 @@ function failureOf(values: readonly string[]) { describe('parseRenameTableFlags', () => { it('parses an unqualified pair', () => { - expect(parseRenameTableFlags(['userProfile=UserProfile'])).toEqual({ - ok: true, - value: [{ from: { name: 'userProfile' }, to: { name: 'UserProfile' } }], - }); + expect(parsedValue(['userProfile=UserProfile'])).toEqual([ + { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, + ]); }); it('parses a namespace qualifier on either side', () => { - expect( - parseRenameTableFlags(['auth.userProfile=UserProfile', 'orderLine=sales.OrderLine']), - ).toEqual({ - ok: true, - value: [ - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - { from: { name: 'orderLine' }, to: { namespaceId: 'sales', name: 'OrderLine' } }, - ], - }); + expect(parsedValue(['auth.userProfile=UserProfile', 'orderLine=sales.OrderLine'])).toEqual([ + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, + { from: { name: 'orderLine' }, to: { namespaceId: 'sales', name: 'OrderLine' } }, + ]); }); it('keeps repeated values in the order given', () => { @@ -38,7 +38,7 @@ describe('parseRenameTableFlags', () => { }); it('returns no intents for no values', () => { - expect(parseRenameTableFlags([])).toEqual({ ok: true, value: [] }); + expect(parsedValue([])).toEqual([]); }); it.each([ diff --git a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts new file mode 100644 index 000000000000..cf4222e35800 --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts @@ -0,0 +1,287 @@ +import type { Contract } from '@internal/contract/types'; +import type { StorageEntityRename } from '@internal/framework-components/control'; +import { + ForeignKey, + type ForeignKeyReference, + type ForeignKeyReferenceInput, + isMaterializedSqlNamespace, + type SqlNamespace, + type SqlNamespaceBase, + type SqlNamespaceEntries, + SqlStorage, + StorageTable, +} from '@internal/sql-contract/types'; +import { InternalError } from '@internal/utils/internal-error'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import type { SqlPlannerConflict } from './types'; + +export const TABLE_RENAME_UNMATCHED_CODE = 'MIGRATION.TABLE_RENAME_UNMATCHED'; + +/** A `--rename-table =` intent as the CLI parsed it. */ +export type TableRenameIntent = StorageEntityRename; + +/** An intent after both sides were matched against the contracts. */ +export interface ResolvedTableRename { + readonly namespaceId: string; + readonly from: string; + readonly to: string; +} + +export interface ApplyTableRenameIntentsInput { + readonly fromContract: Contract | null; + readonly toContract: Contract; + readonly intents: readonly TableRenameIntent[]; +} + +export interface AppliedTableRenames { + /** The previous contract with every intent's table under its new name. */ + readonly contract: Contract; + readonly renames: readonly ResolvedTableRename[]; +} + +type TableLookup = + | { readonly kind: 'found'; readonly namespaceId: string } + | { readonly kind: 'missing' } + | { readonly kind: 'ambiguous'; readonly namespaceIds: readonly string[] }; + +function coordinateLabel(coordinate: StorageEntityRename['from']): string { + return coordinate.namespaceId === undefined + ? coordinate.name + : `${coordinate.namespaceId}.${coordinate.name}`; +} + +function intentLabel(intent: TableRenameIntent): string { + return `${coordinateLabel(intent.from)}=${coordinateLabel(intent.to)}`; +} + +function namespacesDeclaring(contract: Contract, tableName: string): string[] { + return Object.values(contract.storage.namespaces) + .filter((namespace) => Object.hasOwn(namespace.entries.table ?? {}, tableName)) + .map((namespace) => namespace.id); +} + +function lookupTable( + contract: Contract, + coordinate: StorageEntityRename['from'], +): TableLookup { + if (coordinate.namespaceId !== undefined) { + const namespace = contract.storage.namespaces[coordinate.namespaceId]; + return namespace !== undefined && Object.hasOwn(namespace.entries.table ?? {}, coordinate.name) + ? { kind: 'found', namespaceId: coordinate.namespaceId } + : { kind: 'missing' }; + } + const namespaceIds = namespacesDeclaring(contract, coordinate.name); + const [only] = namespaceIds; + if (only !== undefined && namespaceIds.length === 1) return { kind: 'found', namespaceId: only }; + return namespaceIds.length === 0 ? { kind: 'missing' } : { kind: 'ambiguous', namespaceIds }; +} + +function unmatched(intent: TableRenameIntent, reason: string): SqlPlannerConflict { + return { + kind: 'tableRenameUnmatched', + summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename-table "${intentLabel(intent)}" does not match the contracts: ${reason}.`, + why: 'A rename intent must name a table of the previous contract as its old name and a table of the next contract as its new name, and the new name must not already exist in the previous contract. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed.', + meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: intent.from.name, to: intent.to.name }, + }; +} + +function ambiguousReason( + coordinate: StorageEntityRename['from'], + namespaceIds: readonly string[], +): string { + return `table "${coordinate.name}" is declared in more than one namespace (${namespaceIds.join(', ')}); qualify it as .${coordinate.name}`; +} + +function resolveIntent( + intent: TableRenameIntent, + fromContract: Contract, + toContract: Contract, +): Result { + const from = lookupTable(fromContract, intent.from); + if (from.kind === 'ambiguous') { + return notOk(unmatched(intent, ambiguousReason(intent.from, from.namespaceIds))); + } + if (from.kind === 'missing') { + return notOk( + unmatched( + intent, + `table "${coordinateLabel(intent.from)}" does not exist in the previous contract`, + ), + ); + } + const namespaceId = from.namespaceId; + if (intent.to.namespaceId !== undefined && intent.to.namespaceId !== namespaceId) { + return notOk( + unmatched( + intent, + `a rename cannot move table "${intent.from.name}" from namespace "${namespaceId}" to namespace "${intent.to.namespaceId}"`, + ), + ); + } + const previousNamespace = fromContract.storage.namespaces[namespaceId]; + if (Object.hasOwn(previousNamespace?.entries.table ?? {}, intent.to.name)) { + return notOk( + unmatched(intent, `table "${intent.to.name}" already exists in the previous contract`), + ); + } + const nextNamespace = toContract.storage.namespaces[namespaceId]; + if (!Object.hasOwn(nextNamespace?.entries.table ?? {}, intent.to.name)) { + return notOk( + unmatched(intent, `table "${intent.to.name}" does not exist in the next contract`), + ); + } + return ok({ namespaceId, from: intent.from.name, to: intent.to.name }); +} + +function renamedReference( + reference: ForeignKeyReference, + rename: ResolvedTableRename, +): ForeignKeyReference | ForeignKeyReferenceInput { + const local = reference.spaceId === undefined; + if ( + !local || + reference.namespaceId !== rename.namespaceId || + reference.tableName !== rename.from + ) { + return reference; + } + return { ...reference, tableName: rename.to }; +} + +function renameForeignKeys(table: StorageTable, rename: ResolvedTableRename): StorageTable { + const touched = table.foreignKeys.some( + (fk) => + renamedReference(fk.source, rename) !== fk.source || + renamedReference(fk.target, rename) !== fk.target, + ); + if (!touched) return table; + return new StorageTable({ + ...table, + foreignKeys: table.foreignKeys.map( + (fk) => + new ForeignKey({ + ...fk, + source: renamedReference(fk.source, rename), + target: renamedReference(fk.target, rename), + }), + ), + }); +} + +/** + * The same namespace instance with only `entries` replaced: every own + * property, including the non-enumerable `kind`, is carried over onto the + * same prototype, so the target's namespace class (its `qualifyTable`, + * `ddlSchemaName`, entity getters) keeps working on the copy. This is the + * one place a frozen contract node is rebuilt without its constructor: the + * constructors re-hydrate `entries` from raw input, which the already + * hydrated entities here do not need. + */ +function withEntries(namespace: SqlNamespaceBase, entries: SqlNamespaceEntries): SqlNamespaceBase { + const copy: SqlNamespaceBase = Object.create(Object.getPrototypeOf(namespace), { + ...Object.getOwnPropertyDescriptors(namespace), + entries: { + value: Object.freeze(entries), + enumerable: true, + writable: false, + configurable: false, + }, + }); + return Object.freeze(copy); +} + +function renameTablesInNamespace( + namespace: SqlNamespace, + renames: readonly ResolvedTableRename[], +): SqlNamespace { + const own = renames.filter((rename) => rename.namespaceId === namespace.id); + const tables = Object.entries(namespace.entries.table ?? {}).map(([name, table]) => { + const renamedTo = own.find((rename) => rename.from === name)?.to; + const withForeignKeys = renames.reduce(renameForeignKeys, table); + return [renamedTo ?? name, withForeignKeys] as const; + }); + const untouched = tables.every(([name, table]) => namespace.entries.table?.[name] === table); + if (untouched) return namespace; + if (!isMaterializedSqlNamespace(namespace)) { + throw new InternalError( + `applyTableRenameIntents: namespace "${namespace.id}" is not a materialized SQL namespace`, + ); + } + return withEntries(namespace, { + ...namespace.entries, + table: Object.fromEntries(tables), + }); +} + +function renameTablesInContract( + contract: Contract, + renames: readonly ResolvedTableRename[], +): Contract { + const namespaces = Object.fromEntries( + Object.entries(contract.storage.namespaces).map(([id, namespace]) => [ + id, + renameTablesInNamespace(namespace, renames), + ]), + ); + const materialized: Record = {}; + for (const [id, namespace] of Object.entries(namespaces)) { + if (!isMaterializedSqlNamespace(namespace)) { + throw new InternalError( + `applyTableRenameIntents: namespace "${id}" is not a materialized SQL namespace`, + ); + } + materialized[id] = namespace; + } + return { + ...contract, + storage: new SqlStorage({ + storageHash: contract.storage.storageHash, + ...(contract.storage.types === undefined ? {} : { types: contract.storage.types }), + namespaces: materialized, + }), + }; +} + +/** + * Applies operator-stated rename intents to the previous contract so the + * ordinary diff sees each renamed table under its new name. Every intent is + * matched first: its old name must exist in the previous contract (in + * exactly one namespace when unqualified), its new name must not, and its + * new name must exist in the next contract. One conflict per unmatched + * intent; nothing is applied unless every intent matches. + * + * Foreign keys that name a renamed table on either side are retargeted so + * the FK still pairs with the next contract's. Index, unique, check and + * primary-key names are carried unchanged: the differ pairs those by hash or + * by columns, and their renames are planned by the existing rename passes. + */ +export function applyTableRenameIntents( + input: ApplyTableRenameIntentsInput, +): Result { + if (input.intents.length === 0) { + if (input.fromContract === null) { + throw new InternalError('applyTableRenameIntents: nothing to apply and no previous contract'); + } + return ok({ contract: input.fromContract, renames: [] }); + } + if (input.fromContract === null) { + return notOk([ + { + kind: 'unsupportedOperation', + summary: + '--rename-table needs a previous contract to apply the rename to, and this plan starts from an empty database.', + why: 'A rename intent only makes sense between two contracts. Plan from the migration that created the table, or drop the flag.', + }, + ]); + } + const fromContract = input.fromContract; + const conflicts: SqlPlannerConflict[] = []; + const renames: ResolvedTableRename[] = []; + for (const intent of input.intents) { + const resolved = resolveIntent(intent, fromContract, input.toContract); + if (resolved.ok) renames.push(resolved.value); + else conflicts.push(resolved.failure); + } + if (conflicts.length > 0) return notOk(conflicts); + return ok({ contract: renameTablesInContract(fromContract, renames), renames }); +} diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index bd9e4c0685f4..ed3c022b2a77 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -18,6 +18,7 @@ import type { OpFactoryCall, SchemaDiffIssue, SchemaOwnership, + StorageEntityRename, } from '@internal/framework-components/control'; import type { AggregateMigrationEdgeRef } from '@internal/migration-tools/aggregate'; import type { @@ -281,7 +282,8 @@ export type SqlPlannerConflictKind = | 'missingButNonAdditive' | 'unsupportedOperation' | 'controlPolicySuppressedCall' - | 'tableNameCaseChanged'; + | 'tableNameCaseChanged' + | 'tableRenameUnmatched'; export interface SqlPlannerConflictLocation { readonly namespaceId?: string; @@ -373,6 +375,12 @@ export interface SqlMigrationPlannerPlanOptions { * aggregate. See {@link SchemaOwnership}. */ readonly ownership?: SchemaOwnership; + /** + * Operator-stated table renames, applied to `fromContract` before the diff + * so the differ sees each table under its new name; one `renameTable` op + * per intent is prepended to the plan. See {@link StorageEntityRename}. + */ + readonly renames?: readonly StorageEntityRename[]; } export interface SqlMigrationPlanner { diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index ee459ad97af8..c600f33f8203 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -59,6 +59,16 @@ export { detectTableNameCaseChanges, TABLE_NAME_CASE_CHANGED_CODE, } from '../core/migrations/table-name-case-guard'; +export type { + AppliedTableRenames, + ApplyTableRenameIntentsInput, + ResolvedTableRename, + TableRenameIntent, +} from '../core/migrations/table-rename-intents'; +export { + applyTableRenameIntents, + TABLE_RENAME_UNMATCHED_CODE, +} from '../core/migrations/table-rename-intents'; export type { CodecControlHooks, CreateSqlMigrationPlanOptions, diff --git a/packages/2-sql/9-family/test/table-rename-intents.test.ts b/packages/2-sql/9-family/test/table-rename-intents.test.ts index 7429bc4adf22..b9f134a84202 100644 --- a/packages/2-sql/9-family/test/table-rename-intents.test.ts +++ b/packages/2-sql/9-family/test/table-rename-intents.test.ts @@ -123,13 +123,13 @@ describe('applyTableRenameIntents', () => { const renamed = result.value.contract; expect(tablesOf(renamed, UNBOUND_NAMESPACE_ID)).toEqual(['UserProfile', 'post']); const renamedTable = - renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.UserProfile; + renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['UserProfile']; expect(renamedTable).toBeInstanceOf(StorageTable); expect(Object.keys(renamedTable?.columns ?? {})).toEqual(['id', 'parentId']); expect( renamedTable?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName, fk.name]), ).toEqual([['UserProfile', 'UserProfile', 'userProfile_parent_fkey']]); - const post = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.post; + const post = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['post']; expect(post?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName])).toEqual([ ['post', 'UserProfile'], ]); @@ -160,7 +160,7 @@ describe('applyTableRenameIntents', () => { Object.getPrototypeOf(fromContract.storage.namespaces[UNBOUND_NAMESPACE_ID]), ); expect(namespace?.id).toBe(UNBOUND_NAMESPACE_ID); - expect(renamed.storage.namespaces.audit).toBe(fromContract.storage.namespaces.audit); + expect(renamed.storage.namespaces['audit']).toBe(fromContract.storage.namespaces['audit']); expect(renamed.storage.storageHash).toBe(fromContract.storage.storageHash); expect(renamed.storage.types).toEqual(fromContract.storage.types); expect(renamed.profileHash).toBe(fromContract.profileHash); @@ -219,7 +219,10 @@ describe('applyTableRenameIntents', () => { const result = applyTableRenameIntents({ fromContract, toContract: fromContract, intents: [] }); - expect(result).toEqual({ ok: true, value: { contract: fromContract, renames: [] } }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.contract).toBe(fromContract); + expect(result.value.renames).toEqual([]); }); describe('unmatched intents', () => { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 2ea5148e12f2..4fe96a38bd0d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -7,6 +7,7 @@ import type { SuppressionRecord, } from '@internal/family-sql/control'; import { + applyTableRenameIntents, controlPolicyForCall, detectTableNameCaseChanges, extractCodecControlHooks, @@ -24,6 +25,7 @@ import type { MigrationScaffoldContext, SchemaDiffIssue, SchemaOwnership, + StorageEntityRename, } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; @@ -61,9 +63,11 @@ import { RenameCheckConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, + RenameTableCall, } from './op-factory-call'; import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration'; import { postgresPlannerStrategies } from './planner-strategies'; +import { postgresContractToSchema } from './postgres-contract-to-schema'; import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; import { verifyPostgresNamespacePresence } from './verify-postgres-namespaces'; @@ -161,6 +165,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * {@link SqlMigrationPlannerPlanOptions.snapshotsImportPath}. */ readonly snapshotsImportPath: string; + /** See {@link SqlMigrationPlannerPlanOptions.renames}. */ + readonly renames?: readonly StorageEntityRename[]; }): PostgresPlanResult { return this.planSql(options as SqlMigrationPlannerPlanOptions); } @@ -169,8 +175,19 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr context: MigrationScaffoldContext, spaceId: string, ): MigrationPlanWithAuthoringSurface { + // A scaffold has no contract to resolve a namespace against, so a + // qualified intent names the DDL schema directly and an unqualified one + // stays unbound, like every other unqualified authored statement. + const renameCalls = (context.renames ?? []).map( + (rename) => + new RenameTableCall( + rename.from.namespaceId ?? UNBOUND_NAMESPACE_ID, + rename.from.name, + rename.to.name, + ), + ); return new TypeScriptRenderablePostgresMigration( - [], + renameCalls, { from: context.fromHash, to: context.toHash, @@ -191,15 +208,60 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return policyResult; } + // Operator-stated renames are applied to the previous contract before + // the diff, and the "from" tree is re-derived from that renamed contract, + // so the differ sees each renamed table under its new name and plans its + // column, index and constraint changes normally. The renames themselves + // become the first operations of the plan. An intent that matches + // neither side, or a rename the policy's classes do not admit, fails the + // plan here rather than degrading to a drop and a create. + const intents = options.renames ?? []; + const applied = + intents.length === 0 + ? undefined + : applyTableRenameIntents({ + fromContract: options.fromContract, + toContract: options.contract, + intents, + }); + if (applied !== undefined && !applied.ok) { + return plannerFailure(applied.failure); + } + const fromContract = applied === undefined ? options.fromContract : applied.value.contract; + const previousSchema = + applied === undefined + ? options.schema + : postgresContractToSchema(applied.value.contract, options.frameworkComponents); + const renameTableCalls = (applied?.value.renames ?? []).map( + (rename) => + new RenameTableCall( + rename.namespaceId === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : resolveDdlSchemaForNamespaceStorage(options.contract.storage, rename.namespaceId), + rename.from, + rename.to, + ), + ); + const disallowedRenames = renameTableCalls.filter( + (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), + ); + if (disallowedRenames.length > 0) { + return plannerFailure( + disallowedRenames.map((call) => + conflictForDisallowedCall(call, options.policy.allowedOperationClasses), + ), + ); + } + // The one combined tree diff drives the whole plan: relational findings // become structural DDL via `planIssues`, policy findings become RLS ops // via `planPostgresSchemaDiff`. Verify runs its own full-tree node diff // (`diffSchema`) over the same schema and rejects on a // surviving failure. - PostgresDatabaseSchemaNode.assert(options.schema); + PostgresDatabaseSchemaNode.assert(previousSchema); const { issues: rawIssues } = buildPostgresPlanDiff({ contract: options.contract, - actualSchema: options.schema, + actualSchema: previousSchema, frameworkComponents: options.frameworkComponents, }); const policyDiffIssues = rawIssues.filter((issue) => isPolicyDiffIssue(issue)); @@ -246,7 +308,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // sibling-space scoping, matching the retired coordinate walk exactly. const namespaceIssues = verifyPostgresNamespacePresence({ contract: options.contract, - schema: options.schema, + schema: previousSchema, }); const schemaIssues = [...namespaceIssues, ...gated]; @@ -271,7 +333,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // per-schema namespace node — never the whole tree root, and never a flat // merge of every namespace (which would collide same-named tables across // schemas). Probing more than one namespace at once is future work. - const relationalSchema = relationalNamespaceNode(options.schema, schemaName); + const relationalSchema = relationalNamespaceNode(previousSchema, schemaName); // Input-side control-policy partition. `external` / `observed` subjects // — and non-creation issues for `tolerated` subjects — are dropped from @@ -312,7 +374,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // from/to comparisons (unsafe type change, nullable tightening) are // inapplicable there — reconciliation falls through to // `mapNodeIssueToCall`'s direct destructive handlers. - fromContract: options.fromContract, + fromContract, schemaName, codecHooks, storageTypes, @@ -329,6 +391,14 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return plannerFailure([...(result.ok ? [] : result.failure), ...schemaDiff.conflicts]); } + const renameTablePartition = partitionCallsByControlPolicy({ + calls: renameTableCalls, + contract: options.contract, + resolveControlPolicySubject: (call) => + resolvePostgresCallControlPolicySubject(call, options.contract), + resolveFactoryName: (call) => call.factoryName, + }); + const indexRenamePartition = partitionCallsByControlPolicy({ calls: [...indexRenames.calls, ...checkRenames.calls], contract: options.contract, @@ -351,7 +421,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // byte-stable. The hook fires only at the application emitter — // extension-space planning never reaches this helper. const fieldEventOps = planFieldEventOperations({ - priorContract: options.fromContract, + priorContract: fromContract, newContract: options.contract, codecHooks, }); @@ -368,7 +438,10 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr resolvePostgresCallControlPolicySubject(call, options.contract), resolveFactoryName: (call) => call.factoryName, }); + // The table renames run first: every later operation addresses the + // renamed table by its new name. const calls = [ + ...renameTablePartition.kept, ...result.value.calls, ...indexRenamePartition.kept, ...schemaDiffPartition.kept, @@ -381,6 +454,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr const seenWarnings = new Set(); const warnings: SqlPlannerConflict[] = [ ...issuePartition.suppressions, + ...renameTablePartition.suppressions, ...indexRenamePartition.suppressions, ...schemaDiff.suppressions, ...schemaDiffPartition.suppressions, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts new file mode 100644 index 000000000000..d51e273069f2 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts @@ -0,0 +1,41 @@ +import type { ColumnDefault, Contract } from '@internal/contract/types'; +import { buildNativeTypeExpander } from '@internal/family-sql/control'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; +import type { StorageColumn } from '@internal/sql-contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; +import { postgresResolveDefault } from '../default-normalizer'; +import type { PostgresContract } from '../postgres-schema'; +import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; +import { renderDefaultLiteral } from './planner-ddl-builders'; + +export function postgresRenderDefault(def: ColumnDefault, column: StorageColumn): string { + if (def.kind === 'function') { + return def.expression; + } + return renderDefaultLiteral(def.value, column); +} + +/** + * The Postgres schema tree a contract describes, as the planner's "from" side: + * the target descriptor's `migrations.contractToSchema` hook and the planner's + * own re-derivation after applying rename intents both go through here so the + * two trees are built with the same expander, default renderer and resolver. + */ +export function postgresContractToSchema( + contract: Contract | null, + frameworkComponents: ReadonlyArray> | undefined, +): PostgresDatabaseSchemaNode { + const expander = buildNativeTypeExpander(frameworkComponents); + const postgresContract = blindCast< + PostgresContract | null, + 'the family resolver only binds this hook for a Postgres-target contract' + >(contract); + return contractToPostgresDatabaseSchemaNode(postgresContract, { + annotationNamespace: 'pg', + ...ifDefined('expandNativeType', expander), + renderDefault: postgresRenderDefault, + resolveDefault: postgresResolveDefault, + }); +} diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 65fd945e7d1f..66ec9362da13 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -1,24 +1,16 @@ -import type { ColumnDefault } from '@internal/contract/types'; import type { SqlControlTargetDescriptor } from '@internal/family-sql/control'; -import { buildNativeTypeExpander } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import type { ControlTargetInstance, MigrationRunner, } from '@internal/framework-components/control'; -import type { StorageColumn } from '@internal/sql-contract/types'; -import { blindCast } from '@internal/utils/casts'; -import { ifDefined } from '@internal/utils/defined'; -import { postgresResolveDefault } from '../core/default-normalizer'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; -import { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; import { diffPostgresSchema } from '../core/migrations/diff-database-schema'; import { createPostgresMigrationPlanner } from '../core/migrations/planner'; -import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders'; import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details'; +import { postgresContractToSchema } from '../core/migrations/postgres-contract-to-schema'; import { createPostgresMigrationRunner } from '../core/migrations/runner'; import { PostgresContractSerializer } from '../core/postgres-contract-serializer'; -import type { PostgresContract } from '../core/postgres-schema'; import { PostgresSchemaVerifier } from '../core/postgres-schema-verifier'; import { inferPostgresPslContract } from '../core/psl-infer/infer-psl-contract'; import { PostgresDatabaseSchemaNode } from '../core/schema-ir/postgres-database-schema-node'; @@ -27,12 +19,7 @@ import { postgresDiffSubjectGranularity, } from '../core/schema-ir/schema-node-kinds'; -export function postgresRenderDefault(def: ColumnDefault, column: StorageColumn): string { - if (def.kind === 'function') { - return def.expression; - } - return renderDefaultLiteral(def.value, column); -} +export { postgresRenderDefault } from '../core/migrations/postgres-contract-to-schema'; const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresPlanTargetDetails> = { @@ -56,17 +43,7 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP return createPostgresMigrationRunner(family) as MigrationRunner<'sql', 'postgres'>; }, contractToSchema(contract, frameworkComponents) { - const expander = buildNativeTypeExpander(frameworkComponents); - const postgresContract = blindCast< - PostgresContract | null, - 'the family resolver only binds this hook for a Postgres-target contract' - >(contract); - return contractToPostgresDatabaseSchemaNode(postgresContract, { - annotationNamespace: 'pg', - ...ifDefined('expandNativeType', expander), - renderDefault: postgresRenderDefault, - resolveDefault: postgresResolveDefault, - }); + return postgresContractToSchema(contract, frameworkComponents); }, }, create(): ControlTargetInstance<'sql', 'postgres'> { diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts index 6cdfbab5ad4b..43dbeff7ba80 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts @@ -12,6 +12,7 @@ import type { StorageEntityRename, } from '@internal/framework-components/control'; import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { keepInternalSpecifiers } from '@internal/framework-components/emission'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; @@ -29,6 +30,8 @@ const stubLowerer: ExecuteRequestLowerer = { const ALL_CLASSES = ['additive', 'widening', 'destructive'] as const; const ADDITIVE_ONLY = ['additive'] as const; +const FROM_HASH = 'a'.repeat(64); +const TO_HASH = 'b'.repeat(64); interface TableSpec { readonly extraColumn?: string; @@ -118,8 +121,8 @@ const rename = (from: string, to: string): StorageEntityRename => ({ describe('Postgres planner rename-table intents', () => { it('plans exactly one rename op when the tables are otherwise identical', async () => { - const from = contractOf({ userProfile: {} }, 'from'); - const to = contractOf({ UserProfile: {} }, 'to'); + const from = contractOf({ userProfile: {} }, FROM_HASH); + const to = contractOf({ UserProfile: {} }, TO_HASH); const result = plan({ from, to, renames: [rename('userProfile', 'UserProfile')] }); @@ -139,22 +142,22 @@ describe('Postgres planner rename-table intents', () => { it('renders the rename as a facade call in the migration file', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); expect(result.kind).toBe('success'); if (result.kind !== 'success') return; - expect(result.plan.renderTypeScript()).toContain( + expect(result.plan.renderTypeScript(keepInternalSpecifiers)).toContain( 'this.renameTable({ table: "userProfile", to: "UserProfile" })', ); }); it('plans the rename first, then the added column on the new name', async () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: { extraColumn: 'nickname' } }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: { extraColumn: 'nickname' } }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); @@ -168,8 +171,8 @@ describe('Postgres planner rename-table intents', () => { it('qualifies the statement with the schema when the intent names a bound namespace', async () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from', 'auth'), - to: contractOf({ UserProfile: {} }, 'to', 'auth'), + from: contractOf({ userProfile: {} }, FROM_HASH, 'auth'), + to: contractOf({ UserProfile: {} }, TO_HASH, 'auth'), renames: [ { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, ], @@ -185,8 +188,8 @@ describe('Postgres planner rename-table intents', () => { it('applies several intents, one rename op each, in the order given', async () => { const result = plan({ - from: contractOf({ userProfile: {}, orderLine: {} }, 'from'), - to: contractOf({ UserProfile: {}, OrderLine: {} }, 'to'), + from: contractOf({ userProfile: {}, orderLine: {} }, FROM_HASH), + to: contractOf({ UserProfile: {}, OrderLine: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile'), rename('orderLine', 'OrderLine')], }); @@ -198,8 +201,8 @@ describe('Postgres planner rename-table intents', () => { it('fails when the intent names a previous table that does not exist', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('ghost', 'UserProfile')], }); @@ -220,8 +223,8 @@ describe('Postgres planner rename-table intents', () => { it('fails when the new name already exists in the previous state', () => { const result = plan({ - from: contractOf({ userProfile: {}, UserProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {}, UserProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); @@ -233,8 +236,8 @@ describe('Postgres planner rename-table intents', () => { it('fails when the new name is absent from the next contract', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ Account: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ Account: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); @@ -247,7 +250,7 @@ describe('Postgres planner rename-table intents', () => { it('fails when intents are given without a prior contract to apply them to', () => { const result = plan({ from: null, - to: contractOf({ UserProfile: {} }, 'to'), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); @@ -258,8 +261,8 @@ describe('Postgres planner rename-table intents', () => { it('still fires the case-change guard when the pair is not covered by an intent', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), }); expect(result.kind).toBe('failure'); @@ -269,8 +272,8 @@ describe('Postgres planner rename-table intents', () => { it('refuses the rename under an additive-only policy like any other widening op', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], policy: ADDITIVE_ONLY, }); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts index b1ffe35e9286..f8e249e64784 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts @@ -531,7 +531,7 @@ function emptySchemaIR(): SqlSchemaIR { return new SqlSchemaIR({ tables: {} }); } -function conflictForDisallowedCall( +export function conflictForDisallowedCall( call: SqliteOpFactoryCall, allowed: readonly string[], ): SqlPlannerConflict { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 3aeadd0ad65a..4352c03eb79b 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -6,6 +6,7 @@ import type { SqlPlannerFailureResult, } from '@internal/family-sql/control'; import { + applyTableRenameIntents, detectTableNameCaseChanges, extractCodecControlHooks, planFieldEventOperations, @@ -18,6 +19,7 @@ import type { MigrationScaffoldContext, SchemaDiffIssue, SchemaOwnership, + StorageEntityRename, } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; @@ -26,8 +28,14 @@ import { type SqlSchemaIR, SqlTableIR, } from '@internal/sql-schema-ir/types'; -import { buildSqlitePlanDiff } from './diff-database-schema'; -import { coalesceSubtreeIssues, issueNode, planIssues } from './issue-planner'; +import { buildSqlitePlanDiff, sqliteContractToSchema } from './diff-database-schema'; +import { + coalesceSubtreeIssues, + conflictForDisallowedCall, + issueNode, + planIssues, +} from './issue-planner'; +import { RenameTableCall } from './op-factory-call'; import { type SqliteMigrationDestinationInfo, TypeScriptRenderableSqliteMigration, @@ -103,6 +111,8 @@ export class SqliteMigrationPlanner * {@link SqlMigrationPlannerPlanOptions.snapshotsImportPath}. */ readonly snapshotsImportPath: string; + /** See {@link SqlMigrationPlannerPlanOptions.renames}. */ + readonly renames?: readonly StorageEntityRename[]; }): SqlitePlanResult { return this.planSql(options as SqlMigrationPlannerPlanOptions); } @@ -111,8 +121,11 @@ export class SqliteMigrationPlanner context: MigrationScaffoldContext, spaceId: string, ): TypeScriptRenderableSqliteMigration { + const renameCalls = (context.renames ?? []).map( + (rename) => new RenameTableCall(rename.from.name, rename.to.name), + ); return new TypeScriptRenderableSqliteMigration( - [], + renameCalls, { from: context.fromHash, to: context.toHash, @@ -128,7 +141,45 @@ export class SqliteMigrationPlanner const policyResult = this.ensureAdditivePolicy(options.policy); if (policyResult) return policyResult; - const { expected, actual, issues } = this.collectSchemaIssues(options); + // Operator-stated renames are applied to the previous contract before + // the diff, and the "from" tree is re-derived from that renamed contract, + // so the differ sees each renamed table under its new name. The renames + // themselves become the first operations of the plan; an intent that + // matches neither side, or a rename the policy's classes do not admit, + // fails the plan rather than degrading to a drop and a create. + const intents = options.renames ?? []; + const applied = + intents.length === 0 + ? undefined + : applyTableRenameIntents({ + fromContract: options.fromContract, + toContract: options.contract, + intents, + }); + if (applied !== undefined && !applied.ok) { + return plannerFailure(applied.failure); + } + const fromContract = applied === undefined ? options.fromContract : applied.value.contract; + const previousSchema = + applied === undefined ? options.schema : sqliteContractToSchema(applied.value.contract); + const renameTableCalls = (applied?.value.renames ?? []).map( + (rename) => new RenameTableCall(rename.from, rename.to), + ); + const disallowedRenames = renameTableCalls.filter( + (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), + ); + if (disallowedRenames.length > 0) { + return plannerFailure( + disallowedRenames.map((call) => + conflictForDisallowedCall(call, options.policy.allowedOperationClasses), + ), + ); + } + + const { expected, actual, issues } = this.collectSchemaIssues({ + ...options, + schema: previousSchema, + }); const caseChangeConflicts = detectTableNameCaseChanges({ issues, tableOf: (issue) => { @@ -162,14 +213,15 @@ export class SqliteMigrationPlanner // Hook fires only at the application emitter — extension-space planning // (M2 R2) never reaches this helper. const fieldEventOps = planFieldEventOperations({ - priorContract: options.fromContract, + priorContract: fromContract, newContract: options.contract, codecHooks, }); // Codec-emitted calls already conform to `OpFactoryCall` — render + // toOp + importRequirements ride directly through the same emit path - // as structural ops, no `RawSqlCall` wrap. - const calls = [...result.value.calls, ...fieldEventOps]; + // as structural ops, no `RawSqlCall` wrap. The table renames run first: + // every later operation addresses the renamed table by its new name. + const calls = [...renameTableCalls, ...result.value.calls, ...fieldEventOps]; const destination: SqliteMigrationDestinationInfo = { storageHash: options.contract.storage.storageHash, diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts index c9dabd8418b2..f67f88aec72f 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts @@ -12,6 +12,7 @@ import type { StorageEntityRename, } from '@internal/framework-components/control'; import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { keepInternalSpecifiers } from '@internal/framework-components/emission'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; @@ -29,6 +30,8 @@ const stubLowerer: ExecuteRequestLowerer = { const ALL_CLASSES = ['additive', 'widening', 'destructive'] as const; const ADDITIVE_ONLY = ['additive'] as const; +const FROM_HASH = 'a'.repeat(64); +const TO_HASH = 'b'.repeat(64); interface TableSpec { readonly extraColumn?: string; @@ -107,8 +110,8 @@ const rename = (from: string, to: string): StorageEntityRename => ({ describe('SQLite planner rename-table intents', () => { it('plans exactly one rename op when the tables are otherwise identical', async () => { - const from = contractOf({ userProfile: {} }, 'from'); - const to = contractOf({ UserProfile: {} }, 'to'); + const from = contractOf({ userProfile: {} }, FROM_HASH); + const to = contractOf({ UserProfile: {} }, TO_HASH); const result = plan({ from, to, renames: [rename('userProfile', 'UserProfile')] }); @@ -128,22 +131,22 @@ describe('SQLite planner rename-table intents', () => { it('renders the rename as a facade call in the migration file', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); expect(result.kind).toBe('success'); if (result.kind !== 'success') return; - expect(result.plan.renderTypeScript()).toContain( + expect(result.plan.renderTypeScript(keepInternalSpecifiers)).toContain( 'this.renameTable({ table: "userProfile", to: "UserProfile" })', ); }); it('plans the rename first, then the added column on the new name', async () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: { extraColumn: 'nickname' } }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: { extraColumn: 'nickname' } }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); @@ -155,8 +158,8 @@ describe('SQLite planner rename-table intents', () => { it('fails when the intent names a previous table that does not exist', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('ghost', 'UserProfile')], }); @@ -177,8 +180,8 @@ describe('SQLite planner rename-table intents', () => { it('fails when the new name already exists in the previous state', () => { const result = plan({ - from: contractOf({ userProfile: {}, UserProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {}, UserProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); @@ -190,8 +193,8 @@ describe('SQLite planner rename-table intents', () => { it('fails when the new name is absent from the next contract', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ Account: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ Account: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); @@ -202,8 +205,8 @@ describe('SQLite planner rename-table intents', () => { it('fails when the intent is qualified with a namespace SQLite does not have', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [ { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, ], @@ -217,7 +220,7 @@ describe('SQLite planner rename-table intents', () => { it('fails when intents are given without a prior contract to apply them to', () => { const result = plan({ from: null, - to: contractOf({ UserProfile: {} }, 'to'), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], }); @@ -228,8 +231,8 @@ describe('SQLite planner rename-table intents', () => { it('still fires the case-change guard when the pair is not covered by an intent', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), }); expect(result.kind).toBe('failure'); @@ -239,8 +242,8 @@ describe('SQLite planner rename-table intents', () => { it('refuses the rename under an additive-only policy like any other widening op', () => { const result = plan({ - from: contractOf({ userProfile: {} }, 'from'), - to: contractOf({ UserProfile: {} }, 'to'), + from: contractOf({ userProfile: {} }, FROM_HASH), + to: contractOf({ UserProfile: {} }, TO_HASH), renames: [rename('userProfile', 'UserProfile')], policy: ADDITIVE_ONLY, }); From 0c65ae6d74e5af39ba87527d722937f489bf3d23 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 10:33:32 +0200 Subject: [PATCH 05/55] feat(migration): point the table-name case guard at --rename-table; SQLite case-only renames The MIGRATION.TABLE_NAME_CASE_CHANGED remedy now names `prisma migration plan --rename-table "="` instead of a by-hand ALTER TABLE; the error reference documents it together with MIGRATION.TABLE_RENAME_UNMATCHED and CLI.INVALID_RENAME_TABLE_FLAG, and the upgrade fragment tells users to state renames with the flag (Mongo keeps the by-hand path). SQLite compares table names case-insensitively, so a rename that only changes case goes through a temporary name; the journeys prove the rows survive on both targets. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 10 ++++- .../core/migrations/table-name-case-guard.ts | 2 +- .../src/core/migrations/op-factory-call.ts | 35 ++++++++++++--- .../test/migrations/rename-table-ops.test.ts | 43 ++++++++++--------- .../migrations/rename-table-planner.test.ts | 3 +- .../rename-table-migration.e2e.test.ts | 4 +- .../rename-table-migration.sqlite.e2e.test.ts | 4 +- 7 files changed, 68 insertions(+), 33 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 20cf3a0510eb..293d15f51ae3 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -167,6 +167,10 @@ Raised by the commander `init` (deleted in the S5 cutover). On the engine-hosted The main CLI received a `--format` value other than `pretty` or `json`. Raised during global-flag resolution, before any command logic runs. Payload: `value`, `allowed`. +### CLI.INVALID_RENAME_TABLE_FLAG + +A `--rename-table` value given to `migration plan` or `migration new` is not `=` (each side optionally qualified as `.`), names the same entity on both sides, or repeats an old or a new name already used by another value on the same command. Nothing is planned or written. Payload: `value`. + ### CLI.INVALID_VERIFY_MODE `prisma db verify` was given a contradictory mode combination: `--marker-only` together with `--schema-only`, or `--strict` together with `--marker-only` (strict requires schema verification, which marker-only skips). Payload: none. @@ -1300,7 +1304,11 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.TABLE_NAME_CASE_CHANGED -The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename it by hand with `ALTER TABLE "X" RENAME TO "Y"`, after which the plan is empty. Payload: `droppedTable`, `createdTable`. +The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or state the rename when planning with `prisma migration plan --rename-table "X=Y"`, which plans one `renameTable` operation in place of the drop and the create. Payload: `droppedTable`, `createdTable`. + +### MIGRATION.TABLE_RENAME_UNMATCHED + +A `--rename-table =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, or the two sides name different namespaces. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. ### MIGRATION.TARGET_MISMATCH diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index 4f09506266c4..62b586425326 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -61,7 +61,7 @@ export function detectTableNameCaseChanges(input: { conflicts.push({ kind: 'tableNameCaseChanged', summary: `${TABLE_NAME_CASE_CHANGED_CODE}: table "${create.tableName}" would be created and table "${drop.tableName}" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model ${create.tableName} points at "${create.tableName}" instead of "${drop.tableName}".`, - why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. Prisma 8 has no rename-table operation, so a deliberate rename is done by hand: run ALTER TABLE "${drop.tableName}" RENAME TO "${create.tableName}" (schema-qualified where applicable), after which the plan is empty.`, + why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead, state the rename when planning: prisma migration plan --rename-table "${drop.tableName}=${create.tableName}" (either side may be .); the plan then carries one renameTable operation in place of the drop and the create.`, location: { namespaceId: create.namespaceId, entityKind: 'table', diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index 2c3b71fdb5bd..0923e54d4853 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -305,14 +305,39 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { step(`ensure table "${this.oldTableName}" exists`, from.sql, from.params), step(`ensure table "${this.tableName}" does not exist`, toAbsent.sql, toAbsent.params), ], - execute: [ + execute: this.executeSteps(), + postcheck: [step(`verify table "${this.tableName}" exists`, toPresent.sql, toPresent.params)], + }; + } + + /** + * SQLite compares table names case-insensitively, so a rename that only + * changes case (`userProfile` to `UserProfile`) is refused as "already + * exists" when done in one statement. It goes through a temporary name. + */ + private executeSteps(): Op['execute'] { + const from = quoteIdentifier(this.oldTableName); + const to = quoteIdentifier(this.tableName); + if (this.oldTableName.toLowerCase() !== this.tableName.toLowerCase()) { + return [ step( `rename table "${this.oldTableName}" to "${this.tableName}"`, - `ALTER TABLE ${quoteIdentifier(this.oldTableName)} RENAME TO ${quoteIdentifier(this.tableName)}`, + `ALTER TABLE ${from} RENAME TO ${to}`, ), - ], - postcheck: [step(`verify table "${this.tableName}" exists`, toPresent.sql, toPresent.params)], - }; + ]; + } + const viaName = `_prisma_rename_${this.tableName}`; + const via = quoteIdentifier(viaName); + return [ + step( + `rename table "${this.oldTableName}" to "${viaName}" (SQLite table names are case-insensitive)`, + `ALTER TABLE ${from} RENAME TO ${via}`, + ), + step( + `rename table "${viaName}" to "${this.tableName}"`, + `ALTER TABLE ${via} RENAME TO ${to}`, + ), + ]; } renderTypeScript(): string { diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts index 8095aabf9d6b..1328afa1cfa8 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts @@ -33,42 +33,45 @@ describe('RenameTableCall (sqlite)', () => { it('renders ALTER TABLE ... RENAME TO with existence prechecks and a postcheck', async () => { const { lowerer, received } = recordingCheckLowerer(); - const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); + const op = await new RenameTableCall('profile', 'account').toOp(lowerer); expect(received).toEqual([ - tableExistsAst('userProfile').tablePresent(), - tableExistsAst('UserProfile').tableAbsent(), - tableExistsAst('UserProfile').tablePresent(), + tableExistsAst('profile').tablePresent(), + tableExistsAst('account').tableAbsent(), + tableExistsAst('account').tablePresent(), ]); expect(op).toEqual({ - id: 'renameTable.userProfile', - label: 'Rename table userProfile to UserProfile', - summary: 'Renames table userProfile to UserProfile, keeping its rows', + id: 'renameTable.profile', + label: 'Rename table profile to account', + summary: 'Renames table profile to account, keeping its rows', operationClass: 'widening', - target: { - id: 'sqlite', - details: { schema: 'main', objectType: 'table', name: 'UserProfile' }, - }, + target: { id: 'sqlite', details: { schema: 'main', objectType: 'table', name: 'account' } }, precheck: [ - { description: 'ensure table "userProfile" exists', sql: 'LOWERED 1', params: ['p1'] }, - { - description: 'ensure table "UserProfile" does not exist', - sql: 'LOWERED 2', - params: ['p2'], - }, + { description: 'ensure table "profile" exists', sql: 'LOWERED 1', params: ['p1'] }, + { description: 'ensure table "account" does not exist', sql: 'LOWERED 2', params: ['p2'] }, ], execute: [ { - description: 'rename table "userProfile" to "UserProfile"', - sql: 'ALTER TABLE "userProfile" RENAME TO "UserProfile"', + description: 'rename table "profile" to "account"', + sql: 'ALTER TABLE "profile" RENAME TO "account"', }, ], postcheck: [ - { description: 'verify table "UserProfile" exists', sql: 'LOWERED 3', params: ['p3'] }, + { description: 'verify table "account" exists', sql: 'LOWERED 3', params: ['p3'] }, ], }); }); + it('renames through a temporary name when only the case changes, which SQLite would otherwise refuse', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"', + 'ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"', + ]); + }); + it('toOp() without a lowerer reports MIGRATION.SQLITE_CONTROL_STACK_MISSING', async () => { const call = new RenameTableCall('userProfile', 'UserProfile'); await expect(call.toOp()).rejects.toMatchObject({ diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts index f67f88aec72f..1e52cf6fbfab 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts @@ -120,7 +120,8 @@ describe('SQLite planner rename-table intents', () => { const ops = await Promise.all(result.plan.operations); expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); expect(ops[0]?.execute.map((step) => step.sql)).toEqual([ - 'ALTER TABLE "userProfile" RENAME TO "UserProfile"', + 'ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"', + 'ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"', ]); expect(ops[0]?.operationClass).toBe('widening'); expect(result.plan.describe()).toEqual({ diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts index 8c80d4a41376..137c1544f394 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -179,8 +179,8 @@ withTempDir(({ createTempDir }) => { const latest = getLatestMigrationDir(ctx); expect(latest, 'R2.05: scaffold dir').toBeDefined(); const packageDir = join(ctx.testDir, 'migrations', 'app', latest!); - expect(readFileSync(join(packageDir, 'migration.ts'), 'utf-8'), 'R2.05: call').toContain( - 'this.renameTable({ table: "userProfile", to: "UserProfile" })', + expect(readFileSync(join(packageDir, 'migration.ts'), 'utf-8'), 'R2.05: call').toMatch( + /this\.renameTable\(\{ table: ['"]userProfile['"], to: ['"]UserProfile['"] \}\)/, ); const emitted = await selfEmitMigration(ctx, ['--dir', `migrations/app/${latest}`]); diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts index 8b0feb602a1c..7e5b063bd314 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -146,9 +146,7 @@ withTempDir(({ createTempDir }) => { join(ctx.testDir, 'migrations', 'app', getMigrationDirs(ctx).at(-1)!, 'ops.json'), 'utf-8', ); - expect(opsJson, 'R3.07: rendered SQL').toContain( - 'ALTER TABLE \\"userProfile\\" RENAME TO \\"UserProfile\\"', - ); + expect(opsJson, 'R3.07: rendered SQL').toContain('RENAME TO \\"UserProfile\\"'); const apply = await runMigrate(ctx); expect(apply.exitCode, `R3.08: migrate: ${apply.stderr}`).toBe(0); From 2466aac8b9ac613a6368fa37536c5e0b4717973c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 10:42:22 +0200 Subject: [PATCH 06/55] test: index-signature access in the rename journeys Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/cli-journeys/rename-table-migration.e2e.test.ts | 2 +- .../test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts index 137c1544f394..88f0d93adb31 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -82,7 +82,7 @@ async function expectRowsSurvived(connectionString: string, label: string): Prom { id: 2, email: 'bob@example.com' }, ]); const old = await sql(connectionString, `SELECT to_regclass('"public"."userProfile"') AS old`); - expect(old.rows[0]?.old, `${label}: old name is gone`).toBeNull(); + expect(old.rows[0]?.['old'], `${label}: old name is gone`).toBeNull(); } withTempDir(({ createTempDir }) => { diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts index 7e5b063bd314..ec48ae7e7d7a 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -157,7 +157,7 @@ withTempDir(({ createTempDir }) => { `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%serProfile'`, ) .all() - .map((row) => row.name), + .map((row) => row['name']), })); expect(state.rows, 'R3.09: rows present under the new name').toEqual([ { id: 1, email: 'alice@example.com' }, From 23871192ca34b7f5f856287ba5c41370dc4d7c46 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 11:23:19 +0200 Subject: [PATCH 07/55] docs(projects): slice 2 spec, migration plan feeds the planner and migration new scaffolds the operation Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/rename-table-operation/spec.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md index 40bb2abd7598..154fc23ee4a3 100644 --- a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md +++ b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md @@ -7,14 +7,16 @@ Today a model whose table name changes plans as `DropTable` plus `CreateTable`, and the rows are gone. After this slice: ```bash -prisma migration new --name rename-user-profile --rename-table userProfile=UserProfile +prisma migration plan --rename-table userProfile=UserProfile ``` plans one `renameTable` operation followed by whatever else the diff needs, `migrate` runs `ALTER TABLE "userProfile" RENAME TO "UserProfile"`, and the rows survive. The `MIGRATION.TABLE_NAME_CASE_CHANGED` guard's second remedy points at this flag instead of a by-hand `ALTER TABLE`. +`migration new` does not run the planner; it scaffolds an empty migration file for hand-authored operations. With the same flag it scaffolds a file whose body already holds one `this.renameTable({ from, to })` call per intent, so both commands accept the same grammar. Amended after the implementer checked the CLI: the first draft assumed `migration new` planned. + ## Chosen design -**Intent comes from the CLI, never from inference.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The operator states it: `--rename-table =`, repeatable, on `migration new` and on `migration plan` (so a plan can be previewed with the same flags). Either side may be schema-qualified, `auth.userProfile=UserProfile`; an unqualified name means the target's default namespace. Nothing is added to the schema; there is no attribute and no prompt. +**Intent comes from the CLI, never from inference.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The operator states it: `--rename-table =`, repeatable, on `migration plan` (which feeds the planner) and on `migration new` (which scaffolds the operation into the hand-authored file). Either side may be schema-qualified, `auth.userProfile=UserProfile`; an unqualified name means the target's default namespace. Nothing is added to the schema; there is no attribute and no prompt. **Rename intents are applied to the previous schema before the diff runs.** Rather than pairing issues after the fact, the planner takes the previous state (the prior contract's schema IR, or the introspected live schema) and renames the stated tables in it, then runs the ordinary diff against the next contract, then prepends one `RenameTableCall` per intent to the operations. The differ therefore sees the table under its new name and plans any column, index or constraint changes on it normally, and the case-change guard never sees a drop and create pair for it. Existing index and check rename pairing continues to work on the renamed table, since it pairs by wire-name hash. @@ -22,11 +24,11 @@ plans one `renameTable` operation followed by whatever else the diff needs, `mig **The operation.** `renameTable` in the Postgres and SQLite operation sets, following the `renameCheckConstraint` precedent (PR #29894) end to end: op builder with precheck (`from` exists, `to` absent), execute (`ALTER TABLE ... RENAME TO ...`, schema-qualified on Postgres), postcheck; `RenameTableCall` in the op-factory call union with `renderTypeScript` and import requirements so a hand-written migration can call it; the migration facade method; the public op-factory export. Operation class `widening`, idempotency class per ADR 038 (effect-idempotent with equivalence check: pre-state compatible if `from` or `to` already holds the table). Under an `additive`-only policy the op is refused the same way other `widening` ops are, not degraded to drop and create. -**Guard remedy.** `table-name-case-guard.ts` second way out becomes: `prisma migration new --rename-table "="` (Postgres and SQLite), keeping the by-hand `ALTER TABLE` sentence only for Mongo. The error reference entry and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` are updated to match. +**Guard remedy.** `table-name-case-guard.ts` second way out becomes: `prisma migration plan --rename-table "="` (Postgres and SQLite), keeping the by-hand `ALTER TABLE` sentence only for Mongo. The error reference entry and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` are updated to match. ## Scope -**In:** op builder, call, facade, export, TypeScript rendering for Postgres and SQLite; the pre-diff rename application and the unmatched-intent conflict in the shared SQL family planner code, wired in both target planners; CLI flag parsing and threading for `migration new` and `migration plan`; guard remedy text; error reference; fragment text; tests below. +**In:** op builder, call, facade, export, TypeScript rendering for Postgres and SQLite; the pre-diff rename application and the unmatched-intent conflict in the shared SQL family planner code, wired in both target planners; CLI flag parsing for both commands, threading into the planner on `migration plan` and into the scaffold on `migration new`; guard remedy text; error reference; fragment text; tests below. **Out:** `db update` and `db init` (no flag; the guard still protects them); Mongo; any rename inference; renaming columns; a schema attribute. @@ -34,8 +36,8 @@ plans one `renameTable` operation followed by whatever else the diff needs, `mig - Op level, both targets: `renameTable` renders the expected SQL; precheck fails when `from` is missing or `to` exists; `renderTypeScript` round-trips through the migration file parser. - Planner, both targets: with one intent and otherwise identical tables, the plan is exactly one rename op; with an intent and an added column, the plan is the rename op followed by the add-column op on the new name; an intent whose `from` is absent, or whose `to` already exists, yields a failure conflict naming it; the case-change guard does not fire when the pair is covered by an intent and still fires when it is not; an `additive`-only policy refuses the rename as it refuses other widening ops. -- CLI: `--rename-table` parses repeated and schema-qualified values, rejects a malformed value with a clear error, and reaches the planner options on both commands. -- End to end, Postgres (PGlite) and SQLite, under `test/integration/test/cli-journeys/`: create a table with rows under the old name, change the model's table name, run `migration new --rename-table` then `migrate`, and prove the rows are present under the new name and `db verify --schema-only` is clean. +- CLI: `--rename-table` parses repeated and schema-qualified values, rejects a malformed value with a clear error, reaches the planner options on `migration plan`, and on `migration new` produces a migration file that round-trips through the loader and whose operation renders the expected SQL. +- End to end, Postgres (PGlite) and SQLite, under `test/integration/test/cli-journeys/`: create a table with rows under the old name, change the model's table name, run `migration plan --rename-table` then `migrate`, and prove the rows are present under the new name and `db verify --schema-only` is clean. - Guard text: the existing guard tests pin the new remedy string. ## Done conditions From 8ade679cc7cb6d588f967aa9647b86d1f05a409a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 11:30:22 +0200 Subject: [PATCH 08/55] test(cli): load a fresh command tree in the rename-table flag test The repo-wide vitest run uses isolate: false, so another CLI test file in the same fork can load src/orm/cli first. The cached command tree then calls the real plan and scaffold operations instead of the mocks, and three tests exit with code 2 (CONTRACT.VALIDATION_FAILED). Reset the module registry before importing the tree, and unmock and reset after the file, as migration-log.test.ts does. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/test/orm/migration-rename-table-flag.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts index 8a45e11dcbed..138ad850875e 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts @@ -31,6 +31,7 @@ vi.mock('../../src/control-api/operations/migration-new', async (importOriginal) return { ...actual, executeMigrationNewCommand: mocks.executeMigrationNewCommand }; }); +vi.resetModules(); const { BIN_GROUPS, createBinCommands } = await import('../../src/orm/cli'); const commands = createBinCommands(() => ({}) as unknown as ControlClient); @@ -45,6 +46,9 @@ afterAll(() => { for (const dir of projectDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } + vi.doUnmock('../../src/control-api/operations/migration-plan'); + vi.doUnmock('../../src/control-api/operations/migration-new'); + vi.resetModules(); }); beforeEach(() => { From 19301eac36996225785f23ff057b22d5b0cd2c75 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 11:38:25 +0200 Subject: [PATCH 09/55] test: keep the SQLite rename journey on one import root The SQLite rename journey wrote its prisma.config.ts from an inline string that imports @internal/sqlite/config. The cli-journeys package names the published @prisma/orm-* roots, so lint:deps (lint-single-import-root) reported the package as naming both roots. Move the config into the cli-e2e-test-app journey fixtures, which stay on the workspace root like the Postgres configs, and have the journey fill in the database path. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../rename-table-migration.sqlite.e2e.test.ts | 24 ++++--------------- .../cli-journeys/prisma.config.sqlite.psl.ts | 14 +++++++++++ .../test/utils/journey-test-helpers.ts | 2 ++ 3 files changed, 20 insertions(+), 20 deletions(-) create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.sqlite.psl.ts diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts index ec48ae7e7d7a..593436d7936e 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -1,11 +1,7 @@ /** * Renaming a table keeps its rows (SQLite). * - * The SQLite twin of `rename-table-migration.e2e.test.ts`: a file database - * driven through the `@internal/sqlite/config` facade config. Create - * `userProfile` with rows, drop the `@@map` so the model names - * `UserProfile`, and confirm that `migration plan --rename-table` plans - * exactly one rename, applies, keeps the rows, and verifies clean. + * The SQLite twin of `rename-table-migration.e2e.test.ts`: a file database driven through the SQLite facade config. Create `userProfile` with rows, drop the `@@map` so the model names `UserProfile`, and confirm that `migration plan --rename-table` plans exactly one rename, applies, keeps the rows, and verifies clean. */ import { readFileSync, writeFileSync } from 'node:fs'; @@ -24,6 +20,7 @@ import { runDbVerify, runMigrate, runMigrationPlan, + sqlitePslConfigFixture, timeouts, } from '../utils/journey-test-helpers'; @@ -49,25 +46,12 @@ interface PlanDocument { readonly operations: readonly { id: string; label: string; operationClass: string }[]; } -function sqliteConfig(dbPath: string): string { - return `import { defineConfig as ormConfig } from '@internal/sqlite/config'; -import { defineConfig } from '@prisma/cli-engine'; - -export default defineConfig({ - orm: ormConfig({ - contract: './contract.prisma', - db: { connection: ${JSON.stringify(dbPath)} }, - migrations: { dir: 'migrations' }, - }), -}); -`; -} - function setupSqliteJourney(createTempDir: () => string): JourneyContext & { dbPath: string } { const testDir = createTempDir(); const dbPath = join(testDir, 'journey.db'); const configPath = join(testDir, 'prisma.config.ts'); - writeFileSync(configPath, sqliteConfig(dbPath), 'utf-8'); + const config = readFileSync(sqlitePslConfigFixture, 'utf-8').replace('{{DB_PATH}}', () => dbPath); + writeFileSync(configPath, config, 'utf-8'); writeFileSync(join(testDir, 'contract.prisma'), FROM_PSL, 'utf-8'); writeProjectManifest(testDir); return { testDir, configPath, outputDir: testDir, dbPath }; diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.sqlite.psl.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.sqlite.psl.ts new file mode 100644 index 000000000000..6d1b40a292b3 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.sqlite.psl.ts @@ -0,0 +1,14 @@ +import { defineConfig as ormConfig } from '@internal/sqlite/config'; +import { defineConfig } from '@prisma/cli-engine'; + +export default defineConfig({ + orm: ormConfig({ + contract: './contract.prisma', + db: { + connection: '{{DB_PATH}}', + }, + migrations: { + dir: 'migrations', + }, + }), +}); diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index 62d49986c1ee..b2ea7b5e53ff 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -224,6 +224,8 @@ export const pslContractFixtures = { export type PslContractVariant = keyof typeof pslContractFixtures; +export const sqlitePslConfigFixture = join(JOURNEY_FIXTURES_DIR, 'prisma.config.sqlite.psl.ts'); + /** * Swaps the active contract in the test directory to a different variant. * Copies the variant file over `contract.ts` so the config picks it up on next emit. From 8eda7ec12e093424f4ac9a1fe363471051eaafba Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:09:22 +0200 Subject: [PATCH 10/55] fix(mongo): refuse stated renames instead of planning a collection drop The --rename-table flag reaches every family. The Mongo planner ignored it, so a stated rename planned a drop of the old collection, and migration new scaffolded an empty file. Both now refuse with MIGRATION.RENAME_UNSUPPORTED: plan returns a failure conflict, and emptyMigration throws a structured error. migration new now keeps the code and explanation of a structured error the planner throws, instead of turning it into CLI.UNEXPECTED. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 4 + .../control-api/operations/migration-new.ts | 11 ++ .../cli/test/orm/fixtures/offline-project.ts | 6 +- .../cli/test/orm/migration-new.test.ts | 26 +++++ .../1-mongo-target/src/core/mongo-planner.ts | 41 +++++++ .../src/core/mongo-target-errors.ts | 2 +- .../test/mongo-planner.renames.test.ts | 106 ++++++++++++++++++ 7 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 293d15f51ae3..5079557317c4 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1278,6 +1278,10 @@ A ref name resolves to nothing: no pointer file with that name exists, and the f A reference parsed, but as the wrong kind for the argument position — e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. Payload: `input`, `expectedGrammar`. +### MIGRATION.RENAME_UNSUPPORTED + +A stated rename (`--rename-table =`) was given to `migration plan` or `migration new` on a MongoDB project. The MongoDB planner has no rename operation, so it refuses rather than planning a drop of the old collection and a create of the new one. Nothing is planned or written; on `migration plan` it is reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Rename the collection by hand with `renameCollection`, then run the command again without the rename. Payload: none. + ### MIGRATION.RUNNER_FAILED Generic wrapper for a migration runner failure during execution that has no more specific code; the summary/why carry the underlying detail (also used to surface the legacy-marker-shape condition from marker reads, with `meta.runnerErrorCode`). `migrate` and `db init` map unrecognized apply failures through it, passing the failure's own meta through unchanged. Inspect the reported summary/why detail and address the underlying failure before re-running the command. Payload: the wrapped failure's meta, when it has any; `runnerErrorCode` at the legacy-marker-shape site. diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index 9f3f1563743b..068e63b3648f 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -23,6 +23,7 @@ import { findLatestMigration } from '@internal/migration-tools/migration-graph'; import { writeMigrationTs } from '@internal/migration-tools/migration-ts'; import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; +import { isStructuredError } from '@internal/utils/structured-error'; import { join, relative } from 'pathe'; import { CliStructuredError, @@ -254,6 +255,16 @@ export async function executeMigrationNewCommand( if (CliStructuredError.is(error)) { return notOk(error); } + if (isStructuredError(error)) { + return notOk( + new CliStructuredError(error.code, error.message, { + ...ifDefined('why', error.why), + ...ifDefined('fix', error.fix), + ...ifDefined('meta', error.meta), + cause: error, + }), + ); + } return notOk( errorUnexpected(error instanceof Error ? error.message : String(error), { why: `Failed to scaffold migration: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts index 9ef5d8a92ec9..661161e8bed3 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts @@ -144,6 +144,7 @@ export interface FakePlannerScript { readonly operations?: readonly MigrationPlanOperation[]; readonly conflicts?: ReadonlyArray<{ readonly kind: string; readonly summary: string }>; readonly throwOnOperations?: unknown; + readonly throwOnEmptyMigration?: unknown; } function fakePlanner(script: FakePlannerScript): Record { @@ -161,7 +162,10 @@ function fakePlanner(script: FakePlannerScript): Record { }, } : { kind: 'failure', conflicts: script.conflicts }, - emptyMigration: () => ({ operations: [], renderTypeScript: () => '// empty migration\n' }), + emptyMigration: () => { + if (script.throwOnEmptyMigration !== undefined) throw script.throwOnEmptyMigration; + return { operations: [], renderTypeScript: () => '// empty migration\n' }; + }, }; } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts index b6a649415a98..3f6c653e5e90 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { notOk } from '@internal/utils/result'; +import { structuredError } from '@internal/utils/structured-error'; import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -256,6 +257,31 @@ describe('migration new', () => { }); }); + it('keeps the code and explanation of a structured error the planner raises', async () => { + const project = await createOfflineProject({ storageHash: HASH_TO }); + const refusal = structuredError('MIGRATION.RENAME_UNSUPPORTED', 'Renames are not supported.', { + why: 'Rename the collection by hand.', + }); + + const run = await harness(project, { + ...offlineConfig({ project, script: { throwOnEmptyMigration: refusal } }), + }).run(['migration', 'new', '--json'], { cwd: project.dir }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'MIGRATION.RENAME_UNSUPPORTED', + summary: 'Renames are not supported.', + why: 'Rename the collection by hand.', + }, + }, + }); + expect(existsSync(project.appMigrationsDir)).toBe(false); + }); + it('settles every error with typed next actions and no fix prose', async () => { const project = await createOfflineProject({ storageHash: HASH_TO }); await seedMigrationPackage({ diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts index 18d829f666ea..e634ada6dccf 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts @@ -9,6 +9,8 @@ import type { MigrationPlannerResult, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, + StorageEntityRename, + StorageEntityRenameCoordinate, } from '@internal/framework-components/control'; import type { MongoContract } from '@internal/mongo-contract'; import type { @@ -19,6 +21,7 @@ import type { MongoSchemaValidator, } from '@internal/mongo-schema-ir'; import { canonicalize, deepEqual } from '@internal/mongo-schema-ir'; +import { mongoTargetError } from './mongo-target-errors'; import type { OpFactoryCall } from './op-factory-call'; import { CollModCall, @@ -31,6 +34,24 @@ import { } from './op-factory-call'; import { PlannerProducedMongoMigration } from './planner-produced-migration'; +const RENAME_UNSUPPORTED_CODE = 'MIGRATION.RENAME_UNSUPPORTED'; + +function coordinateLabel(coordinate: StorageEntityRenameCoordinate): string { + return coordinate.namespaceId === undefined + ? coordinate.name + : `${coordinate.namespaceId}.${coordinate.name}`; +} + +function renamesLabel(renames: readonly StorageEntityRename[]): string { + return renames + .map((rename) => `"${coordinateLabel(rename.from)}=${coordinateLabel(rename.to)}"`) + .join(', '); +} + +function renamesUnsupportedWhy(retry: 'plan' | 'scaffold'): string { + return `The MongoDB planner has no rename operation, so it cannot keep the documents of a renamed collection. Rename the collection by hand with renameCollection, then ${retry} again without the rename.`; +} + function buildIndexLookupKey(index: MongoSchemaIndex): string { const keys = index.keys.map((k) => `${k.field}:${k.direction}`).join(','); const opts = [ @@ -299,7 +320,20 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> * into the produced plan's `renderTypeScript()` metadata. */ readonly snapshotsImportPath: string; + readonly renames?: readonly StorageEntityRename[]; }): MigrationPlannerResult { + if (options.renames !== undefined && options.renames.length > 0) { + return { + kind: 'failure', + conflicts: [ + { + kind: 'renameUnsupported', + summary: `${RENAME_UNSUPPORTED_CODE}: MongoDB does not support stated renames (${renamesLabel(options.renames)}), so nothing was planned.`, + why: renamesUnsupportedWhy('plan'), + }, + ], + }; + } const contract = options.contract as MongoContract; const result = this.planCalls(options); if (result.kind === 'failure') return result; @@ -326,6 +360,13 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> * not import from the generated contract `.d.ts`. */ emptyMigration(context: MigrationScaffoldContext): MigrationPlanWithAuthoringSurface { + if (context.renames !== undefined && context.renames.length > 0) { + throw mongoTargetError( + RENAME_UNSUPPORTED_CODE, + `MongoDB does not support stated renames (${renamesLabel(context.renames)}), so nothing was scaffolded.`, + { why: renamesUnsupportedWhy('scaffold') }, + ); + } return new PlannerProducedMongoMigration( [], { diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-errors.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-errors.ts index ffea3afd74ae..d9ec68d00a89 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-errors.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-errors.ts @@ -3,7 +3,7 @@ import { structuredError } from '@internal/utils/structured-error'; export type MongoTargetErrorCode = `MIGRATION.${MigrationSubcode}`; -type MigrationSubcode = 'INVALID_OPERATION_ENTRY' | 'OPERATION_UNSUPPORTED'; +type MigrationSubcode = 'INVALID_OPERATION_ENTRY' | 'OPERATION_UNSUPPORTED' | 'RENAME_UNSUPPORTED'; export function mongoTargetError( code: MongoTargetErrorCode, diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts new file mode 100644 index 000000000000..59b70eb8ecca --- /dev/null +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts @@ -0,0 +1,106 @@ +import { coreHash } from '@internal/contract/types'; +import type { StorageEntityRename } from '@internal/framework-components/control'; +import { + buildMongoNamespace, + MongoCollection, + type MongoContract, + MongoStorage, +} from '@internal/mongo-contract'; +import { MongoSchemaCollection, MongoSchemaIR } from '@internal/mongo-schema-ir'; +import { describe, expect, it } from 'vitest'; +import { MongoMigrationPlanner } from '../src/core/mongo-planner'; + +const RENAMES: readonly StorageEntityRename[] = [ + { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, +]; + +function contractWith(collectionName: string): MongoContract { + return { + target: 'mongo', + targetFamily: 'mongo', + profileHash: 'test-profile', + capabilities: {}, + extensions: {}, + meta: {}, + roots: {}, + models: {}, + storage: new MongoStorage({ + storageHash: coreHash('to-storage'), + namespaces: { + __unbound__: buildMongoNamespace({ + id: '__unbound__', + entries: { collection: { [collectionName]: new MongoCollection({}) } }, + }), + }, + }), + } as unknown as MongoContract; +} + +describe('MongoMigrationPlanner stated renames', () => { + const planner = new MongoMigrationPlanner(); + + it('refuses to plan when renames are given, instead of planning a collection drop', () => { + const result = planner.plan({ + contract: contractWith('UserProfile'), + schema: new MongoSchemaIR([new MongoSchemaCollection({ name: 'userProfile', indexes: [] })]), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: contractWith('userProfile'), + frameworkComponents: [], + snapshotsImportPath: '../../snapshots', + renames: RENAMES, + }); + + expect(result).toEqual({ + kind: 'failure', + conflicts: [ + { + kind: 'renameUnsupported', + summary: + 'MIGRATION.RENAME_UNSUPPORTED: MongoDB does not support stated renames ("userProfile=UserProfile"), so nothing was planned.', + why: 'The MongoDB planner has no rename operation, so it cannot keep the documents of a renamed collection. Rename the collection by hand with renameCollection, then plan again without the rename.', + }, + ], + }); + }); + + it('refuses to scaffold when renames are given', () => { + expect(() => + planner.emptyMigration({ + packageDir: '/tmp/migration-pkg', + fromHash: '00', + toHash: '01', + snapshotsImportPath: '../../snapshots', + renames: RENAMES, + }), + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.RENAME_UNSUPPORTED', + message: + 'MongoDB does not support stated renames ("userProfile=UserProfile"), so nothing was scaffolded.', + why: 'The MongoDB planner has no rename operation, so it cannot keep the documents of a renamed collection. Rename the collection by hand with renameCollection, then scaffold again without the rename.', + }), + ); + }); + + it('plans and scaffolds as usual when the renames list is empty', () => { + const result = planner.plan({ + contract: contractWith('users'), + schema: new MongoSchemaIR([new MongoSchemaCollection({ name: 'users', indexes: [] })]), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: null, + frameworkComponents: [], + snapshotsImportPath: '../../snapshots', + renames: [], + }); + const scaffold = planner.emptyMigration({ + packageDir: '/tmp/migration-pkg', + fromHash: '00', + toHash: '01', + snapshotsImportPath: '../../snapshots', + renames: [], + }); + + expect(result.kind).toBe('success'); + expect(scaffold.operations).toEqual([]); + }); +}); From 8bc8b671ea72faf7bff16f121b32fa6eeaf73c53 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:10:08 +0200 Subject: [PATCH 11/55] docs(migration): state what renames do to the planner schema input The MigrationPlanner doc block had been separated from its interface by the two rename types inserted above it, so the interface lost its documentation. The rename types now sit above the doc block. The renames option said renames are applied to the previous state, but planners rebuild the previous schema from fromContract and ignore the schema argument. The framework and SQL option docs now say so, and say that renames are only valid where schema is derived from fromContract. The emptyMigration doc says a planner that cannot rename throws. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-migration-types.ts | 23 ++++++++++--------- .../9-family/src/core/migrations/types.ts | 2 ++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index 1d8648725fc1..c69bc14222c0 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -420,13 +420,6 @@ export interface SchemaOwnership { declaresEntity(coordinate: SchemaEntityCoordinate): boolean; } -/** - * Migration planner interface for planning schema changes. - * This is the minimal interface that CLI commands use. - * - * @template TFamilyId - The family ID (e.g., 'sql', 'document') - * @template TTargetId - The target ID (e.g., 'postgres', 'mysql') - */ /** * One side of a {@link StorageEntityRename}: a storage entity named within a * namespace. `namespaceId` is omitted when the operator did not qualify the @@ -449,6 +442,13 @@ export interface StorageEntityRename { readonly to: StorageEntityRenameCoordinate; } +/** + * Migration planner interface for planning schema changes. + * This is the minimal interface that CLI commands use. + * + * @template TFamilyId - The family ID (e.g., 'sql', 'document') + * @template TTargetId - The target ID (e.g., 'postgres', 'mysql') + */ export interface MigrationPlanner< TFamilyId extends string = string, TTargetId extends string = string, @@ -511,10 +511,9 @@ export interface MigrationPlanner< */ readonly snapshotsImportPath: string; /** - * Operator-stated renames to apply to the previous state before the - * diff, in the order given. Each becomes one rename operation at the - * head of the plan; one that matches neither side is a planning - * failure. Absent or empty means no renames. + * Operator-stated renames, in the order given. Each becomes one rename operation at the head of the plan; one that matches neither side is a planning failure. Absent or empty means no renames. + * + * When renames are present, the planner applies them to `fromContract`, re-derives the previous schema from that renamed contract, and ignores `schema`. Pass renames only where `schema` is itself derived from `fromContract` (offline `migration plan`), never with a live introspected schema, whose drift and unowned objects would be lost. A planner that cannot rename returns a failure result. */ readonly renames?: readonly StorageEntityRename[]; }): MigrationPlannerResult; @@ -530,6 +529,8 @@ export interface MigrationPlanner< * `spaceId` is stamped onto the produced plan; reconciliation flows * (`db init`, `db update`) and authoring flows (`migration new`) all * pass it explicitly. + * + * A planner that cannot rename throws a structured error when `context.renames` is not empty. */ emptyMigration( context: MigrationScaffoldContext, diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index ed3c022b2a77..b32953ba55ed 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -379,6 +379,8 @@ export interface SqlMigrationPlannerPlanOptions { * Operator-stated table renames, applied to `fromContract` before the diff * so the differ sees each table under its new name; one `renameTable` op * per intent is prepended to the plan. See {@link StorageEntityRename}. + * + * When present, the previous schema is re-derived from the renamed `fromContract` and `schema` is ignored, so pass renames only where `schema` is derived from `fromContract`. */ readonly renames?: readonly StorageEntityRename[]; } From c370ee48f48ff63e8552c9bb87de6b7fb5a34acb Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:10:38 +0200 Subject: [PATCH 12/55] fix(cli): name the table in duplicate --rename-table errors The two duplicate-name errors interpolated nothing, so they printed "" is renamed more than once. They now name the table, qualified when the value was qualified. The tests assert the whole summary instead of a substring the echoed value already contained. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/utils/rename-table-flag.ts | 10 ++++++-- .../cli/test/utils/rename-table-flag.test.ts | 24 +++++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts b/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts index 34c7756b870c..bdf867d14a44 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts @@ -38,6 +38,12 @@ function coordinateKey(coordinate: StorageEntityRenameCoordinate): string { return `${coordinate.namespaceId ?? ''}.${coordinate.name}`; } +function coordinateLabel(coordinate: StorageEntityRenameCoordinate): string { + return coordinate.namespaceId === undefined + ? coordinate.name + : `${coordinate.namespaceId}.${coordinate.name}`; +} + /** * Parses every `--rename-table =` value the operator gave, in the * order given. A malformed value, a value whose two sides are the same @@ -70,10 +76,10 @@ export function parseRenameTableFlags( const toKey = coordinateKey(to.value); if (fromKey === toKey) return notOk(invalid(value, 'the old and new names are the same')); if (seenFrom.has(fromKey)) { - return notOk(invalid(value, `"" is renamed more than once`)); + return notOk(invalid(value, `"${coordinateLabel(from.value)}" is renamed more than once`)); } if (seenTo.has(toKey)) { - return notOk(invalid(value, `more than one value renames to ""`)); + return notOk(invalid(value, `more than one value renames to "${coordinateLabel(to.value)}"`)); } seenFrom.add(fromKey); seenTo.add(toKey); diff --git a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts b/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts index cb89cd5d63f5..206a78467066 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts @@ -65,17 +65,21 @@ describe('parseRenameTableFlags', () => { }); }); - it('rejects two values that rename the same table', () => { - expect(failureOf(['userProfile=UserProfile', 'userProfile=Profile'])).toMatchObject({ - code: 'CLI.INVALID_RENAME_TABLE_FLAG', - summary: expect.stringContaining('userProfile'), - }); + it('rejects two values that rename the same table, naming the table', () => { + expect(failureOf(['userProfile=UserProfile', 'userProfile=Profile'])?.summary).toBe( + 'Invalid --rename-table value "userProfile=Profile": "userProfile" is renamed more than once.', + ); }); - it('rejects two values that rename to the same table', () => { - expect(failureOf(['userProfile=UserProfile', 'profile=UserProfile'])).toMatchObject({ - code: 'CLI.INVALID_RENAME_TABLE_FLAG', - summary: expect.stringContaining('UserProfile'), - }); + it('names the qualified table when two qualified values rename it', () => { + expect(failureOf(['auth.userProfile=UserProfile', 'auth.userProfile=Profile'])?.summary).toBe( + 'Invalid --rename-table value "auth.userProfile=Profile": "auth.userProfile" is renamed more than once.', + ); + }); + + it('rejects two values that rename to the same table, naming the table', () => { + expect(failureOf(['userProfile=UserProfile', 'profile=UserProfile'])?.summary).toBe( + 'Invalid --rename-table value "profile=UserProfile": more than one value renames to "UserProfile".', + ); }); }); From db756b1e9e8de850d6cb187a1ea88a340b2cbe51 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:13:14 +0200 Subject: [PATCH 13/55] fix(migration): read --rename-table the same way on migration plan and migration new migration plan refuses a rename between namespaces and, on SQLite, any namespace qualifier. migration new ignored both. Now: - The flag parser refuses a value whose two sides name different namespaces, so both commands reject it in one place. - The SQLite scaffold refuses a qualified intent with MIGRATION.TABLE_RENAME_UNMATCHED. - The Postgres scaffold uses the qualifier from either side, so userProfile=auth.UserProfile renames in auth instead of in the unbound schema. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 4 +- .../cli/src/utils/rename-table-flag.ts | 17 +++++- .../cli/test/utils/rename-table-flag.test.ts | 15 +++++ .../core/migrations/table-rename-intents.ts | 4 +- .../2-sql/9-family/src/exports/control.ts | 1 + .../postgres/src/core/migrations/planner.ts | 9 +-- .../migrations/rename-table-scaffold.test.ts | 54 +++++++++++++++++ .../3-targets/sqlite/src/core/errors.ts | 1 + .../sqlite/src/core/migrations/planner.ts | 20 ++++++- .../migrations/rename-table-scaffold.test.ts | 58 +++++++++++++++++++ 10 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 5079557317c4..d76ab44bc901 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -169,7 +169,7 @@ The main CLI received a `--format` value other than `pretty` or `json`. Raised d ### CLI.INVALID_RENAME_TABLE_FLAG -A `--rename-table` value given to `migration plan` or `migration new` is not `=` (each side optionally qualified as `.`), names the same entity on both sides, or repeats an old or a new name already used by another value on the same command. Nothing is planned or written. Payload: `value`. +A `--rename-table` value given to `migration plan` or `migration new` is not `=` (each side optionally qualified as `.`), names the same entity on both sides, qualifies its two sides with different namespaces (a rename cannot move a table between namespaces), or repeats an old or a new name already used by another value on the same command. Nothing is planned or written. Payload: `value`. ### CLI.INVALID_VERIFY_MODE @@ -1312,7 +1312,7 @@ The planner would drop table `X` and create table `Y` in the same namespace, whe ### MIGRATION.TABLE_RENAME_UNMATCHED -A `--rename-table =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, or the two sides name different namespaces. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. +A `--rename-table =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, or the two sides name different namespaces. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. `migration new` on SQLite raises it directly when either side carries a namespace qualifier, since SQLite has no namespaces; nothing is written. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. ### MIGRATION.TARGET_MISMATCH diff --git a/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts b/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts index bdf867d14a44..b3a60a600092 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts @@ -47,8 +47,9 @@ function coordinateLabel(coordinate: StorageEntityRenameCoordinate): string { /** * Parses every `--rename-table =` value the operator gave, in the * order given. A malformed value, a value whose two sides are the same - * entity, and two values that share an old or a new name are each refused - * with `CLI.INVALID_RENAME_TABLE_FLAG`. + * entity, a value whose sides name different namespaces, and two values that + * share an old or a new name are each refused with + * `CLI.INVALID_RENAME_TABLE_FLAG`. */ export function parseRenameTableFlags( values: readonly string[] | undefined, @@ -72,6 +73,18 @@ export function parseRenameTableFlags( if (!from.ok) return from; const to = parseCoordinate(value, right); if (!to.ok) return to; + if ( + from.value.namespaceId !== undefined && + to.value.namespaceId !== undefined && + from.value.namespaceId !== to.value.namespaceId + ) { + return notOk( + invalid( + value, + `a rename cannot move a table from namespace "${from.value.namespaceId}" to namespace "${to.value.namespaceId}"`, + ), + ); + } const fromKey = coordinateKey(from.value); const toKey = coordinateKey(to.value); if (fromKey === toKey) return notOk(invalid(value, 'the old and new names are the same')); diff --git a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts b/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts index 206a78467066..2439b10a777e 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts @@ -58,6 +58,21 @@ describe('parseRenameTableFlags', () => { expect(envelope?.fix).toContain('='); }); + it('accepts both sides qualified with the same namespace', () => { + expect(parsedValue(['auth.userProfile=auth.UserProfile'])).toEqual([ + { + from: { namespaceId: 'auth', name: 'userProfile' }, + to: { namespaceId: 'auth', name: 'UserProfile' }, + }, + ]); + }); + + it('rejects a value that moves a table to another namespace', () => { + expect(failureOf(['auth.userProfile=sales.UserProfile'])?.summary).toBe( + 'Invalid --rename-table value "auth.userProfile=sales.UserProfile": a rename cannot move a table from namespace "auth" to namespace "sales".', + ); + }); + it('rejects a value whose two sides are the same table', () => { expect(failureOf(['userProfile=userProfile'])).toMatchObject({ code: 'CLI.INVALID_RENAME_TABLE_FLAG', diff --git a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts index cf4222e35800..124c60bd40e1 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts @@ -50,7 +50,7 @@ function coordinateLabel(coordinate: StorageEntityRename['from']): string { : `${coordinate.namespaceId}.${coordinate.name}`; } -function intentLabel(intent: TableRenameIntent): string { +export function tableRenameIntentLabel(intent: TableRenameIntent): string { return `${coordinateLabel(intent.from)}=${coordinateLabel(intent.to)}`; } @@ -79,7 +79,7 @@ function lookupTable( function unmatched(intent: TableRenameIntent, reason: string): SqlPlannerConflict { return { kind: 'tableRenameUnmatched', - summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename-table "${intentLabel(intent)}" does not match the contracts: ${reason}.`, + summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename-table "${tableRenameIntentLabel(intent)}" does not match the contracts: ${reason}.`, why: 'A rename intent must name a table of the previous contract as its old name and a table of the next contract as its new name, and the new name must not already exist in the previous contract. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed.', meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: intent.from.name, to: intent.to.name }, }; diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index c600f33f8203..c7bf83e89809 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -68,6 +68,7 @@ export type { export { applyTableRenameIntents, TABLE_RENAME_UNMATCHED_CODE, + tableRenameIntentLabel, } from '../core/migrations/table-rename-intents'; export type { CodecControlHooks, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 4fe96a38bd0d..068c63063726 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -175,13 +175,14 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr context: MigrationScaffoldContext, spaceId: string, ): MigrationPlanWithAuthoringSurface { - // A scaffold has no contract to resolve a namespace against, so a - // qualified intent names the DDL schema directly and an unqualified one - // stays unbound, like every other unqualified authored statement. + // A scaffold has no contract to resolve a namespace against, so the + // qualifier on either side names the DDL schema directly, and an intent + // with no qualifier stays unbound, like every other unqualified authored + // statement. The flag parser has already refused two different qualifiers. const renameCalls = (context.renames ?? []).map( (rename) => new RenameTableCall( - rename.from.namespaceId ?? UNBOUND_NAMESPACE_ID, + rename.from.namespaceId ?? rename.to.namespaceId ?? UNBOUND_NAMESPACE_ID, rename.from.name, rename.to.name, ), diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts new file mode 100644 index 000000000000..7c3d20e0df5c --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts @@ -0,0 +1,54 @@ +/** + * `migration new --rename-table` on Postgres: the scaffold carries one rename call per intent, in the schema either side names. + */ + +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import type { StorageEntityRename } from '@internal/framework-components/control'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { keepInternalSpecifiers } from '@internal/framework-components/emission'; +import { describe, expect, it } from 'vitest'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; + +const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +function scaffoldSource(renames: readonly StorageEntityRename[]): string { + return createPostgresMigrationPlanner(stubLowerer) + .emptyMigration( + { + packageDir: '/tmp/migration-pkg', + fromHash: 'a'.repeat(64), + toHash: 'b'.repeat(64), + snapshotsImportPath: '../../snapshots', + renames, + }, + APP_SPACE_ID, + ) + .renderTypeScript(keepInternalSpecifiers); +} + +describe('Postgres scaffold rename-table intents', () => { + it('leaves an unqualified intent unbound', () => { + expect( + scaffoldSource([{ from: { name: 'userProfile' }, to: { name: 'UserProfile' } }]), + ).toContain('this.renameTable({ table: "userProfile", to: "UserProfile" })'); + }); + + it('renames in the schema the old name is qualified with', () => { + expect( + scaffoldSource([ + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, + ]), + ).toContain('this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })'); + }); + + it('renames in the schema the new name is qualified with when the old name is not', () => { + expect( + scaffoldSource([ + { from: { name: 'userProfile' }, to: { namespaceId: 'auth', name: 'UserProfile' } }, + ]), + ).toContain('this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })'); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/src/core/errors.ts b/packages/3-targets/3-targets/sqlite/src/core/errors.ts index 4da420e9c7db..0b046da61133 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/errors.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/errors.ts @@ -13,6 +13,7 @@ export type SqliteTargetErrorCode = | 'CONTRACT.TYPE_UNKNOWN' | 'MIGRATION.CONTRACT_SPACE_VIOLATION' | 'MIGRATION.SQLITE_CONTROL_STACK_MISSING' + | 'MIGRATION.TABLE_RENAME_UNMATCHED' | 'MIGRATION.TARGET_MISMATCH' | 'RUNTIME.DECODE_FAILED' | 'RUNTIME.ENCODE_FAILED'; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 4352c03eb79b..7cd39f387b4d 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -11,6 +11,7 @@ import { extractCodecControlHooks, planFieldEventOperations, plannerFailure, + tableRenameIntentLabel, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; @@ -28,6 +29,7 @@ import { type SqlSchemaIR, SqlTableIR, } from '@internal/sql-schema-ir/types'; +import { sqliteError } from '../errors'; import { buildSqlitePlanDiff, sqliteContractToSchema } from './diff-database-schema'; import { coalesceSubtreeIssues, @@ -121,9 +123,21 @@ export class SqliteMigrationPlanner context: MigrationScaffoldContext, spaceId: string, ): TypeScriptRenderableSqliteMigration { - const renameCalls = (context.renames ?? []).map( - (rename) => new RenameTableCall(rename.from.name, rename.to.name), - ); + const renameCalls = (context.renames ?? []).map((rename) => { + const namespaceId = rename.from.namespaceId ?? rename.to.namespaceId; + if (namespaceId !== undefined) { + throw sqliteError( + 'MIGRATION.TABLE_RENAME_UNMATCHED', + `--rename-table "${tableRenameIntentLabel(rename)}" names a namespace, "${namespaceId}", and SQLite has no namespaces.`, + { + why: 'A SQLite database has one namespace, so a rename intent names its tables without a qualifier.', + fix: 'Drop the namespace qualifier from the --rename-table value.', + meta: { from: rename.from.name, to: rename.to.name }, + }, + ); + } + return new RenameTableCall(rename.from.name, rename.to.name); + }); return new TypeScriptRenderableSqliteMigration( renameCalls, { diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts new file mode 100644 index 000000000000..00222a9b08b6 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts @@ -0,0 +1,58 @@ +/** + * `migration new --rename-table` on SQLite: the scaffold carries one rename call per intent and refuses a namespace qualifier, as the planner does. + */ + +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import type { StorageEntityRename } from '@internal/framework-components/control'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { keepInternalSpecifiers } from '@internal/framework-components/emission'; +import { describe, expect, it } from 'vitest'; +import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; + +const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +function scaffold(renames: readonly StorageEntityRename[]) { + return createSqliteMigrationPlanner(stubLowerer).emptyMigration( + { + packageDir: '/tmp/migration-pkg', + fromHash: 'a'.repeat(64), + toHash: 'b'.repeat(64), + snapshotsImportPath: '../../snapshots', + renames, + }, + APP_SPACE_ID, + ); +} + +describe('SQLite scaffold rename-table intents', () => { + it('carries one rename call per unqualified intent', () => { + const source = scaffold([ + { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, + ]).renderTypeScript(keepInternalSpecifiers); + + expect(source).toContain('this.renameTable({ table: "userProfile", to: "UserProfile" })'); + }); + + it.each([ + [ + 'the old name', + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, + '"auth.userProfile=UserProfile" names a namespace, "auth"', + ], + [ + 'the new name', + { from: { name: 'userProfile' }, to: { namespaceId: 'auth', name: 'UserProfile' } }, + '"userProfile=auth.UserProfile" names a namespace, "auth"', + ], + ])('refuses a namespace qualifier on %s, which SQLite does not have', (_side, intent, detail) => { + expect(() => scaffold([intent])).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: `--rename-table ${detail}, and SQLite has no namespaces.`, + }), + ); + }); +}); From ac02139dbb1e22e1128f22806a43002b470c5a6d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:14:53 +0200 Subject: [PATCH 14/55] fix(sql): refuse two rename intents for one table, and code the no-history case Two intents that resolved to the same old table (userProfile=A and auth.userProfile=B) or the same new table both passed. The helper applied only the first, but the planner emitted both rename calls, so the second failed its precheck at migrate time. The second intent is now a MIGRATION.TABLE_RENAME_UNMATCHED conflict at plan time. An intent given with no previous contract was an unsupportedOperation conflict with no code. It now carries MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT, names the intent, and tells a db update user to rename by hand. Both are in the error reference. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 6 +- .../core/migrations/table-rename-intents.ts | 59 +++++++++++++++---- .../test/table-rename-intents.test.ts | 56 +++++++++++++++++- 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index d76ab44bc901..27c82006bf81 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1310,9 +1310,13 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or state the rename when planning with `prisma migration plan --rename-table "X=Y"`, which plans one `renameTable` operation in place of the drop and the create. Payload: `droppedTable`, `createdTable`. +### MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT + +A `--rename-table =` intent was given to `migration plan`, but the plan has no previous contract to rename the table in: there is no migration history, so the plan starts from an empty database. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`, one per intent. Plan from the migration that created the table. A database managed with `db update` has no migration history, so rename the table there by hand with `ALTER TABLE "X" RENAME TO "Y"` and drop the flag. Payload: none. + ### MIGRATION.TABLE_RENAME_UNMATCHED -A `--rename-table =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, or the two sides name different namespaces. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. `migration new` on SQLite raises it directly when either side carries a namespace qualifier, since SQLite has no namespaces; nothing is written. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. +A `--rename-table =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, the two sides name different namespaces, or an earlier intent on the same command already resolved to the same old table or the same new table. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. `migration new` on SQLite raises it directly when either side carries a namespace qualifier, since SQLite has no namespaces; nothing is written. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. ### MIGRATION.TARGET_MISMATCH diff --git a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts index 124c60bd40e1..62a4cfeb0b15 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts @@ -1,5 +1,6 @@ import type { Contract } from '@internal/contract/types'; import type { StorageEntityRename } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { ForeignKey, type ForeignKeyReference, @@ -16,6 +17,7 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import type { SqlPlannerConflict } from './types'; export const TABLE_RENAME_UNMATCHED_CODE = 'MIGRATION.TABLE_RENAME_UNMATCHED'; +export const TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE = 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT'; /** A `--rename-table =` intent as the CLI parsed it. */ export type TableRenameIntent = StorageEntityRename; @@ -76,6 +78,14 @@ function lookupTable( return namespaceIds.length === 0 ? { kind: 'missing' } : { kind: 'ambiguous', namespaceIds }; } +function qualifiedTableKey(namespaceId: string, tableName: string): string { + return JSON.stringify([namespaceId, tableName]); +} + +function resolvedTableLabel(namespaceId: string, tableName: string): string { + return namespaceId === UNBOUND_NAMESPACE_ID ? tableName : `${namespaceId}.${tableName}`; +} + function unmatched(intent: TableRenameIntent, reason: string): SqlPlannerConflict { return { kind: 'tableRenameUnmatched', @@ -247,7 +257,8 @@ function renameTablesInContract( * ordinary diff sees each renamed table under its new name. Every intent is * matched first: its old name must exist in the previous contract (in * exactly one namespace when unqualified), its new name must not, and its - * new name must exist in the next contract. One conflict per unmatched + * new name must exist in the next contract. Two intents may not resolve to + * the same old table or the same new table. One conflict per unmatched * intent; nothing is applied unless every intent matches. * * Foreign keys that name a renamed table on either side are retargeted so @@ -265,22 +276,50 @@ export function applyTableRenameIntents( return ok({ contract: input.fromContract, renames: [] }); } if (input.fromContract === null) { - return notOk([ - { + return notOk( + input.intents.map((intent) => ({ kind: 'unsupportedOperation', - summary: - '--rename-table needs a previous contract to apply the rename to, and this plan starts from an empty database.', - why: 'A rename intent only makes sense between two contracts. Plan from the migration that created the table, or drop the flag.', - }, - ]); + summary: `${TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE}: --rename-table "${tableRenameIntentLabel(intent)}" needs a previous contract to apply the rename to, and this plan starts from an empty database.`, + why: 'A rename intent only makes sense between two contracts. Plan from the migration that created the table. A database managed with db update has no migration history: rename the table there by hand with ALTER TABLE ... RENAME TO ..., and drop the flag.', + meta: { code: TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE }, + })), + ); } const fromContract = input.fromContract; const conflicts: SqlPlannerConflict[] = []; const renames: ResolvedTableRename[] = []; + const oldNamesTaken = new Map(); + const newNamesTaken = new Map(); for (const intent of input.intents) { const resolved = resolveIntent(intent, fromContract, input.toContract); - if (resolved.ok) renames.push(resolved.value); - else conflicts.push(resolved.failure); + if (!resolved.ok) { + conflicts.push(resolved.failure); + continue; + } + const { namespaceId, from, to } = resolved.value; + const oldName = qualifiedTableKey(namespaceId, from); + const newName = qualifiedTableKey(namespaceId, to); + const earlierOld = oldNamesTaken.get(oldName); + const earlierNew = newNamesTaken.get(newName); + if (earlierOld !== undefined) { + conflicts.push( + unmatched( + intent, + `table "${resolvedTableLabel(namespaceId, from)}" is already renamed by --rename-table "${tableRenameIntentLabel(earlierOld)}"`, + ), + ); + } else if (earlierNew !== undefined) { + conflicts.push( + unmatched( + intent, + `table "${resolvedTableLabel(namespaceId, to)}" is already the new name in --rename-table "${tableRenameIntentLabel(earlierNew)}"`, + ), + ); + } else { + oldNamesTaken.set(oldName, intent); + newNamesTaken.set(newName, intent); + renames.push(resolved.value); + } } if (conflicts.length > 0) return notOk(conflicts); return ok({ contract: renameTablesInContract(fromContract, renames), renames }); diff --git a/packages/2-sql/9-family/test/table-rename-intents.test.ts b/packages/2-sql/9-family/test/table-rename-intents.test.ts index b9f134a84202..3238032b591c 100644 --- a/packages/2-sql/9-family/test/table-rename-intents.test.ts +++ b/packages/2-sql/9-family/test/table-rename-intents.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; import { applyTableRenameIntents, + TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE, TABLE_RENAME_UNMATCHED_CODE, } from '../src/core/migrations/table-rename-intents'; @@ -314,7 +315,7 @@ describe('applyTableRenameIntents', () => { expect(result.failure[0]?.summary).toContain('more than one namespace'); }); - it('rejects intents without a prior contract to apply them to', () => { + it('rejects intents without a prior contract to apply them to, with its own code', () => { const result = applyTableRenameIntents({ fromContract: null, toContract, @@ -323,7 +324,58 @@ describe('applyTableRenameIntents', () => { expect(result.ok).toBe(false); if (result.ok) return; - expect(result.failure.map((conflict) => conflict.kind)).toEqual(['unsupportedOperation']); + expect(result.failure).toEqual([ + { + kind: 'unsupportedOperation', + summary: `${TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE}: --rename-table "userProfile=UserProfile" needs a previous contract to apply the rename to, and this plan starts from an empty database.`, + why: 'A rename intent only makes sense between two contracts. Plan from the migration that created the table. A database managed with db update has no migration history: rename the table there by hand with ALTER TABLE ... RENAME TO ..., and drop the flag.', + meta: { code: TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE }, + }, + ]); + }); + + describe('two intents that resolve to the same table', () => { + const authFrom = contractOf({ auth: { userProfile: table(), account: table() } }); + const authTo = contractOf({ auth: { Profile: table(), Member: table(), account: table() } }); + + function conflictsBetween(intents: readonly StorageEntityRename[]) { + const result = applyTableRenameIntents({ + fromContract: authFrom, + toContract: authTo, + intents, + }); + expect(result.ok).toBe(false); + return result.ok ? [] : result.failure; + } + + it('reports the second intent that renames the same old table', () => { + expect( + conflictsBetween([ + rename('userProfile', 'Profile'), + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'Member' } }, + ]), + ).toEqual([ + expect.objectContaining({ + kind: 'tableRenameUnmatched', + summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename-table "auth.userProfile=Member" does not match the contracts: table "auth.userProfile" is already renamed by --rename-table "userProfile=Profile".`, + meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: 'userProfile', to: 'Member' }, + }), + ]); + }); + + it('reports the second intent that renames to the same new table', () => { + expect( + conflictsBetween([ + rename('userProfile', 'Profile'), + { from: { name: 'account' }, to: { namespaceId: 'auth', name: 'Profile' } }, + ]), + ).toEqual([ + expect.objectContaining({ + kind: 'tableRenameUnmatched', + summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename-table "account=auth.Profile" does not match the contracts: table "auth.Profile" is already the new name in --rename-table "userProfile=Profile".`, + }), + ]); + }); }); }); }); From 24f3b91d09b651ec51984be384a3b41b5a8a621c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:15:36 +0200 Subject: [PATCH 15/55] fix(sql): keep the by-hand rename remedy in the table-name case guard The guard fires on db update as well as migration plan. Its second remedy pointed only at migration plan --rename-table, which a project without migration history cannot use. The guard now gives both: the flag for projects with migration history, and ALTER TABLE ... RENAME TO ... for projects that use db update. The guard tests and the error reference pin both. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 2 +- .../9-family/src/core/migrations/table-name-case-guard.ts | 2 +- .../test/migrations/planner.table-name-case-guard.test.ts | 6 ++++-- .../test/migrations/planner.table-name-case-guard.test.ts | 6 ++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 27c82006bf81..22644e95ad88 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1308,7 +1308,7 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.TABLE_NAME_CASE_CHANGED -The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or state the rename when planning with `prisma migration plan --rename-table "X=Y"`, which plans one `renameTable` operation in place of the drop and the create. Payload: `droppedTable`, `createdTable`. +The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename-table "X=Y"`, which plans one `renameTable` operation in place of the drop and the create. In a project that uses `db update`, which has no migration history, rename it by hand with `ALTER TABLE "X" RENAME TO "Y"`, after which the plan is empty. Payload: `droppedTable`, `createdTable`. ### MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index 62b586425326..30980f6eb6f0 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -61,7 +61,7 @@ export function detectTableNameCaseChanges(input: { conflicts.push({ kind: 'tableNameCaseChanged', summary: `${TABLE_NAME_CASE_CHANGED_CODE}: table "${create.tableName}" would be created and table "${drop.tableName}" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model ${create.tableName} points at "${create.tableName}" instead of "${drop.tableName}".`, - why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead, state the rename when planning: prisma migration plan --rename-table "${drop.tableName}=${create.tableName}" (either side may be .); the plan then carries one renameTable operation in place of the drop and the create.`, + why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename-table "${drop.tableName}=${create.tableName}" (either side may be .), and the plan carries one renameTable operation in place of the drop and the create; in a project that uses db update, rename it by hand: ALTER TABLE "${drop.tableName}" RENAME TO "${create.tableName}" (schema-qualified where applicable), after which the plan is empty.`, location: { namespaceId: create.namespaceId, entityKind: 'table', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index 769b0bc6b820..b46f3f43c186 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -168,9 +168,11 @@ describe('Postgres planner table-name case guard', () => { ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); expect(result.conflicts[0]?.why).toContain( - 'migration plan --rename-table "userProfile=UserProfile"', + 'in a project with migration history, state the rename when planning: prisma migration plan --rename-table "userProfile=UserProfile"', + ); + expect(result.conflicts[0]?.why).toContain( + 'in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile"', ); - expect(result.conflicts[0]?.why).not.toContain('ALTER TABLE'); }); it('still refuses when UserProfile also gained a column', () => { diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts index 1e7d7f88382a..082a144b7e39 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts @@ -138,9 +138,11 @@ describe('SQLite planner table-name case guard', () => { ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); expect(result.conflicts[0]?.why).toContain( - 'migration plan --rename-table "userProfile=UserProfile"', + 'in a project with migration history, state the rename when planning: prisma migration plan --rename-table "userProfile=UserProfile"', + ); + expect(result.conflicts[0]?.why).toContain( + 'in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile"', ); - expect(result.conflicts[0]?.why).not.toContain('ALTER TABLE'); }); it('still refuses when UserProfile also gained a column', () => { From b35a9c762737f424d211bce9fc99ef62ab14c7eb Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:18:34 +0200 Subject: [PATCH 16/55] fix(migration): fail a table rename when the old and new tables both exist The runner checks an operation postcheck first and skips the operation if it already holds. The rename postcheck was only "the new table exists", so when both tables existed the rename was recorded as applied, the rows stayed under the old name, and nothing failed. Both targets now also postcheck that the old table is gone, so the runner falls through to the precheck and fails with MIGRATION.PRECHECK_FAILED naming the new table. Runner tests on PGlite and SQLite pin this. On SQLite, a rename that only changes case passes through _prisma_rename_. A precheck now refuses when a table with that name exists, and says why the name is needed, instead of letting SQLite fail with its own message. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/operations/tables.ts | 16 ++-- .../test/migrations/rename-table-ops.test.ts | 8 +- .../src/core/migrations/op-factory-call.ts | 44 ++++++++-- .../test/migrations/rename-table-ops.test.ts | 33 +++++++ .../runner.rename-table.integration.test.ts | 85 +++++++++++++++++++ .../migrations/runner.rename-table.test.ts | 66 ++++++++++++++ 6 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts create mode 100644 packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts index 95335a591765..c83102347ea3 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts @@ -31,19 +31,19 @@ export async function renameTable( lowerer: ExecuteRequestLowerer, ): Promise { const qualified = qualifyTableName(schemaName, fromName); - const from = await lowerer.lowerToExecuteRequest( - tableExistsAst(schemaName, fromName).tablePresent(), - ); + const fromChecks = tableExistsAst(schemaName, fromName); const toChecks = tableExistsAst(schemaName, toName); + const fromPresent = await lowerer.lowerToExecuteRequest(fromChecks.tablePresent()); const toAbsent = await lowerer.lowerToExecuteRequest(toChecks.tableAbsent()); const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); + const fromAbsent = await lowerer.lowerToExecuteRequest(fromChecks.tableAbsent()); return { id: `renameTable.${fromName}`, label: `Rename table "${fromName}" to "${toName}"`, operationClass: 'widening', target: targetDetails('table', toName, schemaName), precheck: [ - step(`ensure table "${fromName}" exists`, from.sql, from.params), + step(`ensure table "${fromName}" exists`, fromPresent.sql, fromPresent.params), step(`ensure table "${toName}" does not exist`, toAbsent.sql, toAbsent.params), ], execute: [ @@ -52,6 +52,12 @@ export async function renameTable( `ALTER TABLE ${qualified} RENAME TO ${quoteIdentifier(toName)}`, ), ], - postcheck: [step(`verify table "${toName}" exists`, toPresent.sql, toPresent.params)], + // Both postchecks: the runner skips an operation whose postcheck already + // holds, and "the new table exists" alone would skip a rename that never + // happened when both tables exist. + postcheck: [ + step(`verify table "${toName}" exists`, toPresent.sql, toPresent.params), + step(`verify table "${fromName}" no longer exists`, fromAbsent.sql, fromAbsent.params), + ], }; } diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts index 91b598116b3d..52f4b413ea22 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts @@ -51,7 +51,7 @@ describe('renameTable (postgres)', () => { ]); }); - it('prechecks that the old table exists and the new one does not, then postchecks the new one', async () => { + it('prechecks that the old table exists and the new one does not, then postchecks both', async () => { const { lowerer, received } = recordingCheckLowerer(); const op = await renameTable('public', 'userProfile', 'UserProfile', lowerer); @@ -59,6 +59,7 @@ describe('renameTable (postgres)', () => { tableExistsAst('public', 'userProfile').tablePresent(), tableExistsAst('public', 'UserProfile').tableAbsent(), tableExistsAst('public', 'UserProfile').tablePresent(), + tableExistsAst('public', 'userProfile').tableAbsent(), ]); expect(op.precheck).toEqual([ { description: 'ensure table "userProfile" exists', sql: 'LOWERED 1', params: ['p1'] }, @@ -70,6 +71,11 @@ describe('renameTable (postgres)', () => { ]); expect(op.postcheck).toEqual([ { description: 'verify table "UserProfile" exists', sql: 'LOWERED 3', params: ['p3'] }, + { + description: 'verify table "userProfile" no longer exists', + sql: 'LOWERED 4', + params: ['p4'], + }, ]); }); }); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index 0923e54d4853..5e456cb90cc7 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -289,12 +289,15 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { { meta: { factory: this.factoryName, tableName: this.oldTableName } }, ); } - const from = await lowerer.lowerToExecuteRequest( - tableExistsAst(this.oldTableName).tablePresent(), - ); + const fromChecks = tableExistsAst(this.oldTableName); const toChecks = tableExistsAst(this.tableName); + const fromPresent = await lowerer.lowerToExecuteRequest(fromChecks.tablePresent()); const toAbsent = await lowerer.lowerToExecuteRequest(toChecks.tableAbsent()); + const viaAbsent = this.caseOnly() + ? await lowerer.lowerToExecuteRequest(tableExistsAst(this.viaName()).tableAbsent()) + : undefined; const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); + const fromAbsent = await lowerer.lowerToExecuteRequest(fromChecks.tableAbsent()); return { id: `renameTable.${this.oldTableName}`, label: this.label, @@ -302,11 +305,30 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { operationClass: 'widening', target: { id: 'sqlite', details: buildTargetDetails('table', this.tableName) }, precheck: [ - step(`ensure table "${this.oldTableName}" exists`, from.sql, from.params), + step(`ensure table "${this.oldTableName}" exists`, fromPresent.sql, fromPresent.params), step(`ensure table "${this.tableName}" does not exist`, toAbsent.sql, toAbsent.params), + ...(viaAbsent === undefined + ? [] + : [ + step( + `ensure table "${this.viaName()}" does not exist (a rename that only changes case passes through this temporary name, because SQLite compares table names without case)`, + viaAbsent.sql, + viaAbsent.params, + ), + ]), ], execute: this.executeSteps(), - postcheck: [step(`verify table "${this.tableName}" exists`, toPresent.sql, toPresent.params)], + // Both postchecks: the runner skips an operation whose postcheck + // already holds, and "the new table exists" alone would skip a rename + // that never happened when both tables exist. + postcheck: [ + step(`verify table "${this.tableName}" exists`, toPresent.sql, toPresent.params), + step( + `verify table "${this.oldTableName}" no longer exists`, + fromAbsent.sql, + fromAbsent.params, + ), + ], }; } @@ -315,10 +337,18 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { * changes case (`userProfile` to `UserProfile`) is refused as "already * exists" when done in one statement. It goes through a temporary name. */ + private caseOnly(): boolean { + return this.oldTableName.toLowerCase() === this.tableName.toLowerCase(); + } + + private viaName(): string { + return `_prisma_rename_${this.tableName}`; + } + private executeSteps(): Op['execute'] { const from = quoteIdentifier(this.oldTableName); const to = quoteIdentifier(this.tableName); - if (this.oldTableName.toLowerCase() !== this.tableName.toLowerCase()) { + if (!this.caseOnly()) { return [ step( `rename table "${this.oldTableName}" to "${this.tableName}"`, @@ -326,7 +356,7 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { ), ]; } - const viaName = `_prisma_rename_${this.tableName}`; + const viaName = this.viaName(); const via = quoteIdentifier(viaName); return [ step( diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts index 1328afa1cfa8..cb0d54794259 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts @@ -39,6 +39,7 @@ describe('RenameTableCall (sqlite)', () => { tableExistsAst('profile').tablePresent(), tableExistsAst('account').tableAbsent(), tableExistsAst('account').tablePresent(), + tableExistsAst('profile').tableAbsent(), ]); expect(op).toEqual({ id: 'renameTable.profile', @@ -58,6 +59,11 @@ describe('RenameTableCall (sqlite)', () => { ], postcheck: [ { description: 'verify table "account" exists', sql: 'LOWERED 3', params: ['p3'] }, + { + description: 'verify table "profile" no longer exists', + sql: 'LOWERED 4', + params: ['p4'], + }, ], }); }); @@ -72,6 +78,33 @@ describe('RenameTableCall (sqlite)', () => { ]); }); + it('prechecks that the temporary name is free on a case-only rename, saying why it is needed', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); + + expect(received).toEqual([ + tableExistsAst('userProfile').tablePresent(), + tableExistsAst('UserProfile').tableAbsent(), + tableExistsAst('_prisma_rename_UserProfile').tableAbsent(), + tableExistsAst('UserProfile').tablePresent(), + tableExistsAst('userProfile').tableAbsent(), + ]); + expect(op.precheck).toEqual([ + { description: 'ensure table "userProfile" exists', sql: 'LOWERED 1', params: ['p1'] }, + { + description: 'ensure table "UserProfile" does not exist', + sql: 'LOWERED 2', + params: ['p2'], + }, + { + description: + 'ensure table "_prisma_rename_UserProfile" does not exist (a rename that only changes case passes through this temporary name, because SQLite compares table names without case)', + sql: 'LOWERED 3', + params: ['p3'], + }, + ]); + }); + it('toOp() without a lowerer reports MIGRATION.SQLITE_CONTROL_STACK_MISSING', async () => { const call = new RenameTableCall('userProfile', 'UserProfile'); await expect(call.toOp()).rejects.toMatchObject({ diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts new file mode 100644 index 000000000000..6711b919b1f8 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts @@ -0,0 +1,85 @@ +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { RenameTableCall } from '@internal/target-postgres/op-factory-call'; +import type { PostgresPlanTargetDetails } from '@internal/target-postgres/planner-target-details'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + contract, + controlAdapter, + createDriver, + createMigrationPlan, + createTestDatabase, + familyInstance, + frameworkComponents, + type PostgresControlDriver, + postgresTargetDescriptor, + resetDatabase, + synthEdges, + testTimeout, + toPlanContractInfo, +} from './fixtures/runner-fixtures'; + +describe('PostgresMigrationRunner - renameTable', { concurrent: false }, () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + await database?.close(); + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + await driver?.close(); + driver = undefined; + }, testTimeout); + + it('fails the precheck instead of skipping the rename when the old and the new table both exist', { + timeout: testTimeout, + }, async () => { + await driver!.query('create table "public"."userProfile" (id int primary key)'); + await driver!.query('insert into "public"."userProfile" (id) values (1)'); + await driver!.query('create table "public"."UserProfile" (id int primary key)'); + const plan = createMigrationPlan({ + targetId: 'postgres', + spaceId: APP_SPACE_ID, + origin: null, + destination: toPlanContractInfo(contract), + operations: [ + await new RenameTableCall('public', 'userProfile', 'UserProfile').toOp(controlAdapter), + ], + providedInvariants: [], + }); + + const result = await postgresTargetDescriptor.createRunner(familyInstance).execute({ + driver: driver!, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan, + migrationEdges: synthEdges(plan), + driver: driver!, + destinationContract: contract, + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + frameworkComponents, + }, + ], + }); + + expect(result.ok).toBe(false); + expect(result.assertNotOk()).toMatchObject({ + code: 'MIGRATION.PRECHECK_FAILED', + summary: + 'Operation renameTable.userProfile failed during precheck: ensure table "UserProfile" does not exist', + meta: { operationId: 'renameTable.userProfile' }, + }); + const rows = await driver!.query('select id from "public"."userProfile"'); + expect(rows.rows).toEqual([{ id: 1 }]); + }); +}); diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts new file mode 100644 index 000000000000..5525aa8379c9 --- /dev/null +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts @@ -0,0 +1,66 @@ +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { RenameTableCall } from '@internal/target-sqlite/op-factory-call'; +import type { SqlitePlanTargetDetails } from '@internal/target-sqlite/planner-target-details'; +import { timeouts } from '@repo/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + contract, + controlAdapter, + createMigrationPlan, + createTestDatabase, + familyInstance, + frameworkComponents, + sqliteTargetDescriptor, + synthEdges, + type TestDatabase, + toPlanContractInfo, +} from './fixtures/runner-fixtures'; + +describe('SqliteMigrationRunner - renameTable', { timeout: timeouts.databaseOperation }, () => { + let testDb: TestDatabase; + + afterEach(() => { + testDb?.cleanup(); + }); + + it('fails the precheck instead of skipping the rename when the old and the new table both exist', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + await driver.query('CREATE TABLE "profile" (id INTEGER PRIMARY KEY)'); + await driver.query('INSERT INTO "profile" (id) VALUES (1)'); + await driver.query('CREATE TABLE "account" (id INTEGER PRIMARY KEY)'); + const plan = createMigrationPlan({ + targetId: 'sqlite', + spaceId: APP_SPACE_ID, + origin: null, + destination: toPlanContractInfo(contract), + operations: [await new RenameTableCall('profile', 'account').toOp(controlAdapter)], + providedInvariants: [], + }); + + const result = await sqliteTargetDescriptor.createRunner(familyInstance).execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan, + migrationEdges: synthEdges(plan), + driver, + destinationContract: contract, + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + frameworkComponents, + }, + ], + }); + + expect(result.ok).toBe(false); + expect(result.assertNotOk()).toMatchObject({ + code: 'MIGRATION.PRECHECK_FAILED', + summary: + 'Operation renameTable.profile failed during precheck: ensure table "account" does not exist', + meta: { operationId: 'renameTable.profile' }, + }); + const rows = await driver.query('SELECT id FROM "profile"'); + expect(rows.rows).toEqual([{ id: 1 }]); + }); +}); From b37a029b3675dd15ac021319bbad7f68e8b714e5 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:21:07 +0200 Subject: [PATCH 17/55] fix(postgres): move RLS markers and policies with a renamed table The family rename helper re-keyed tables and retargeted foreign keys but carried other entity kinds unchanged. The Postgres rls marker is keyed by table name and each policy names its table, so after a rename both still named the old table, and re-deriving the previous schema threw CONTRACT.POLICY_INVALID. A table with @@rls could not be renamed. The helper now takes a renameTableReferences function from the target, applied once per rename in its namespace, which keeps the helper free of target entity kinds. The Postgres planner passes one that re-keys the rls marker and sets each policy tableName to the new name. SQLite passes none. Policy names come from the policy block name, not the table name, so no policy is renamed, and ALTER TABLE ... RENAME TO keeps RLS enabled and the policies attached. The plan is the rename alone. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../core/migrations/table-rename-intents.ts | 30 +++++- .../test/table-rename-intents.test.ts | 53 +++++++++- .../postgres/src/core/migrations/planner.ts | 2 + .../core/migrations/rename-rls-references.ts | 55 +++++++++++ .../rename-table-planner.rls.test.ts | 97 +++++++++++++++++++ .../sqlite/src/core/migrations/planner.ts | 1 + 6 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts diff --git a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts index 62a4cfeb0b15..8d82193d55c4 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts @@ -29,10 +29,19 @@ export interface ResolvedTableRename { readonly to: string; } +/** + * Renames a target's own references to a renamed table inside one namespace's entries: entity kinds the family does not know, such as a target's row-level-security markers, that name a table. + */ +export type RenameTableReferences = ( + entries: SqlNamespaceEntries, + rename: ResolvedTableRename, +) => SqlNamespaceEntries; + export interface ApplyTableRenameIntentsInput { readonly fromContract: Contract | null; readonly toContract: Contract; readonly intents: readonly TableRenameIntent[]; + readonly renameTableReferences: RenameTableReferences | undefined; } export interface AppliedTableRenames { @@ -203,6 +212,7 @@ function withEntries(namespace: SqlNamespaceBase, entries: SqlNamespaceEntries): function renameTablesInNamespace( namespace: SqlNamespace, renames: readonly ResolvedTableRename[], + renameTableReferences: RenameTableReferences | undefined, ): SqlNamespace { const own = renames.filter((rename) => rename.namespaceId === namespace.id); const tables = Object.entries(namespace.entries.table ?? {}).map(([name, table]) => { @@ -217,20 +227,27 @@ function renameTablesInNamespace( `applyTableRenameIntents: namespace "${namespace.id}" is not a materialized SQL namespace`, ); } - return withEntries(namespace, { + const renamedTables: SqlNamespaceEntries = { ...namespace.entries, table: Object.fromEntries(tables), - }); + }; + return withEntries( + namespace, + renameTableReferences === undefined + ? renamedTables + : own.reduce(renameTableReferences, renamedTables), + ); } function renameTablesInContract( contract: Contract, renames: readonly ResolvedTableRename[], + renameTableReferences: RenameTableReferences | undefined, ): Contract { const namespaces = Object.fromEntries( Object.entries(contract.storage.namespaces).map(([id, namespace]) => [ id, - renameTablesInNamespace(namespace, renames), + renameTablesInNamespace(namespace, renames, renameTableReferences), ]), ); const materialized: Record = {}; @@ -265,6 +282,8 @@ function renameTablesInContract( * the FK still pairs with the next contract's. Index, unique, check and * primary-key names are carried unchanged: the differ pairs those by hash or * by columns, and their renames are planned by the existing rename passes. + * Target entity kinds that name a table are renamed by the target's + * `renameTableReferences`, once per rename in their namespace. */ export function applyTableRenameIntents( input: ApplyTableRenameIntentsInput, @@ -322,5 +341,8 @@ export function applyTableRenameIntents( } } if (conflicts.length > 0) return notOk(conflicts); - return ok({ contract: renameTablesInContract(fromContract, renames), renames }); + return ok({ + contract: renameTablesInContract(fromContract, renames, input.renameTableReferences), + renames, + }); } diff --git a/packages/2-sql/9-family/test/table-rename-intents.test.ts b/packages/2-sql/9-family/test/table-rename-intents.test.ts index 3238032b591c..59811fa99cc4 100644 --- a/packages/2-sql/9-family/test/table-rename-intents.test.ts +++ b/packages/2-sql/9-family/test/table-rename-intents.test.ts @@ -111,6 +111,7 @@ describe('applyTableRenameIntents', () => { }); const result = applyTableRenameIntents({ + renameTableReferences: undefined, fromContract, toContract, intents: [rename('userProfile', 'UserProfile')], @@ -147,6 +148,7 @@ describe('applyTableRenameIntents', () => { }); const result = applyTableRenameIntents({ + renameTableReferences: undefined, fromContract, toContract, intents: [rename('userProfile', 'UserProfile')], @@ -173,6 +175,7 @@ describe('applyTableRenameIntents', () => { const toContract = contractOf({ auth: { UserProfile: table() }, app: { post: table() } }); const result = applyTableRenameIntents({ + renameTableReferences: undefined, fromContract, toContract, intents: [rename('userProfile', 'UserProfile')], @@ -197,6 +200,7 @@ describe('applyTableRenameIntents', () => { }); const result = applyTableRenameIntents({ + renameTableReferences: undefined, fromContract, toContract, intents: [ @@ -215,10 +219,47 @@ describe('applyTableRenameIntents', () => { expect(tablesOf(result.value.contract, 'app')).toEqual(['userProfile']); }); + it('lets the target rename its own references to each renamed table in that table namespace', () => { + const fromContract = contractOf({ + auth: { userProfile: table(), account: table() }, + app: { userProfile: table() }, + }); + const toContract = contractOf({ + auth: { UserProfile: table(), account: table() }, + app: { userProfile: table() }, + }); + const calls: string[] = []; + + const result = applyTableRenameIntents({ + fromContract, + toContract, + intents: [ + { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, + ], + renameTableReferences: (entries, applied) => { + calls.push(`${applied.namespaceId}:${applied.from}>${applied.to}`); + return { ...entries, marker: { [applied.to]: { tableName: applied.to } } }; + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(calls).toEqual(['auth:userProfile>UserProfile']); + expect(result.value.contract.storage.namespaces['auth']?.entries['marker']).toEqual({ + UserProfile: { tableName: 'UserProfile' }, + }); + expect(result.value.contract.storage.namespaces['app']?.entries['marker']).toBeUndefined(); + }); + it('returns nothing to do when there are no intents', () => { const fromContract = contractOf({ [UNBOUND_NAMESPACE_ID]: { userProfile: table() } }); - const result = applyTableRenameIntents({ fromContract, toContract: fromContract, intents: [] }); + const result = applyTableRenameIntents({ + fromContract, + toContract: fromContract, + intents: [], + renameTableReferences: undefined, + }); expect(result.ok).toBe(true); if (!result.ok) return; @@ -235,7 +276,12 @@ describe('applyTableRenameIntents', () => { }); function conflictsFor(intents: readonly StorageEntityRename[]) { - const result = applyTableRenameIntents({ fromContract, toContract, intents }); + const result = applyTableRenameIntents({ + fromContract, + toContract, + intents, + renameTableReferences: undefined, + }); expect(result.ok).toBe(false); return result.ok ? [] : result.failure; } @@ -305,6 +351,7 @@ describe('applyTableRenameIntents', () => { app: { userProfile: table() }, }); const result = applyTableRenameIntents({ + renameTableReferences: undefined, fromContract: ambiguousFrom, toContract: ambiguousTo, intents: [rename('userProfile', 'UserProfile')], @@ -317,6 +364,7 @@ describe('applyTableRenameIntents', () => { it('rejects intents without a prior contract to apply them to, with its own code', () => { const result = applyTableRenameIntents({ + renameTableReferences: undefined, fromContract: null, toContract, intents: [rename('userProfile', 'UserProfile')], @@ -340,6 +388,7 @@ describe('applyTableRenameIntents', () => { function conflictsBetween(intents: readonly StorageEntityRename[]) { const result = applyTableRenameIntents({ + renameTableReferences: undefined, fromContract: authFrom, toContract: authTo, intents, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 068c63063726..3bd51f3c9f64 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -68,6 +68,7 @@ import { import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration'; import { postgresPlannerStrategies } from './planner-strategies'; import { postgresContractToSchema } from './postgres-contract-to-schema'; +import { renameRlsReferences } from './rename-rls-references'; import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; import { verifyPostgresNamespacePresence } from './verify-postgres-namespaces'; @@ -224,6 +225,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr fromContract: options.fromContract, toContract: options.contract, intents, + renameTableReferences: renameRlsReferences, }); if (applied !== undefined && !applied.ok) { return plannerFailure(applied.failure); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts new file mode 100644 index 000000000000..0934ab53f2f6 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts @@ -0,0 +1,55 @@ +import type { ResolvedTableRename } from '@internal/family-sql/control'; +import type { SqlNamespaceEntries } from '@internal/sql-contract/types'; +import { namingOf } from '@internal/sql-schema-ir/naming'; +import { PostgresRlsEnablement } from '../postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../postgres-rls-policy'; + +function renamedMarker(marker: unknown, rename: ResolvedTableRename): unknown { + if (!(marker instanceof PostgresRlsEnablement) || marker.tableName !== rename.from) return marker; + return new PostgresRlsEnablement({ tableName: rename.to, namespaceId: marker.namespaceId }); +} + +function renamedPolicy(policy: unknown, rename: ResolvedTableRename): unknown { + if (!(policy instanceof PostgresRlsPolicy) || policy.tableName !== rename.from) return policy; + return new PostgresRlsPolicy({ + naming: namingOf(policy.name, policy.prefix), + tableName: rename.to, + namespaceId: policy.namespaceId, + operation: policy.operation, + roles: policy.roles, + using: policy.using, + withCheck: policy.withCheck, + permissive: policy.permissive, + }); +} + +/** + * Moves the RLS marker and the policies of a renamed table onto its new name. The marker is keyed by table name; policies are keyed by their own name and only their `tableName` changes. Policy names do not derive from the table name, so none is renamed. + */ +export function renameRlsReferences( + entries: SqlNamespaceEntries, + rename: ResolvedTableRename, +): SqlNamespaceEntries { + const markers = entries['rls']; + const policies = entries['policy']; + return { + ...entries, + ...(markers === undefined + ? {} + : { + rls: Object.fromEntries( + Object.entries(markers).map(([tableName, marker]) => [ + tableName === rename.from ? rename.to : tableName, + renamedMarker(marker, rename), + ]), + ), + }), + ...(policies === undefined + ? {} + : { + policy: Object.fromEntries( + Object.entries(policies).map(([name, policy]) => [name, renamedPolicy(policy, rename)]), + ), + }), + }; +} diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts new file mode 100644 index 000000000000..680c88456218 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts @@ -0,0 +1,97 @@ +/** + * `--rename-table` on a row-level-security table: the Postgres `rls` marker and every policy name the table they attach to, so a rename moves them with the table. `ALTER TABLE ... RENAME TO` keeps RLS enabled and the policies attached, and policy names do not derive from the table name, so the plan is the rename alone. + */ + +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import type { StorageEntityRename } from '@internal/framework-components/control'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { PostgresRlsEnablement } from '../../src/core/postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; +import { PostgresSchema } from '../../src/core/postgres-schema'; + +const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const RENAME: StorageEntityRename = { + from: { name: 'userProfile' }, + to: { name: 'UserProfile' }, +}; + +function rlsContract(tableName: string, hashSeed: string): Contract { + const policy = new PostgresRlsPolicy({ + naming: { kind: 'wire', prefix: 'tenant_read', hash: 'f8d5e783' }, + tableName, + namespaceId: 'public', + operation: 'select', + roles: ['app_user'], + using: '(tenant_id = 1)', + withCheck: undefined, + permissive: true, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + public: new PostgresSchema({ + id: 'public', + entries: { + table: { + [tableName]: new StorageTable({ + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + tenant_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'], name: `${tableName}_pkey` }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + }, + policy: { [policy.name]: policy }, + rls: { [tableName]: new PostgresRlsEnablement({ tableName, namespaceId: 'public' }) }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +function plan(from: Contract, to: Contract) { + return createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: postgresContractToSchema(from, []), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + renames: [RENAME], + }); +} + +describe('Postgres planner rename-table intents on an RLS table', () => { + it('plans the rename alone for a table with RLS enabled and a policy', async () => { + const result = plan(rlsContract('userProfile', 'from'), rlsContract('UserProfile', 'to')); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const ops = await Promise.all(result.plan.operations); + expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 7cd39f387b4d..a60d9d3d7e5e 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -169,6 +169,7 @@ export class SqliteMigrationPlanner fromContract: options.fromContract, toContract: options.contract, intents, + renameTableReferences: undefined, }); if (applied !== undefined && !applied.ok) { return plannerFailure(applied.failure); From f15a66becfac27102c105d7964e0679dad09b145 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:27:05 +0200 Subject: [PATCH 18/55] fix(migration): rename the constraints and indexes a renamed table named after itself Postgres names a primary key, unique constraint or foreign key the contract leaves unnamed after its table (userProfile_pkey, userProfile_email_key, userProfile_accountId_fkey). ALTER TABLE ... RENAME TO keeps those names, but every later plan derives them from the new table name, so a migration that dropped one failed. The Postgres planner now follows each table rename with a constraint rename for every such constraint the renamed table owns. A constraint the previous or next contract names explicitly keeps its name, and foreign keys on other tables keep theirs. To keep the op-factory union the same size, RenameCheckConstraintCall becomes RenameConstraintCall with a kind (primaryKey, unique, foreignKey, checkConstraint). Check renames keep their op id, label and SQL; they now render as this.renameConstraint({ ..., kind: "checkConstraint" }). The renameCheckConstraint facade method stays and delegates. The default-name templates move into one module used by both the issue planner and the table rename. Wire-named indexes and checks whose prefix derives from the table name already pair by content hash and plan as renames on Postgres; new planner tests pin that, and that foreign keys on other tables plan nothing. On SQLite, the automatic indexes and referencing foreign keys follow the table without help, and a table-derived index name is dropped and recreated by the ordinary diff. SQLite compares index names without case, so when only the case changes the create failed while the old index existed. Index drops on a renamed table now run right after the rename. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../migrations/default-constraint-names.ts | 15 + .../src/core/migrations/issue-planner.ts | 26 +- .../src/core/migrations/op-factory-call.ts | 27 +- .../core/migrations/operations/constraints.ts | 29 +- .../postgres/src/core/migrations/planner.ts | 44 ++- .../src/core/migrations/postgres-migration.ts | 18 +- .../table-rename-constraint-renames.ts | 73 +++++ .../postgres/src/exports/op-factory-call.ts | 2 +- ...rename-table-planner.table-objects.test.ts | 265 ++++++++++++++++++ .../test/migrations/render-typescript.test.ts | 6 +- .../sqlite/src/core/migrations/planner.ts | 15 +- ...rename-table-planner.table-objects.test.ts | 146 ++++++++++ skills/prisma-8/references/migrations.md | 4 +- 13 files changed, 627 insertions(+), 43 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/default-constraint-names.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/default-constraint-names.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/default-constraint-names.ts new file mode 100644 index 000000000000..994bfa262c91 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/default-constraint-names.ts @@ -0,0 +1,15 @@ +/** + * The names the planner gives a primary key, unique constraint or foreign key the contract leaves unnamed. They derive from the table name, so renaming the table leaves them stale until the planner renames them too. + */ + +export function defaultPrimaryKeyName(tableName: string): string { + return `${tableName}_pkey`; +} + +export function defaultUniqueName(tableName: string, columns: readonly string[]): string { + return `${tableName}_${columns.join('_')}_key`; +} + +export function defaultForeignKeyName(tableName: string, columns: readonly string[]): string { + return `${tableName}_${columns.join('_')}_fkey`; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index b4aa945c667c..04f55f168fd9 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -55,6 +55,11 @@ import { renderColumnDefaultSql, } from './column-ddl-rendering'; import { resolveNamespaceIdForDdlSchema } from './control-policy'; +import { + defaultForeignKeyName, + defaultPrimaryKeyName, + defaultUniqueName, +} from './default-constraint-names'; import { AddCheckConstraintCall, AddColumnCall, @@ -158,7 +163,7 @@ function classifyCall(call: PostgresOpFactoryCall): CallCategory { case 'dropDefault': return 'drop'; case 'addCheckConstraint': - case 'renameCheckConstraint': + case 'renameConstraint': return 'unique'; // after uniques, before indexes case 'createTable': case 'renameTable': @@ -410,7 +415,7 @@ function isStrictDescendantPath(path: readonly string[], ancestor: readonly stri // ---------------------------------------------------------------------------- function fkSpecFromNode(fk: SqlForeignKeyIR, tableName: string): ForeignKeySpec { - const name = fk.name ?? `${tableName}_${fk.columns.join('_')}_fkey`; + const name = fk.name ?? defaultForeignKeyName(tableName, fk.columns); return { name, columns: [...fk.columns], @@ -470,7 +475,7 @@ function buildCreateTableCallsFromNode( calls.push(new AddForeignKeyCall(schemaName, table.name, fkSpecFromNode(fk, table.name))); } for (const unique of table.uniques) { - const constraintName = unique.name ?? `${table.name}_${unique.columns.join('_')}_key`; + const constraintName = unique.name ?? defaultUniqueName(table.name, unique.columns); calls.push(new AddUniqueCall(schemaName, table.name, constraintName, [...unique.columns])); } // Marker-driven: a newly-created table that is RLS-controlled enables RLS @@ -731,7 +736,7 @@ function mapPrimaryKeyNodeIssue( { readonly columns: readonly string[]; readonly name?: string }, 'a not-found primary-key issue always carries the expected PrimaryKey node' >(issue.expected); - const constraintName = pk.name ?? `${tableName}_pkey`; + const constraintName = pk.name ?? defaultPrimaryKeyName(tableName); return ok([new AddPrimaryKeyCall(schemaName, tableName, constraintName, [...pk.columns])]); } if (issueOutcome(issue) === 'not-expected') { @@ -740,7 +745,12 @@ function mapPrimaryKeyNodeIssue( 'a not-expected primary-key issue always carries the actual PrimaryKey node' >(issue.actual); return ok([ - new DropConstraintCall(schemaName, tableName, pk.name ?? `${tableName}_pkey`, 'primaryKey'), + new DropConstraintCall( + schemaName, + tableName, + pk.name ?? defaultPrimaryKeyName(tableName), + 'primaryKey', + ), ]); } return notOk(nodeConflict('indexIncompatible', issue.path.join('/'))); @@ -763,7 +773,7 @@ function mapForeignKeyNodeIssue( SqlForeignKeyIR, 'a not-expected foreign-key issue always carries the actual foreign-key node' >(issue.actual); - const name = fk.name ?? `${tableName}_${fk.columns.join('_')}_fkey`; + const name = fk.name ?? defaultForeignKeyName(tableName, fk.columns); return ok([new DropConstraintCall(schemaName, tableName, name, 'foreignKey')]); } return notOk(nodeConflict('foreignKeyConflict', issue.path.join('/'))); @@ -779,7 +789,7 @@ function mapUniqueNodeIssue( SqlUniqueIR, 'a not-found unique issue always carries the expected unique node' >(issue.expected); - const name = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`; + const name = unique.name ?? defaultUniqueName(tableName, unique.columns); return ok([new AddUniqueCall(schemaName, tableName, name, [...unique.columns])]); } if (issueOutcome(issue) === 'not-expected') { @@ -787,7 +797,7 @@ function mapUniqueNodeIssue( SqlUniqueIR, 'a not-expected unique issue always carries the actual unique node' >(issue.actual); - const name = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`; + const name = unique.name ?? defaultUniqueName(tableName, unique.columns); return ok([new DropConstraintCall(schemaName, tableName, name, 'unique')]); } return notOk(nodeConflict('indexIncompatible', issue.path.join('/'))); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 63d6e55a7106..08f253eab492 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -73,7 +73,9 @@ import { addUnique, dropCheckConstraint, dropConstraint, - renameCheckConstraint, + type RenamableConstraintKind, + renameConstraint, + renameConstraintLabel, } from './operations/constraints'; import { createExtension } from './operations/dependencies'; import { @@ -365,7 +367,7 @@ export class DropTableCall extends PostgresOpFactoryCallNode { export class RenameTableCall extends PostgresOpFactoryCallNode { readonly factoryName = 'renameTable' as const; - // `widening` for the same reason as `RenameCheckConstraintCall`: a rename is + // `widening` for the same reason as `RenameConstraintCall`: a rename is // neither additive creation nor destructive, and the class vocabulary has no // neutral middle class, so this is the class that plans under every // allowance set except additive-only init. @@ -1095,8 +1097,8 @@ export class DropConstraintCall extends PostgresOpFactoryCallNode { } } -export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { - readonly factoryName = 'renameCheckConstraint' as const; +export class RenameConstraintCall extends PostgresOpFactoryCallNode { + readonly factoryName = 'renameConstraint' as const; // `widening` is chosen so the rename plans under every allowance set except // additive-only init — a rename is neither additive-creation nor // destructive, and the class vocabulary has no neutral middle class. It is @@ -1104,6 +1106,7 @@ export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; + readonly kind: RenamableConstraintKind; readonly oldConstraintName: string; readonly newConstraintName: string; readonly label: string; @@ -1111,15 +1114,17 @@ export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { constructor( schemaName: string, tableName: string, + kind: RenamableConstraintKind, oldConstraintName: string, newConstraintName: string, ) { super(); this.schemaName = schemaName; this.tableName = tableName; + this.kind = kind; this.oldConstraintName = oldConstraintName; this.newConstraintName = newConstraintName; - this.label = `Rename check constraint "${oldConstraintName}" to "${newConstraintName}" on "${tableName}"`; + this.label = renameConstraintLabel(kind, oldConstraintName, newConstraintName, tableName); this.freeze(); } @@ -1127,13 +1132,14 @@ export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { if (lowerer === undefined) { throw postgresError( 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', - `RenameCheckConstraintCall.toOp: a lowerer is required on the Postgres planner path (constraint "${this.oldConstraintName}" on table "${this.tableName}"). Pass the control adapter to createPostgresMigrationPlanner.`, - { meta: { factory: 'RenameCheckConstraintCall' } }, + `RenameConstraintCall.toOp: a lowerer is required on the Postgres planner path (constraint "${this.oldConstraintName}" on table "${this.tableName}"). Pass the control adapter to createPostgresMigrationPlanner.`, + { meta: { factory: 'RenameConstraintCall' } }, ); } - return renameCheckConstraint( + return renameConstraint( this.schemaName, this.tableName, + this.kind, this.oldConstraintName, this.newConstraintName, lowerer, @@ -1146,9 +1152,10 @@ export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { opts.push(`schema: ${jsonToTsSource(this.schemaName)}`); } opts.push(`table: ${jsonToTsSource(this.tableName)}`); + opts.push(`kind: ${jsonToTsSource(this.kind)}`); opts.push(`from: ${jsonToTsSource(this.oldConstraintName)}`); opts.push(`to: ${jsonToTsSource(this.newConstraintName)}`); - return `this.renameCheckConstraint({ ${opts.join(', ')} })`; + return `this.renameConstraint({ ${opts.join(', ')} })`; } override importRequirements(): readonly ImportRequirement[] { @@ -2021,7 +2028,7 @@ export type PostgresOpFactoryCall = | AddForeignKeyCall | AddUniqueCall | AddCheckConstraintCall - | RenameCheckConstraintCall + | RenameConstraintCall | DropCheckConstraintCall | CreateIndexCall | RenameIndexCall diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts index 4c2fd2704bd4..1c93e9c776e0 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts @@ -161,9 +161,28 @@ export async function addCheckConstraint( }; } -export async function renameCheckConstraint( +export type RenamableConstraintKind = 'primaryKey' | 'unique' | 'foreignKey' | 'checkConstraint'; + +const CONSTRAINT_KIND_LABEL: Readonly> = { + primaryKey: 'primary key', + unique: 'unique constraint', + foreignKey: 'foreign key', + checkConstraint: 'check constraint', +}; + +export function renameConstraintLabel( + kind: RenamableConstraintKind, + fromName: string, + toName: string, + tableName: string, +): string { + return `Rename ${CONSTRAINT_KIND_LABEL[kind]} "${fromName}" to "${toName}" on "${tableName}"`; +} + +export async function renameConstraint( schemaName: string, tableName: string, + kind: RenamableConstraintKind, fromName: string, toName: string, lowerer: ExecuteRequestLowerer, @@ -182,19 +201,19 @@ export async function renameCheckConstraint( table: tableName, }); return { - id: `checkConstraint.${schemaName}.${tableName}.${fromName}.rename`, - label: `Rename check constraint "${fromName}" to "${toName}" on "${tableName}"`, + id: `${kind}.${schemaName}.${tableName}.${fromName}.rename`, + label: renameConstraintLabel(kind, fromName, toName, tableName), operationClass: 'widening', // The NEW name is the constraint's contract-side identity — the rename // convention indexes and policies already follow. - target: targetDetails('checkConstraint', toName, schemaName, tableName), + target: targetDetails(kind, toName, schemaName, tableName), precheck: [ step(`ensure constraint "${fromName}" exists`, from.present.sql, from.present.params), step(`ensure constraint "${toName}" does not exist`, to.absent.sql, to.absent.params), ], execute: [ step( - `rename check constraint "${fromName}" to "${toName}"`, + `rename ${CONSTRAINT_KIND_LABEL[kind]} "${fromName}" to "${toName}"`, `ALTER TABLE ${qualified} RENAME CONSTRAINT ${quoteIdentifier(fromName)} TO ${quoteIdentifier(toName)}`, ), ], diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 3bd51f3c9f64..09066b05af02 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -33,6 +33,7 @@ import type { SqlStorage } from '@internal/sql-contract/types'; import { namingOf, parseWireName } from '@internal/sql-schema-ir/naming'; import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; import { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types'; +import { assertDefined } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; @@ -60,7 +61,7 @@ import type { PostgresOpFactoryCall } from './op-factory-call'; import { CreatePostgresRlsPolicyCall, DropPostgresRlsPolicyCall, - RenameCheckConstraintCall, + RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, RenameTableCall, @@ -70,6 +71,7 @@ import { postgresPlannerStrategies } from './planner-strategies'; import { postgresContractToSchema } from './postgres-contract-to-schema'; import { renameRlsReferences } from './rename-rls-references'; import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; +import { constraintRenamesForTableRename } from './table-rename-constraint-renames'; import { verifyPostgresNamespacePresence } from './verify-postgres-namespaces'; type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends { @@ -235,15 +237,34 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr applied === undefined ? options.schema : postgresContractToSchema(applied.value.contract, options.frameworkComponents); - const renameTableCalls = (applied?.value.renames ?? []).map( - (rename) => - new RenameTableCall( + const renameTableCalls = (applied?.value.renames ?? []).flatMap( + (rename): PostgresOpFactoryCall[] => { + const schemaName = rename.namespaceId === UNBOUND_NAMESPACE_ID ? UNBOUND_NAMESPACE_ID - : resolveDdlSchemaForNamespaceStorage(options.contract.storage, rename.namespaceId), - rename.from, - rename.to, - ), + : resolveDdlSchemaForNamespaceStorage(options.contract.storage, rename.namespaceId); + const previous = + options.fromContract?.storage.namespaces[rename.namespaceId]?.entries.table?.[ + rename.from + ]; + const next = + options.contract.storage.namespaces[rename.namespaceId]?.entries.table?.[rename.to]; + assertDefined( + previous, + `a resolved rename names table "${rename.from}" of the previous contract`, + ); + assertDefined(next, `a resolved rename names table "${rename.to}" of the next contract`); + return [ + new RenameTableCall(schemaName, rename.from, rename.to), + ...constraintRenamesForTableRename({ + schemaName, + from: rename.from, + to: rename.to, + previous, + next, + }), + ]; + }, ); const disallowedRenames = renameTableCalls.filter( (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), @@ -631,11 +652,11 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr options: PlannerOptionsWithComponents, issues: readonly SchemaDiffIssue[], ): { - readonly calls: readonly RenameCheckConstraintCall[]; + readonly calls: readonly RenameConstraintCall[]; readonly consumed: ReadonlySet>; } { const consumed = new Set>(); - const calls: RenameCheckConstraintCall[] = []; + const calls: RenameConstraintCall[] = []; if (!options.policy.allowedOperationClasses.includes('widening')) { return { calls, consumed }; } @@ -697,9 +718,10 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr consumed.add(missingFinding.issue); consumed.add(candidate.issue); calls.push( - new RenameCheckConstraintCall( + new RenameConstraintCall( emissionSchema(missingFinding.ddlSchema), missingFinding.tableName, + 'checkConstraint', candidate.node.name, missingFinding.node.name, ), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 16e74be5a168..25fdcc3209f1 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts @@ -35,13 +35,14 @@ import { DropPostgresRlsPolicyCall, DropTableCall, EnableRowLevelSecurityCall, - RenameCheckConstraintCall, + RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, RenameTableCall, SetDefaultCall, SetNotNullCall, } from './op-factory-call'; +import type { RenamableConstraintKind } from './operations/constraints'; import { type DataTransformOptions, dataTransform } from './operations/data-transform'; import { installExtension } from './operations/dependencies'; import type { CreateIndexExtras } from './operations/indexes'; @@ -297,12 +298,23 @@ export abstract class PostgresMigration< readonly from: string; readonly to: string; }): Promise> { - return new RenameCheckConstraintCall( + return this.renameConstraint({ ...options, kind: 'checkConstraint' }); + } + + protected renameConstraint(options: { + readonly schema?: string; + readonly table: string; + readonly kind: RenamableConstraintKind; + readonly from: string; + readonly to: string; + }): Promise> { + return new RenameConstraintCall( options.schema ?? UNBOUND_NAMESPACE_ID, options.table, + options.kind, options.from, options.to, - ).toOp(this.controlAdapterFor('renameCheckConstraint')); + ).toOp(this.controlAdapterFor('renameConstraint')); } protected dropCheckConstraint(options: { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts new file mode 100644 index 000000000000..209b7cfd3fe8 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts @@ -0,0 +1,73 @@ +import type { StorageTable } from '@internal/sql-contract/types'; +import { + defaultForeignKeyName, + defaultPrimaryKeyName, + defaultUniqueName, +} from './default-constraint-names'; +import { RenameConstraintCall } from './op-factory-call'; + +export interface TableRenameConstraintInput { + readonly schemaName: string; + readonly from: string; + readonly to: string; + readonly previous: StorageTable; + readonly next: StorageTable; +} + +function sameColumns(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((column, index) => column === right[index]); +} + +/** + * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the previous contract left unnamed carries a name the planner derived from the old table name, and every later plan derives it from the new one, so it is renamed to match. A constraint the previous contract named keeps its name, and so does one the next contract names explicitly. Indexes and checks are not handled here: their wire names pair by content hash in the ordinary rename passes. + */ +export function constraintRenamesForTableRename( + input: TableRenameConstraintInput, +): readonly RenameConstraintCall[] { + const { schemaName, from, to, previous, next } = input; + const rename = ( + kind: 'primaryKey' | 'unique' | 'foreignKey', + oldName: string, + newName: string, + ): RenameConstraintCall => new RenameConstraintCall(schemaName, to, kind, oldName, newName); + + const primaryKey = + previous.primaryKey !== undefined && + previous.primaryKey.name === undefined && + next.primaryKey?.name === undefined + ? [rename('primaryKey', defaultPrimaryKeyName(from), defaultPrimaryKeyName(to))] + : []; + + const uniques = previous.uniques + .filter( + (unique) => + unique.name === undefined && + next.uniques.find((candidate) => sameColumns(candidate.columns, unique.columns))?.name === + undefined, + ) + .map((unique) => + rename( + 'unique', + defaultUniqueName(from, unique.columns), + defaultUniqueName(to, unique.columns), + ), + ); + + const foreignKeys = previous.foreignKeys + .filter( + (fk) => + fk.name === undefined && + next.foreignKeys.find((candidate) => + sameColumns(candidate.source.columns, fk.source.columns), + )?.name === undefined, + ) + .map((fk) => + rename( + 'foreignKey', + defaultForeignKeyName(from, fk.source.columns), + defaultForeignKeyName(to, fk.source.columns), + ), + ); + + return [...primaryKey, ...uniques, ...foreignKeys]; +} diff --git a/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts index 9673dcad2665..d48e49622b37 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts @@ -26,7 +26,7 @@ export { DropTableCall, EnableRowLevelSecurityCall, RawSqlCall, - RenameCheckConstraintCall, + RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, RenameTableCall, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts new file mode 100644 index 000000000000..9b58d635b4d4 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts @@ -0,0 +1,265 @@ +/** + * `--rename-table` on a Postgres table that carries indexes, constraints and foreign keys. + * + * Wire-named indexes and checks whose prefix derives from the table name pair by content hash and plan as renames. Primary keys, uniques and foreign keys the contract leaves unnamed were named by the planner from the old table name, so each gets a companion constraint rename to the name the planner now derives from the new table name. Explicitly named constraints keep their names, and foreign keys on other tables keep theirs. + */ + +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + type CheckConstraintInput, + type ForeignKeyInput, + type IndexInput, + type PrimaryKeyInput, + SqlStorage, + StorageTable, + type UniqueConstraintInput, +} from '@internal/sql-contract/types'; +import { computeCheckContentHash, computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { postgresCreateNamespace } from '../../src/core/postgres-schema'; + +const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const text = { nativeType: 'text', codecId: 'pg/text@1', nullable: false }; +const int4 = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; +const NICKNAME_CHECK = 'length(nickname) > 0'; + +interface ProfileSpec { + readonly primaryKey?: PrimaryKeyInput; + readonly uniques?: readonly UniqueConstraintInput[]; + readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; + readonly indexes?: (tableName: string) => readonly IndexInput[]; + readonly checks?: (tableName: string) => readonly CheckConstraintInput[]; +} + +function profileTable(tableName: string, spec: ProfileSpec): StorageTable { + return new StorageTable({ + columns: { id: int4, email: text, handle: text, nickname: text, accountId: int4 }, + primaryKey: spec.primaryKey ?? { columns: ['id'], name: 'profile_pk' }, + uniques: spec.uniques ?? [], + indexes: spec.indexes?.(tableName) ?? [], + foreignKeys: spec.foreignKeys?.(tableName) ?? [], + checks: spec.checks?.(tableName) ?? [], + }); +} + +function reference(tableName: string, columns: readonly string[]) { + return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; +} + +const accountTable = new StorageTable({ + columns: { id: int4 }, + primaryKey: { columns: ['id'], name: 'account_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], +}); + +function postTable(profileTableName: string): StorageTable { + return new StorageTable({ + columns: { id: int4, profileId: int4 }, + primaryKey: { columns: ['id'], name: 'post_pk' }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference('post', ['profileId']), + target: reference(profileTableName, ['id']), + }, + ], + }); +} + +function contractOf( + profileTableName: string, + spec: ProfileSpec, + hashSeed: string, + extraTables: (profileTableName: string) => Record = () => ({}), +): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [UNBOUND_NAMESPACE_ID]: postgresCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + [profileTableName]: profileTable(profileTableName, spec), + account: accountTable, + ...extraTables(profileTableName), + }, + policy: {}, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +async function plannedOps(from: Contract, to: Contract) { + const result = createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: postgresContractToSchema(from, []), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + renames: [{ from: { name: 'userProfile' }, to: { name: 'UserProfile' } }], + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return []; + return Promise.all(result.plan.operations); +} + +async function plannedSql( + from: Contract, + to: Contract, +): Promise { + return (await plannedOps(from, to)).map((op) => op.execute.map((step) => step.sql)); +} + +async function planRename(spec: ProfileSpec, nextSpec: ProfileSpec = spec) { + return plannedSql( + contractOf('userProfile', spec, 'from'), + contractOf('UserProfile', nextSpec, 'to'), + ); +} + +const RENAME_TABLE = ['ALTER TABLE "userProfile" RENAME TO "UserProfile"']; + +describe('Postgres planner rename-table intents with table objects', () => { + describe('wire-named indexes and checks', () => { + it('renames an index whose prefix derives from the table name', async () => { + const hash = computeIndexContentHash({ columns: ['handle'], unique: false }); + const handleIndex = (tableName: string): IndexInput => ({ + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }); + + const ops = await plannedOps( + contractOf('userProfile', { indexes: (t) => [handleIndex(t)] }, 'from'), + contractOf('UserProfile', { indexes: (t) => [handleIndex(t)] }, 'to'), + ); + + expect(ops.map((op) => op.label)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + `Rename index "userProfile_handle_idx_${hash}" to "UserProfile_handle_idx_${hash}" on "UserProfile"`, + ]); + }); + + it('renames a check constraint whose prefix derives from the table name', async () => { + const hash = computeCheckContentHash(NICKNAME_CHECK); + const nicknameCheck = (tableName: string): CheckConstraintInput => ({ + naming: { kind: 'wire', prefix: `${tableName}_nickname_check`, hash }, + expression: NICKNAME_CHECK, + }); + + expect(await planRename({ checks: (t) => [nicknameCheck(t)] })).toEqual([ + RENAME_TABLE, + [ + `ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_nickname_check_${hash}" TO "UserProfile_nickname_check_${hash}"`, + ], + ]); + }); + + it('plans nothing for a foreign key on another table that references the renamed table', async () => { + expect( + await plannedSql( + contractOf('userProfile', {}, 'from', (t) => ({ post: postTable(t) })), + contractOf('UserProfile', {}, 'to', (t) => ({ post: postTable(t) })), + ), + ).toEqual([RENAME_TABLE]); + }); + }); + + describe('constraints the contract leaves unnamed', () => { + it('renames the primary key the planner named after the old table', async () => { + expect(await planRename({ primaryKey: { columns: ['id'] } })).toEqual([ + RENAME_TABLE, + ['ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"'], + ]); + }); + + it('renames an unnamed unique constraint', async () => { + expect(await planRename({ uniques: [{ columns: ['email'] }] })).toEqual([ + RENAME_TABLE, + [ + 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_email_key" TO "UserProfile_email_key"', + ], + ]); + }); + + it('renames an unnamed foreign key the renamed table owns', async () => { + const accountFk = (tableName: string): ForeignKeyInput => ({ + source: reference(tableName, ['accountId']), + target: reference('account', ['id']), + }); + + expect(await planRename({ foreignKeys: (t) => [accountFk(t)] })).toEqual([ + RENAME_TABLE, + [ + 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_accountId_fkey" TO "UserProfile_accountId_fkey"', + ], + ]); + }); + + it('renames the unique first, so dropping it in the same plan uses the new name', async () => { + expect(await planRename({ uniques: [{ columns: ['email'] }] }, {})).toEqual([ + RENAME_TABLE, + [ + 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_email_key" TO "UserProfile_email_key"', + ], + ['ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_email_key"'], + ]); + }); + }); + + describe('constraints with explicit names', () => { + it('keeps explicitly named primary keys, uniques and foreign keys', async () => { + const spec: ProfileSpec = { + primaryKey: { columns: ['id'], name: 'profile_pk' }, + uniques: [{ columns: ['email'], name: 'profile_email_unique' }], + foreignKeys: (tableName) => [ + { + source: reference(tableName, ['accountId']), + target: reference('account', ['id']), + name: 'profile_account_fk', + }, + ], + }; + + expect(await planRename(spec)).toEqual([RENAME_TABLE]); + }); + + it('keeps the old name of an unnamed unique that the next contract names explicitly', async () => { + expect( + await planRename( + { uniques: [{ columns: ['email'] }] }, + { uniques: [{ columns: ['email'], name: 'userProfile_email_key' }] }, + ), + ).toEqual([RENAME_TABLE]); + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts index b1c96133087d..35ceca2b0c9d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts @@ -41,7 +41,7 @@ import { DropTableCall, EnableRowLevelSecurityCall, RawSqlCall, - RenameCheckConstraintCall, + RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, RenameTableCall, @@ -232,12 +232,14 @@ describe('renderCallsToTypeScript (postgres) — facade import surface', () => { new AddUniqueCall('public', 'note', 'note_kind_key', ['kind']), new AddCheckConstraintCall('public', 'note', 'note_kind_check', `"kind" IN ('draft')`), new DropCheckConstraintCall('public', 'note', 'note_kind_check'), - new RenameCheckConstraintCall( + new RenameConstraintCall( 'public', 'note', + 'checkConstraint', 'note_kind_check_0a1b2c3d', 'note_kind_check_1b2c3d4e', ), + new RenameConstraintCall('public', 'note', 'unique', 'note_kind_key', 'Note_kind_key'), new CreateIndexCall('public', 'note', 'note_kind_idx', { columns: ['kind'] }), new RenameIndexCall('public', 'note', 'note_kind_old_idx', 'note_kind_idx'), new DropIndexCall('public', 'note', 'note_kind_idx'), diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index a60d9d3d7e5e..66b2e5d496d9 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -236,7 +236,20 @@ export class SqliteMigrationPlanner // toOp + importRequirements ride directly through the same emit path // as structural ops, no `RawSqlCall` wrap. The table renames run first: // every later operation addresses the renamed table by its new name. - const calls = [...renameTableCalls, ...result.value.calls, ...fieldEventOps]; + // SQLite cannot rename an index and compares index names without case, so + // an index whose table-derived name changes only in case must be dropped + // before its replacement is created: index drops on a renamed table run + // right after the renames. + const renamedTables = new Set((applied?.value.renames ?? []).map((rename) => rename.to)); + const indexDropsOnRenamedTables = result.value.calls.filter( + (call) => call.factoryName === 'dropIndex' && renamedTables.has(call.tableName), + ); + const calls = [ + ...renameTableCalls, + ...indexDropsOnRenamedTables, + ...result.value.calls.filter((call) => !indexDropsOnRenamedTables.includes(call)), + ...fieldEventOps, + ]; const destination: SqliteMigrationDestinationInfo = { storageHash: options.contract.storage.storageHash, diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts new file mode 100644 index 000000000000..58da64c1d40e --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts @@ -0,0 +1,146 @@ +/** + * `--rename-table` on a SQLite table that carries indexes, constraints and foreign keys. + * + * SQLite renames a table's automatic indexes and rewrites foreign keys that reference it, and it names no primary key, unique or foreign key the contract leaves unnamed, so those need nothing beyond the rename. A wire-named index whose prefix derives from the table name keeps its old name through `ALTER TABLE ... RENAME TO`, and SQLite cannot rename an index, so it is dropped and created under the new name; an index holds no rows, so nothing is lost. + */ + +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + type ForeignKeyInput, + type IndexInput, + SqlStorage, + StorageTable, + type UniqueConstraintInput, +} from '@internal/sql-contract/types'; +import { computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; +import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; + +const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const integer = { nativeType: 'integer', codecId: 'sqlite/integer@1', nullable: false }; +const text = { nativeType: 'text', codecId: 'sqlite/text@1', nullable: false }; + +interface ProfileSpec { + readonly uniques?: readonly UniqueConstraintInput[]; + readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; + readonly indexes?: (tableName: string) => readonly IndexInput[]; +} + +function reference(tableName: string, columns: readonly string[]) { + return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; +} + +function contractOf( + profileTableName: string, + spec: ProfileSpec, + hashSeed: string, +): Contract { + return { + target: 'sqlite', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + [profileTableName]: new StorageTable({ + columns: { id: integer, email: text, handle: text, accountId: integer }, + primaryKey: { columns: ['id'] }, + uniques: spec.uniques ?? [], + indexes: spec.indexes?.(profileTableName) ?? [], + foreignKeys: spec.foreignKeys?.(profileTableName) ?? [], + }), + account: new StorageTable({ + columns: { id: integer }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + post: new StorageTable({ + columns: { id: integer, profileId: integer }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference('post', ['profileId']), + target: reference(profileTableName, ['id']), + }, + ], + }), + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +async function plannedLabels(spec: ProfileSpec): Promise { + const from = contractOf('userProfile', spec, 'from'); + const result = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: contractOf('UserProfile', spec, 'to'), + schema: sqliteContractToSchema(from), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + renames: [{ from: { name: 'userProfile' }, to: { name: 'UserProfile' } }], + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return []; + return (await Promise.all(result.plan.operations)).map((op) => op.label); +} + +const RENAME_TABLE = 'Rename table userProfile to UserProfile'; + +describe('SQLite planner rename-table intents with table objects', () => { + it('drops an index whose prefix derives from the table name before creating it under the new name', async () => { + const hash = computeIndexContentHash({ columns: ['handle'], unique: false }); + const handleIndex = (tableName: string): IndexInput => ({ + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }); + + expect(await plannedLabels({ indexes: (t) => [handleIndex(t)] })).toEqual([ + RENAME_TABLE, + `Drop index userProfile_handle_idx_${hash} on UserProfile`, + `Create index UserProfile_handle_idx_${hash} on UserProfile`, + ]); + }); + + it('plans the rename alone for an unnamed unique, an owned foreign key and a referencing foreign key', async () => { + expect( + await plannedLabels({ + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [ + { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, + ], + }), + ).toEqual([RENAME_TABLE]); + }); +}); diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index 6426954ef854..6532f8b68eac 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -358,9 +358,9 @@ On Postgres the operations are **methods on the `Migration` base class**, each t **Postgres** operations (representative set, all `this.({...})`): -- Tables: `createTable`, `dropTable`. +- Tables: `createTable`, `renameTable`, `dropTable`. - Columns: `addColumn` (`column: col(name, nativeType, { codecRef })`), `dropColumn`, `alterColumnType`, `setNotNull`, `dropNotNull`, `setDefault`, `dropDefault`. -- Constraints: `addPrimaryKey`, `addForeignKey`, `addUnique`, `addCheckConstraint`, `renameCheckConstraint`, `dropCheckConstraint`, `dropConstraint`. +- Constraints: `addPrimaryKey`, `addForeignKey`, `addUnique`, `addCheckConstraint`, `renameConstraint` (`kind`: `primaryKey`, `unique`, `foreignKey` or `checkConstraint`), `renameCheckConstraint`, `dropCheckConstraint`, `dropConstraint`. - Indexes: `createIndex`, `renameIndex`, `dropIndex`. - Enums: `createNativeEnumType`, `addNativeEnumValue`, `dropNativeEnumType`. - Row-level security: `enableRowLevelSecurity`, `disableRowLevelSecurity`, `createRlsPolicy`, `renameRlsPolicy`, `dropRlsPolicy`. From cc61637d2beb2590251fdfa2594327d6b9a0ca12 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:36:09 +0200 Subject: [PATCH 19/55] fix(sqlite): do not skip a table recreate that removes the last unique or foreign key A recreate postchecks that the table exists and that each expected unique constraint and foreign key exists. When a migration removes the last one there is none to check, so the postcheck already held, the runner skipped the recreate as applied, and the unique constraint or foreign key stayed. The rename journey found this when its follow-up migration removed both. The postchecks now also compare the number of unique constraints and of foreign keys with the expected spec. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/operations/tables.ts | 16 ++++++++ .../migrations/recreate-postchecks.test.ts | 39 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts index d631ad7b4236..4763e72a03a8 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts @@ -400,6 +400,17 @@ export function buildRecreatePostchecks( } } + // The checks above only prove expected uniques exist, so removing the last + // one would leave the postcheck already true and the runner would skip the + // recreate. `origin = 'u'` counts UNIQUE constraints, not CREATE INDEX. + if (hasUniqueIssue) { + const expected = spec.uniques?.length ?? 0; + checks.push({ + description: `verify "${tableName}" has exactly ${expected} unique constraints`, + sql: `SELECT (SELECT COUNT(*) FROM pragma_index_list('${t}') WHERE origin = 'u') = ${expected}`, + }); + } + if (hasFkIssue) { for (const fk of spec.foreignKeys ?? []) { const refTable = escapeLiteral(fk.references.table); @@ -424,6 +435,11 @@ export function buildRecreatePostchecks( ` AND SUM(CASE WHEN (f."from", f."to") IN (${tuples}) THEN 1 ELSE 0 END) = ${colCount})`, }); } + const expected = spec.foreignKeys?.length ?? 0; + checks.push({ + description: `verify "${tableName}" has exactly ${expected} foreign keys`, + sql: `SELECT (SELECT COUNT(DISTINCT id) FROM pragma_foreign_key_list('${t}')) = ${expected}`, + }); } return checks; diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts index 08b767c32b20..455ba668ac3a 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts @@ -205,7 +205,7 @@ describe('buildRecreatePostchecks — constraints', () => { }), ]; const checks = buildRecreatePostchecks('users', issues, spec); - const uniqueChecks = checks.filter((c) => c.description.includes('unique constraint')); + const uniqueChecks = checks.filter((c) => c.description.startsWith('verify unique constraint')); expect(uniqueChecks).toHaveLength(2); expect(uniqueChecks[0]!.sql).toContain("pragma_index_list('users')"); expect(uniqueChecks[0]!.sql).toContain('l."unique" = 1'); @@ -236,7 +236,7 @@ describe('buildRecreatePostchecks — constraints', () => { }), ]; const checks = buildRecreatePostchecks('posts', issues, spec); - const fkChecks = checks.filter((c) => c.description.includes('foreign key')); + const fkChecks = checks.filter((c) => c.description.startsWith('verify foreign key')); expect(fkChecks).toHaveLength(2); expect(fkChecks[0]!.sql).toContain("pragma_foreign_key_list('posts')"); @@ -248,6 +248,41 @@ describe('buildRecreatePostchecks — constraints', () => { expect(fkChecks[1]!.sql).toContain('HAVING COUNT(*) = 2'); }); + it('checks the unique constraint count, so removing the last unique is not already satisfied', () => { + const spec = tableSpec({ columns: [colSpec({ name: 'email' })], uniques: [] }); + const issues = [ + issue({ path: ['database', 'users', 'unique:email'], actual: unique(['email']) }), + ]; + + expect(buildRecreatePostchecks('users', issues, spec)).toEqual([ + { + description: 'verify "users" has exactly 0 unique constraints', + sql: `SELECT (SELECT COUNT(*) FROM pragma_index_list('users') WHERE origin = 'u') = 0`, + }, + ]); + }); + + it('checks the foreign key count, so removing the last foreign key is not already satisfied', () => { + const spec = tableSpec({ columns: [colSpec({ name: 'user_id' })], foreignKeys: [] }); + const issues = [ + issue({ + path: ['database', 'posts', 'foreign-key:user_id->.users(id)'], + actual: foreignKey({ + columns: ['user_id'], + referencedTable: 'users', + referencedColumns: ['id'], + }), + }), + ]; + + expect(buildRecreatePostchecks('posts', issues, spec)).toEqual([ + { + description: 'verify "posts" has exactly 0 foreign keys', + sql: `SELECT (SELECT COUNT(DISTINCT id) FROM pragma_foreign_key_list('posts')) = 0`, + }, + ]); + }); + it('does not emit constraint postchecks when only column-level issues are present', () => { const spec = tableSpec({ columns: [colSpec({ name: 'a' })], From eaabf06d53bf4e79957b2d93e029bd6ad7972e2e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:36:25 +0200 Subject: [PATCH 20/55] test: rename a table that has constraints, indexes and RLS, then change it again Journey R1 (Postgres) now renames a table with a unique constraint, a foreign key, an index, row-level security and a policy. It checks that the plan renames each constraint and index named after the old table, that migrate keeps the rows, the policy and RLS, that db verify --schema-only is clean, that a plan with no schema change is empty, and that a later migration removing the unique constraint, the foreign key and the index applies. Journey R3 (SQLite) does the same without RLS: the rename plus a drop and a create of each index named after the old table, then the empty plan and the removal migration. Journey R2 (migration new) keeps the plain table. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/cli-journeys/README.md | 4 +- .../rename-table-migration.e2e.test.ts | 107 ++++++++++++--- .../rename-table-migration.sqlite.e2e.test.ts | 122 +++++++++++++++--- ...ntract-rename-table-objects-dropped.prisma | 23 ++++ .../contract-rename-table-objects-from.prisma | 27 ++++ .../contract-rename-table-objects-to.prisma | 26 ++++ .../test/utils/journey-test-helpers.ts | 12 ++ 7 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-dropped.prisma create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-from.prisma create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-to.prisma diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 45dd24c15a16..4b5a704000cf 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -30,8 +30,8 @@ pnpm test:journeys | `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column, tests `--no-interactive` rejection, `--json` error envelope, and `--json -y` auto-accept. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | -| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile`, drop the model's `@@map`, confirm the bare plan is refused by the case-change guard and a stale `--rename-table` intent fails planning, then `migration plan --rename-table userProfile=UserProfile` plans exactly one `renameTable`, `migrate` applies it, the rows are present under `UserProfile`, and `db verify --schema-only` is clean. Second journey: the same rename through `migration new --rename-table`, whose scaffold carries the `renameTable` call and self-emits the SQL | -| `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the rename journey, driven through a file database and the `@internal/sqlite/config` facade config | +| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, drop the model's `@@map`, confirm the bare plan is refused by the case-change guard and a stale `--rename-table` intent fails planning, then `migration plan --rename-table userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. Second journey: the same rename through `migration new --rename-table`, whose scaffold carries the `renameTable` call and self-emits the SQL | +| `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts index 88f0d93adb31..afbb9a56d47a 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -1,19 +1,11 @@ /** * Renaming a table keeps its rows (Postgres). * - * A model whose table name changes used to plan as `dropTable` plus - * `createTable`. With `--rename-table =` the operator states the - * rename and the plan carries one `renameTable` operation instead. + * A model whose table name changes used to plan as `dropTable` plus `createTable`. With `--rename-table =` the operator states the rename and the plan carries a `renameTable` operation instead. * - * Journey R1 (`migration plan`): create `userProfile` with rows, drop the - * `@@map` so the model names `UserProfile`, and confirm that planning without - * the flag is refused by the case-change guard, that a stale intent is - * refused as a planning failure, and that the stated intent plans exactly one - * rename, applies, keeps the rows, and verifies clean. + * Journey R1 (`migration plan`): create `userProfile` with rows, a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map` so it names `UserProfile`. Planning without the flag is refused by the case-change guard and a stale intent fails planning. The stated intent plans the rename plus a rename of each constraint and index named after the old table; `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. * - * Journey R2 (`migration new`): the same rename authored by hand. The flag - * pre-fills the scaffolded `migration.ts` with the rename call; running the - * file self-emits the op, and `migrate` applies it. + * Journey R2 (`migration new`): the same rename authored by hand on a table with no other objects. The flag pre-fills the scaffolded `migration.ts` with the rename call; running the file self-emits the op, and `migrate` applies it. */ import { readFileSync } from 'node:fs'; @@ -90,14 +82,31 @@ withTempDir(({ createTempDir }) => { const db = useDevDatabase(); it( - 'guard refuses the bare plan; stale intent fails; stated intent plans one rename, keeps the rows, verifies clean', + 'guard refuses the bare plan; stale intent fails; stated intent renames the table and its named objects, keeps rows and policies, and later changes apply', async () => { const ctx = setupJourney({ connectionString: db.connectionString, createTempDir, contractMode: 'psl', }); - const origin = await seedRenamedTable(ctx, db.connectionString, 'R1'); + await sql(db.connectionString, 'CREATE ROLE app_user'); + swapPslContract(ctx, 'contract-rename-table-objects-from'); + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `R1.01: emit userProfile: ${emit.stderr}`).toBe(0); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `R1.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `R1.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + await sql( + db.connectionString, + `INSERT INTO "public"."Account" (id) VALUES (1); + INSERT INTO "public"."userProfile" (id, email, handle, tenant_id, "accountId") + VALUES (1, 'alice@example.com', 'alice', 1, 1), (2, 'bob@example.com', 'bob', 1, 1)`, + ); + swapPslContract(ctx, 'contract-rename-table-objects-to'); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `R1.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); + const origin = latestMigrationDirName(ctx); const bare = await runMigrationPlan(ctx, ['--name', 'bare', '--from', origin, '--json']); expect(bare.exitCode, 'R1.05: bare plan is refused').not.toBe(0); @@ -141,16 +150,82 @@ withTempDir(({ createTempDir }) => { expect(plan.exitCode, `R1.07: plan with intent: ${plan.stderr}`).toBe(0); const document = parseJsonOutput(plan); expect( - document.operations.map((op) => ({ id: op.id, operationClass: op.operationClass })), - 'R1.07: exactly one rename op', - ).toEqual([{ id: 'renameTable.userProfile', operationClass: 'widening' }]); + document.operations.map((op) => op.label), + 'R1.07: the rename, then a rename of each object named after the old table', + ).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + 'Rename index "userProfile_accountId_idx_cbfb3085" to "UserProfile_accountId_idx_cbfb3085" on "UserProfile"', + 'Rename index "userProfile_handle_idx_b5b249e4" to "UserProfile_handle_idx_b5b249e4" on "UserProfile"', + ]); + expect( + document.operations.every((op) => op.operationClass === 'widening'), + 'R1.07: every operation is a widening rename', + ).toBe(true); const apply = await runMigrate(ctx); expect(apply.exitCode, `R1.08: migrate: ${apply.stderr}`).toBe(0); await expectRowsSurvived(db.connectionString, 'R1.09'); + const live = await sql( + db.connectionString, + `SELECT + (SELECT array_agg(conname::text ORDER BY conname) FROM pg_constraint + WHERE conrelid = '"public"."UserProfile"'::regclass) AS constraints, + (SELECT array_agg(policyname::text) FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'UserProfile') AS policies, + (SELECT relrowsecurity FROM pg_class + WHERE oid = '"public"."UserProfile"'::regclass) AS rls`, + ); + expect(live.rows[0], 'R1.09: constraints renamed, policy and RLS kept').toEqual({ + constraints: ['UserProfile_accountId_fkey', 'UserProfile_email_key', 'UserProfile_pkey'], + policies: [expect.stringMatching(/^tenant_read_[0-9a-f]{8}$/)], + rls: true, + }); const verify = await runDbVerify(ctx, ['--schema-only']); expect(verify.exitCode, `R1.10: db verify --schema-only: ${verify.stderr}`).toBe(0); + + const fresh = await runMigrationPlan(ctx, [ + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(fresh.exitCode, `R1.11: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, 'R1.11: plan is empty').toBe(true); + expect(getMigrationDirs(ctx), 'R1.11: nothing written').toHaveLength(2); + + swapPslContract(ctx, 'contract-rename-table-objects-dropped'); + const emitDropped = await runContractEmit(ctx); + expect(emitDropped.exitCode, `R1.12: emit without objects: ${emitDropped.stderr}`).toBe(0); + const dropPlan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'drop-objects', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(dropPlan.exitCode, `R1.12: plan the removal: ${dropPlan.stderr}`).toBe(0); + const applyDrop = await runMigrate(ctx); + expect(applyDrop.exitCode, `R1.13: migrate the removal: ${applyDrop.stderr}`).toBe(0); + const remaining = await sql( + db.connectionString, + `SELECT + (SELECT array_agg(conname::text ORDER BY conname) FROM pg_constraint + WHERE conrelid = '"public"."UserProfile"'::regclass) AS constraints, + (SELECT array_agg(indexname::text) FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'UserProfile') AS indexes`, + ); + expect(remaining.rows[0], 'R1.13: unique, foreign key and indexes removed').toEqual({ + constraints: ['UserProfile_pkey'], + indexes: ['UserProfile_pkey'], + }); + const verifyDropped = await runDbVerify(ctx, ['--schema-only']); + expect( + verifyDropped.exitCode, + `R1.14: db verify --schema-only after the removal: ${verifyDropped.stderr}`, + ).toBe(0); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts index 593436d7936e..759bb9fbc365 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -1,7 +1,7 @@ /** * Renaming a table keeps its rows (SQLite). * - * The SQLite twin of `rename-table-migration.e2e.test.ts`: a file database driven through the SQLite facade config. Create `userProfile` with rows, drop the `@@map` so the model names `UserProfile`, and confirm that `migration plan --rename-table` plans exactly one rename, applies, keeps the rows, and verifies clean. + * The SQLite twin of `rename-table-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. Create `userProfile` with rows, a unique constraint, a foreign key and an index, then drop the `@@map` so the model names `UserProfile`. `migration plan --rename-table` plans the rename plus a drop and a create of each index named after the old table; `migrate` keeps the rows; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. */ import { readFileSync, writeFileSync } from 'node:fs'; @@ -26,19 +26,52 @@ import { const FROM_PSL = `// use prisma-8 +model Account { + id Int @id + profiles UserProfile[] +} + model UserProfile { - id Int @id - email String + id Int @id + email String @unique + handle String + accountId Int + account Account @relation(fields: [accountId], references: [id]) + @@index([handle]) @@map("userProfile") } `; const TO_PSL = `// use prisma-8 +model Account { + id Int @id + profiles UserProfile[] +} + model UserProfile { - id Int @id - email String + id Int @id + email String @unique + handle String + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@index([handle]) +} +`; + +const DROPPED_PSL = `// use prisma-8 + +model Account { + id Int @id +} + +model UserProfile { + id Int @id + email String + handle String + accountId Int } `; @@ -69,7 +102,7 @@ function withDatabase(dbPath: string, run: (db: DatabaseSync) => T): T { withTempDir(({ createTempDir }) => { describe('Journey R3 (SQLite): rename a table with migration plan --rename-table', () => { it( - 'stale intent fails; stated intent plans one rename, keeps the rows, verifies clean', + 'stale intent fails; stated intent renames the table and recreates its named indexes, keeps the rows, and later changes apply', async () => { const ctx = setupSqliteJourney(createTempDir); @@ -82,7 +115,9 @@ withTempDir(({ createTempDir }) => { const origin = latestMigrationDirName(ctx); withDatabase(ctx.dbPath, (db) => { db.exec( - `INSERT INTO "userProfile" (id, email) VALUES (1, 'alice@example.com'), (2, 'bob@example.com')`, + `INSERT INTO "Account" (id) VALUES (1); + INSERT INTO "userProfile" (id, email, handle, "accountId") + VALUES (1, 'alice@example.com', 'alice', 1), (2, 'bob@example.com', 'bob', 1)`, ); }); @@ -123,19 +158,26 @@ withTempDir(({ createTempDir }) => { expect(plan.exitCode, `R3.07: plan with intent: ${plan.stderr}`).toBe(0); const document = parseJsonOutput(plan); expect( - document.operations.map((op) => ({ id: op.id, operationClass: op.operationClass })), - 'R3.07: exactly one rename op', - ).toEqual([{ id: 'renameTable.userProfile', operationClass: 'widening' }]); - const opsJson = readFileSync( - join(ctx.testDir, 'migrations', 'app', getMigrationDirs(ctx).at(-1)!, 'ops.json'), - 'utf-8', - ); - expect(opsJson, 'R3.07: rendered SQL').toContain('RENAME TO \\"UserProfile\\"'); + document.operations.map((op) => op.label), + 'R3.07: the rename, then each index named after the old table dropped and recreated', + ).toEqual([ + 'Rename table userProfile to UserProfile', + 'Drop index userProfile_accountId_idx_cbfb3085 on UserProfile', + 'Drop index userProfile_handle_idx_b5b249e4 on UserProfile', + 'Create index UserProfile_accountId_idx_cbfb3085 on UserProfile', + 'Create index UserProfile_handle_idx_b5b249e4 on UserProfile', + ]); const apply = await runMigrate(ctx); expect(apply.exitCode, `R3.08: migrate: ${apply.stderr}`).toBe(0); const state = withDatabase(ctx.dbPath, (db) => ({ rows: db.prepare(`SELECT id, email FROM "UserProfile" ORDER BY id`).all(), + objects: db + .prepare( + `SELECT type, name FROM sqlite_master WHERE tbl_name = 'UserProfile' ORDER BY type, name`, + ) + .all() + .map((row) => `${row['type']} ${row['name']}`), tables: db .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%serProfile'`, @@ -148,9 +190,59 @@ withTempDir(({ createTempDir }) => { { id: 2, email: 'bob@example.com' }, ]); expect(state.tables, 'R3.09: old name is gone').toEqual(['UserProfile']); + expect(state.objects, 'R3.09: every index follows the new name').toEqual([ + 'index UserProfile_accountId_idx_cbfb3085', + 'index UserProfile_handle_idx_b5b249e4', + 'index sqlite_autoindex_UserProfile_1', + 'table UserProfile', + ]); const verify = await runDbVerify(ctx, ['--schema-only']); expect(verify.exitCode, `R3.10: db verify --schema-only: ${verify.stderr}`).toBe(0); + + const fresh = await runMigrationPlan(ctx, [ + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(fresh.exitCode, `R3.11: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, 'R3.11: plan is empty').toBe(true); + expect(getMigrationDirs(ctx), 'R3.11: nothing written').toHaveLength(2); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), DROPPED_PSL, 'utf-8'); + const emitDropped = await runContractEmit(ctx); + expect(emitDropped.exitCode, `R3.12: emit without objects: ${emitDropped.stderr}`).toBe(0); + const dropPlan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'drop-objects', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(dropPlan.exitCode, `R3.12: plan the removal: ${dropPlan.stderr}`).toBe(0); + const applyDrop = await runMigrate(ctx); + expect(applyDrop.exitCode, `R3.13: migrate the removal: ${applyDrop.stderr}`).toBe(0); + const remaining = withDatabase(ctx.dbPath, (db) => ({ + rows: db.prepare(`SELECT id, email FROM "UserProfile" ORDER BY id`).all(), + objects: db + .prepare( + `SELECT type, name FROM sqlite_master WHERE tbl_name = 'UserProfile' ORDER BY type, name`, + ) + .all() + .map((row) => `${row['type']} ${row['name']}`), + })); + expect(remaining, 'R3.13: unique, foreign key and indexes removed, rows kept').toEqual({ + rows: [ + { id: 1, email: 'alice@example.com' }, + { id: 2, email: 'bob@example.com' }, + ], + objects: ['table UserProfile'], + }); + const verifyDropped = await runDbVerify(ctx, ['--schema-only']); + expect( + verifyDropped.exitCode, + `R3.14: db verify --schema-only after the removal: ${verifyDropped.stderr}`, + ).toBe(0); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-dropped.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-dropped.prisma new file mode 100644 index 000000000000..77744be0c8d4 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-dropped.prisma @@ -0,0 +1,23 @@ +// use prisma-8 + +namespace public { + model Account { + id Int @id + } + + model UserProfile { + id Int @id + email String + handle String + tenantId Int @map("tenant_id") + accountId Int + + @@rls + } + + policy_select tenant_read { + target = UserProfile + roles = [app_user] + using = "(tenant_id = 1)" + } +} diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-from.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-from.prisma new file mode 100644 index 000000000000..e856d3dcf937 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-from.prisma @@ -0,0 +1,27 @@ +// use prisma-8 + +namespace public { + model Account { + id Int @id + profiles UserProfile[] + } + + model UserProfile { + id Int @id + email String @unique + handle String + tenantId Int @map("tenant_id") + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@index([handle]) + @@map("userProfile") + @@rls + } + + policy_select tenant_read { + target = UserProfile + roles = [app_user] + using = "(tenant_id = 1)" + } +} diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-to.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-to.prisma new file mode 100644 index 000000000000..3cca24a432e6 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-to.prisma @@ -0,0 +1,26 @@ +// use prisma-8 + +namespace public { + model Account { + id Int @id + profiles UserProfile[] + } + + model UserProfile { + id Int @id + email String @unique + handle String + tenantId Int @map("tenant_id") + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@index([handle]) + @@rls + } + + policy_select tenant_read { + target = UserProfile + roles = [app_user] + using = "(tenant_id = 1)" + } +} diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index b2ea7b5e53ff..86b92bee7504 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -220,6 +220,18 @@ export const pslContractFixtures = { 'contract-rls-wire': join(JOURNEY_FIXTURES_DIR, 'contract-rls-wire.prisma'), 'contract-rename-table-from': join(JOURNEY_FIXTURES_DIR, 'contract-rename-table-from.prisma'), 'contract-rename-table-to': join(JOURNEY_FIXTURES_DIR, 'contract-rename-table-to.prisma'), + 'contract-rename-table-objects-from': join( + JOURNEY_FIXTURES_DIR, + 'contract-rename-table-objects-from.prisma', + ), + 'contract-rename-table-objects-to': join( + JOURNEY_FIXTURES_DIR, + 'contract-rename-table-objects-to.prisma', + ), + 'contract-rename-table-objects-dropped': join( + JOURNEY_FIXTURES_DIR, + 'contract-rename-table-objects-dropped.prisma', + ), } as const; export type PslContractVariant = keyof typeof pslContractFixtures; From 285f57f8ae6ae2f0e6638495bb621f9d10af4e8f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 12:57:39 +0200 Subject: [PATCH 21/55] docs(upgrade): record the RenameCheckConstraintCall rename for extension authors RenameCheckConstraintCall, exported from @prisma/orm-postgres/target/op-factory-call, became RenameConstraintCall with a constraint kind argument. This pending extension fragment tells extension authors how to update references. Its detection predicate matches the old class name as a whole identifier. No example, extension or committed migration file uses the old name. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../extension/instructions.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 upgrade-instructions/pending/rename-constraint-call/extension/instructions.md diff --git a/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md b/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md new file mode 100644 index 000000000000..ea0e70b7c3fd --- /dev/null +++ b/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md @@ -0,0 +1,19 @@ +--- +changes: + - id: rename-check-constraint-call-is-rename-constraint-call + summary: Replace `RenameCheckConstraintCall` with `RenameConstraintCall`, which takes the constraint kind as a new third constructor argument; pass `'checkConstraint'` for a check constraint. + detection: + glob: "**/*.{ts,mts,cts,js,mjs,cjs}" + matches: + - '(? Date: Thu, 17 Sep 2026 13:07:33 +0200 Subject: [PATCH 22/55] fix(migration): scaffold the same renames migration plan plans in migration new migration new --rename-table scaffolded only the table rename. The constraints and indexes named after the old table kept their old names, so the next migrate failed schema verification, and a later migration touching them would fail too. migration new now passes the stated renames to the planner with the contract the migration starts from, the contract it ends at, and the framework components. Each SQL planner splits its rename phase into planTableRenames, which both plan and emptyMigration call: - Postgres: the table rename and the companion constraint renames, then the existing index and check hash pairing, kept for the renamed tables. - SQLite: the table rename, then the new pairRenamedTableIndexes module (drop the old index, create the new one). The planner now uses the same module, replacing the reordering of index drops. The scaffold carries only those operations; other differences between the contracts stay with the author. Without migration history the scaffold refuses with MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT, and an unmatched intent with MIGRATION.TABLE_RENAME_UNMATCHED, the same refusals migration plan reports. Mongo reads the new shape and still refuses. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 4 +- .../src/control/control-migration-types.ts | 39 +++- .../src/exports/control.ts | 1 + .../control-api/operations/migration-new.ts | 29 ++- .../cli/test/orm/fixtures/offline-project.ts | 4 +- .../test/orm/migration-new-renames.test.ts | 90 ++++++++ packages/2-sql/9-family/src/core/errors.ts | 4 +- .../core/migrations/table-rename-intents.ts | 21 +- .../2-sql/9-family/src/exports/control.ts | 2 +- .../1-mongo-target/src/core/mongo-planner.ts | 4 +- .../test/mongo-planner.renames.test.ts | 14 +- .../postgres/src/core/migrations/planner.ts | 216 ++++++++++++------ .../test/migrations/rename-table-fixtures.ts | 104 +++++++++ ...rename-table-planner.table-objects.test.ts | 114 ++------- .../migrations/rename-table-scaffold.test.ts | 179 ++++++++++++--- .../3-targets/sqlite/src/core/errors.ts | 1 - .../sqlite/src/core/migrations/planner.ts | 181 ++++++++++----- .../core/migrations/renamed-table-indexes.ts | 66 ++++++ .../test/migrations/rename-table-fixtures.ts | 99 ++++++++ ...rename-table-planner.table-objects.test.ts | 108 +-------- .../migrations/rename-table-scaffold.test.ts | 130 +++++++++-- .../contract-rename-table-from.prisma | 8 - .../contract-rename-table-to.prisma | 6 - 23 files changed, 993 insertions(+), 431 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts create mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/renamed-table-indexes.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts delete mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-from.prisma delete mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-to.prisma diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 22644e95ad88..7a5ef9a6092b 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1312,11 +1312,11 @@ The planner would drop table `X` and create table `Y` in the same namespace, whe ### MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT -A `--rename-table =` intent was given to `migration plan`, but the plan has no previous contract to rename the table in: there is no migration history, so the plan starts from an empty database. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`, one per intent. Plan from the migration that created the table. A database managed with `db update` has no migration history, so rename the table there by hand with `ALTER TABLE "X" RENAME TO "Y"` and drop the flag. Payload: none. +A `--rename-table =` intent was given to `migration plan` or `migration new`, but there is no previous contract to rename the table in: there is no migration history, so the migration starts from an empty database. Nothing is planned or written. `migration plan` reports it as a conflict inside `MIGRATION.PLANNING_FAILED`, one per intent; `migration new` raises it directly. Plan from the migration that created the table. A database managed with `db update` has no migration history, so rename the table there by hand with `ALTER TABLE "X" RENAME TO "Y"` and drop the flag. Payload: none. ### MIGRATION.TABLE_RENAME_UNMATCHED -A `--rename-table =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, the two sides name different namespaces, or an earlier intent on the same command already resolved to the same old table or the same new table. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. `migration new` on SQLite raises it directly when either side carries a namespace qualifier, since SQLite has no namespaces; nothing is written. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. +A `--rename-table =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, the two sides name different namespaces, or an earlier intent on the same command already resolved to the same old table or the same new table. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. `migration new --rename-table` raises it directly for the same mismatches, including a namespace qualifier on SQLite, which has no namespaces; nothing is written. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. ### MIGRATION.TARGET_MISMATCH diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index c69bc14222c0..28c07e58d04a 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -522,18 +522,21 @@ export interface MigrationPlanner< * Produce an empty migration with the target's authoring conventions. * * Used by `migration new` to scaffold a fresh `migration.ts`. The - * returned plan carries only the operations `context.renames` asks for - * (none by default); its `renderTypeScript()` yields a stub the user can - * edit. + * returned plan carries no operations unless `context.renames` states + * renames; its `renderTypeScript()` yields a stub the user can edit. + * + * With renames, the plan carries the same rename operations `plan` would + * plan for them between `context.renames.fromContract` and + * `context.renames.toContract`, and nothing else. A rename that `plan` + * would refuse, and any rename on a planner that cannot rename, throws a + * structured error. * * `spaceId` is stamped onto the produced plan; reconciliation flows * (`db init`, `db update`) and authoring flows (`migration new`) all * pass it explicitly. - * - * A planner that cannot rename throws a structured error when `context.renames` is not empty. */ emptyMigration( - context: MigrationScaffoldContext, + context: MigrationScaffoldContext, spaceId: string, ): MigrationPlanWithAuthoringSurface; } @@ -667,7 +670,23 @@ export interface TargetMigrationsCapability< * (e.g. the contract `.d.ts` import for typed-contract builders). Passed to * `MigrationPlanner.emptyMigration(context)`. */ -export interface MigrationScaffoldContext { +/** + * Operator-stated renames for a scaffold, with the two contracts a planner resolves them against. `fromContract` is `null` when the migration has no previous contract. + */ +export interface MigrationScaffoldRenames< + TFamilyId extends string = string, + TTargetId extends string = string, +> { + readonly intents: readonly StorageEntityRename[]; + readonly fromContract: Contract | null; + readonly toContract: Contract; + readonly frameworkComponents: ReadonlyArray>; +} + +export interface MigrationScaffoldContext< + TFamilyId extends string = string, + TTargetId extends string = string, +> { /** Absolute path to the migration package directory. Used by targets to compute relative imports. */ readonly packageDir: string; /** Absolute path to the contract.json file, if one exists. Used by targets that emit typed-contract imports. */ @@ -693,9 +712,7 @@ export interface MigrationScaffoldContext { */ readonly snapshotsImportPath: string; /** - * Operator-stated renames the scaffold starts with: one rename operation - * per entry, in the order given, so a hand-authored migration that only - * renames needs no editing. Absent or empty scaffolds an empty body. + * Operator-stated renames the scaffold starts with, so a hand-authored migration that only renames needs no editing. Absent scaffolds an empty body. */ - readonly renames?: readonly StorageEntityRename[]; + readonly renames?: MigrationScaffoldRenames; } diff --git a/packages/1-framework/1-core/framework-components/src/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts index 71759b8be5e8..d6b9fb5ace15 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/control.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/control.ts @@ -55,6 +55,7 @@ export type { MigrationRunnerResult, MigrationRunnerSuccessValue, MigrationScaffoldContext, + MigrationScaffoldRenames, OpFactoryCall, SchemaEntityCoordinate, SchemaOwnership, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index 068e63b3648f..faa5c5367dba 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -51,9 +51,7 @@ export interface MigrationNewOptions { readonly name?: string; readonly from?: string; /** - * Operator-stated renames (`--rename-table`): the scaffold starts with one - * rename call per entry and its manifest is attested over those ops, so a - * migration that only renames is complete as written. + * Operator-stated renames (`--rename-table`): the scaffold starts with the rename operations `migration plan` would plan for them, and its manifest is attested over those ops, so a migration that only renames is complete as written. */ readonly renames?: readonly StorageEntityRename[]; /** Renders the declarations of the destination snapshot from its `contract.json`. */ @@ -187,11 +185,11 @@ export async function executeMigrationNewCommand( } try { - assertFrameworkComponentsCompatible(config.family.familyId, config.target.targetId, [ - config.target, - config.adapter, - ...(config.extensions ?? []), - ]); + const frameworkComponents = assertFrameworkComponentsCompatible( + config.family.familyId, + config.target.targetId, + [config.target, config.adapter, ...(config.extensions ?? [])], + ); // Before any write: an unreadable or contradictory project manifest fails // the command outright rather than after a half-scaffolded migration @@ -207,6 +205,19 @@ export async function executeMigrationNewCommand( return notOk(declarations.failure); } + // The planner resolves stated renames against the contract the migration + // starts from and the one it ends at, so the scaffold carries the same + // rename operations `migration plan` would plan. + const renames = + options.renames === undefined || options.renames.length === 0 + ? undefined + : { + intents: options.renames, + fromContract: + fromHash === null ? null : (await aggregate.app.contractAt(fromHash)).contract, + toContract, + frameworkComponents, + }; const planner = migrations.createPlanner(controlAdapter); const scaffold = planner.emptyMigration( { @@ -215,7 +226,7 @@ export async function executeMigrationNewCommand( fromHash, toHash: toStorageHash, snapshotsImportPath: snapshotsImportPathFrom(packageDir, migrationsDir), - ...ifDefined('renames', options.renames), + ...ifDefined('renames', renames), }, APP_SPACE_ID, ); diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts index 661161e8bed3..0f503be97b35 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts @@ -145,6 +145,7 @@ export interface FakePlannerScript { readonly conflicts?: ReadonlyArray<{ readonly kind: string; readonly summary: string }>; readonly throwOnOperations?: unknown; readonly throwOnEmptyMigration?: unknown; + readonly onEmptyMigration?: (context: unknown) => void; } function fakePlanner(script: FakePlannerScript): Record { @@ -162,7 +163,8 @@ function fakePlanner(script: FakePlannerScript): Record { }, } : { kind: 'failure', conflicts: script.conflicts }, - emptyMigration: () => { + emptyMigration: (context: unknown) => { + script.onEmptyMigration?.(context); if (script.throwOnEmptyMigration !== undefined) throw script.throwOnEmptyMigration; return { operations: [], renderTypeScript: () => '// empty migration\n' }; }, diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts new file mode 100644 index 000000000000..fe16368110f4 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts @@ -0,0 +1,90 @@ +/** + * `migration new --rename-table` hands the target planner the stated renames together with the contract the migration starts from and the one it ends at, so the planner can scaffold the same rename operations `migration plan` would plan. + */ + +import { createTestCli } from '@prisma/cli-engine/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BIN_GROUPS } from '../../src/orm/cli'; +import { + contractJson, + createOfflineProject, + OFFLINE_COMMANDS, + type OfflineProject, + offlineConfig, + removeOfflineProjects, + resetRenderContractDtsMock, + seedContractSnapshot, + seedMigrationPackage, +} from './fixtures/offline-project'; + +const HASH_TO = `c0ffee${'0'.repeat(58)}`; +const HASH_FROM = `beef${'1'.repeat(60)}`; +const INTENT = { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }; + +beforeEach(resetRenderContractDtsMock); +afterEach(removeOfflineProjects); + +async function scaffoldContext( + project: OfflineProject, + args: readonly string[], +): Promise | undefined> { + let received: unknown; + const run = await createTestCli({ + commands: OFFLINE_COMMANDS, + groups: BIN_GROUPS, + config: { + orm: offlineConfig({ + project, + script: { + onEmptyMigration: (context) => { + received = context; + }, + }, + }), + }, + }).run(['migration', 'new', '--json', ...args], { cwd: project.dir }); + expect(run.exitCode).toBe(0); + return typeof received === 'object' && received !== null ? { ...received } : undefined; +} + +describe('migration new --rename-table', () => { + it('passes the renames with the previous and the next contract', async () => { + const project = await createOfflineProject({ storageHash: HASH_TO }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_FROM, + }); + await seedContractSnapshot({ migrationsDir: project.migrationsDir, storageHash: HASH_FROM }); + + const context = await scaffoldContext(project, ['--rename-table', 'userProfile=UserProfile']); + + expect(context?.['renames']).toMatchObject({ + intents: [INTENT], + fromContract: contractJson(HASH_FROM), + toContract: contractJson(HASH_TO), + frameworkComponents: expect.any(Array), + }); + }); + + it('passes no previous contract when the project has no migration history', async () => { + const project = await createOfflineProject({ storageHash: HASH_TO }); + + const context = await scaffoldContext(project, ['--rename-table', 'userProfile=UserProfile']); + + expect(context?.['renames']).toMatchObject({ + intents: [INTENT], + fromContract: null, + toContract: contractJson(HASH_TO), + }); + }); + + it('passes no renames when the flag is absent', async () => { + const project = await createOfflineProject({ storageHash: HASH_TO }); + + const context = await scaffoldContext(project, []); + + expect(context).not.toHaveProperty('renames'); + }); +}); diff --git a/packages/2-sql/9-family/src/core/errors.ts b/packages/2-sql/9-family/src/core/errors.ts index 5e8cdd081772..b1822190dfe8 100644 --- a/packages/2-sql/9-family/src/core/errors.ts +++ b/packages/2-sql/9-family/src/core/errors.ts @@ -8,7 +8,9 @@ type SqlFamilyErrorCode = | 'CONTRACT.PACK_CONTRIBUTION_INVALID' | 'CONTRACT.TABLE_AMBIGUOUS' | 'CONTRACT.TYPE_UNKNOWN' - | 'MIGRATION.MARKER_CAS_FAILURE'; + | 'MIGRATION.MARKER_CAS_FAILURE' + | 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT' + | 'MIGRATION.TABLE_RENAME_UNMATCHED'; export function sqlFamilyError( code: SqlFamilyErrorCode, diff --git a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts index 8d82193d55c4..6a4c234f2ca5 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts @@ -12,13 +12,32 @@ import { SqlStorage, StorageTable, } from '@internal/sql-contract/types'; +import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; +import type { StructuredError } from '@internal/utils/structured-error'; +import { sqlFamilyError } from '../errors'; import type { SqlPlannerConflict } from './types'; export const TABLE_RENAME_UNMATCHED_CODE = 'MIGRATION.TABLE_RENAME_UNMATCHED'; export const TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE = 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT'; +/** + * The error `migration new` raises for rename intents a planner refused. It carries the code the refusals carry as conflicts of `migration plan`. + */ +export function tableRenameScaffoldError( + conflicts: readonly SqlPlannerConflict[], +): StructuredError { + const noPreviousContract = conflicts.every( + (conflict) => conflict.meta?.['code'] === TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE, + ); + return sqlFamilyError( + noPreviousContract ? TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE : TABLE_RENAME_UNMATCHED_CODE, + conflicts.map((conflict) => conflict.summary).join('\n'), + ifDefined('why', conflicts[0]?.why), + ); +} + /** A `--rename-table =` intent as the CLI parsed it. */ export type TableRenameIntent = StorageEntityRename; @@ -61,7 +80,7 @@ function coordinateLabel(coordinate: StorageEntityRename['from']): string { : `${coordinate.namespaceId}.${coordinate.name}`; } -export function tableRenameIntentLabel(intent: TableRenameIntent): string { +function tableRenameIntentLabel(intent: TableRenameIntent): string { return `${coordinateLabel(intent.from)}=${coordinateLabel(intent.to)}`; } diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index c7bf83e89809..0c32c6427ce1 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -68,7 +68,7 @@ export type { export { applyTableRenameIntents, TABLE_RENAME_UNMATCHED_CODE, - tableRenameIntentLabel, + tableRenameScaffoldError, } from '../core/migrations/table-rename-intents'; export type { CodecControlHooks, diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts index e634ada6dccf..574e1721d674 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts @@ -360,10 +360,10 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> * not import from the generated contract `.d.ts`. */ emptyMigration(context: MigrationScaffoldContext): MigrationPlanWithAuthoringSurface { - if (context.renames !== undefined && context.renames.length > 0) { + if (context.renames !== undefined && context.renames.intents.length > 0) { throw mongoTargetError( RENAME_UNSUPPORTED_CODE, - `MongoDB does not support stated renames (${renamesLabel(context.renames)}), so nothing was scaffolded.`, + `MongoDB does not support stated renames (${renamesLabel(context.renames.intents)}), so nothing was scaffolded.`, { why: renamesUnsupportedWhy('scaffold') }, ); } diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts index 59b70eb8ecca..075a2b012ec1 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts @@ -70,7 +70,12 @@ describe('MongoMigrationPlanner stated renames', () => { fromHash: '00', toHash: '01', snapshotsImportPath: '../../snapshots', - renames: RENAMES, + renames: { + intents: RENAMES, + fromContract: contractWith('userProfile'), + toContract: contractWith('UserProfile'), + frameworkComponents: [], + }, }), ).toThrow( expect.objectContaining({ @@ -97,7 +102,12 @@ describe('MongoMigrationPlanner stated renames', () => { fromHash: '00', toHash: '01', snapshotsImportPath: '../../snapshots', - renames: [], + renames: { + intents: [], + fromContract: contractWith('users'), + toContract: contractWith('users'), + frameworkComponents: [], + }, }); expect(result.kind).toBe('success'); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 09066b05af02..193863f04916 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -15,6 +15,7 @@ import { partitionIssuesByControlPolicy, planFieldEventOperations, plannerFailure, + tableRenameScaffoldError, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; @@ -23,6 +24,7 @@ import type { MigrationPlanner, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, + MigrationScaffoldRenames, SchemaDiffIssue, SchemaOwnership, StorageEntityRename, @@ -36,6 +38,7 @@ import { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types' import { assertDefined } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; +import { notOk, ok, type Result } from '@internal/utils/result'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; import { postgresNodeStorageCoordinate } from '../schema-ir/node-storage-coordinate'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; @@ -84,6 +87,16 @@ type PlannerOptionsWithComponents = SqlMigrationPlannerPlanOptions & { readonly frameworkComponents: PlannerFrameworkComponents; }; +interface PlannedTableRenames { + readonly fromContract: Contract | null; + readonly previousSchema: SqlMigrationPlannerPlanOptions['schema']; + readonly calls: readonly PostgresOpFactoryCall[]; +} + +const SCAFFOLD_POLICY: MigrationOperationPolicy = { + allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'], +}; + export function createPostgresMigrationPlanner( lowerer: ExecuteRequestLowerer, ): PostgresMigrationPlanner { @@ -175,23 +188,13 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr } emptyMigration( - context: MigrationScaffoldContext, + context: MigrationScaffoldContext<'sql', 'postgres'>, spaceId: string, ): MigrationPlanWithAuthoringSurface { - // A scaffold has no contract to resolve a namespace against, so the - // qualifier on either side names the DDL schema directly, and an intent - // with no qualifier stays unbound, like every other unqualified authored - // statement. The flag parser has already refused two different qualifiers. - const renameCalls = (context.renames ?? []).map( - (rename) => - new RenameTableCall( - rename.from.namespaceId ?? rename.to.namespaceId ?? UNBOUND_NAMESPACE_ID, - rename.from.name, - rename.to.name, - ), - ); return new TypeScriptRenderablePostgresMigration( - renameCalls, + context.renames === undefined + ? [] + : this.scaffoldTableRenames(context, context.renames, spaceId), { from: context.fromHash, to: context.toHash, @@ -202,6 +205,118 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr ); } + /** + * The rename operations `planSql` plans for the stated renames, and nothing else: the table and constraint renames, then the index and check renames on the renamed tables. Every other difference between the two contracts is left to the author. + */ + private scaffoldTableRenames( + context: MigrationScaffoldContext<'sql', 'postgres'>, + renames: MigrationScaffoldRenames<'sql', 'postgres'>, + spaceId: string, + ): readonly PostgresOpFactoryCall[] { + const contract = blindCast< + Contract, + 'the family resolver only binds the Postgres planner for a SQL contract' + >(renames.toContract); + const fromContract = blindCast< + Contract | null, + 'the family resolver only binds the Postgres planner for a SQL contract' + >(renames.fromContract); + const options: SqlMigrationPlannerPlanOptions = { + contract, + fromContract, + schema: postgresContractToSchema(fromContract, renames.frameworkComponents), + policy: SCAFFOLD_POLICY, + frameworkComponents: renames.frameworkComponents, + spaceId, + snapshotsImportPath: context.snapshotsImportPath, + renames: renames.intents, + }; + const planned = this.planTableRenames(options); + if (!planned.ok) { + throw tableRenameScaffoldError(planned.failure); + } + PostgresDatabaseSchemaNode.assert(planned.value.previousSchema); + const { issues } = buildPostgresPlanDiff({ + contract, + actualSchema: planned.value.previousSchema, + frameworkComponents: renames.frameworkComponents, + }); + const relationalIssues = issues.filter((issue) => !isPolicyDiffIssue(issue)); + const renamedTables = planned.value.calls.filter( + (call): call is RenameTableCall => call.factoryName === 'renameTable', + ); + const onRenamedTable = (call: { readonly schemaName: string; readonly tableName: string }) => + renamedTables.some( + (renamed) => renamed.schemaName === call.schemaName && renamed.tableName === call.tableName, + ); + return [ + ...planned.value.calls, + ...this.pairIndexRenames(options, relationalIssues).calls.filter(onRenamedTable), + ...this.pairCheckRenames(options, relationalIssues).calls.filter(onRenamedTable), + ]; + } + + /** + * Applies the stated renames to the previous contract and plans the table and constraint renames they need. Without renames it hands back the previous contract and schema unchanged and no calls. + */ + private planTableRenames( + options: SqlMigrationPlannerPlanOptions, + ): Result { + const intents = options.renames ?? []; + if (intents.length === 0) { + return ok({ fromContract: options.fromContract, previousSchema: options.schema, calls: [] }); + } + const applied = applyTableRenameIntents({ + fromContract: options.fromContract, + toContract: options.contract, + intents, + renameTableReferences: renameRlsReferences, + }); + if (!applied.ok) { + return notOk(applied.failure); + } + const calls = applied.value.renames.flatMap((rename): PostgresOpFactoryCall[] => { + const schemaName = + rename.namespaceId === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : resolveDdlSchemaForNamespaceStorage(options.contract.storage, rename.namespaceId); + const previous = + options.fromContract?.storage.namespaces[rename.namespaceId]?.entries.table?.[rename.from]; + const next = + options.contract.storage.namespaces[rename.namespaceId]?.entries.table?.[rename.to]; + assertDefined( + previous, + `a resolved rename names table "${rename.from}" of the previous contract`, + ); + assertDefined(next, `a resolved rename names table "${rename.to}" of the next contract`); + return [ + new RenameTableCall(schemaName, rename.from, rename.to), + ...constraintRenamesForTableRename({ + schemaName, + from: rename.from, + to: rename.to, + previous, + next, + }), + ]; + }); + const disallowed = calls.filter( + (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), + ); + if (disallowed.length > 0) { + return notOk( + disallowed.map((call) => + conflictForDisallowedCall(call, options.policy.allowedOperationClasses), + ), + ); + } + return ok({ + fromContract: applied.value.contract, + previousSchema: postgresContractToSchema(applied.value.contract, options.frameworkComponents), + calls, + }); + } + private planSql(options: SqlMigrationPlannerPlanOptions): PostgresPlanResult { const schemaName = options.schemaName ?? @@ -215,67 +330,16 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // Operator-stated renames are applied to the previous contract before // the diff, and the "from" tree is re-derived from that renamed contract, // so the differ sees each renamed table under its new name and plans its - // column, index and constraint changes normally. The renames themselves - // become the first operations of the plan. An intent that matches - // neither side, or a rename the policy's classes do not admit, fails the - // plan here rather than degrading to a drop and a create. - const intents = options.renames ?? []; - const applied = - intents.length === 0 - ? undefined - : applyTableRenameIntents({ - fromContract: options.fromContract, - toContract: options.contract, - intents, - renameTableReferences: renameRlsReferences, - }); - if (applied !== undefined && !applied.ok) { - return plannerFailure(applied.failure); - } - const fromContract = applied === undefined ? options.fromContract : applied.value.contract; - const previousSchema = - applied === undefined - ? options.schema - : postgresContractToSchema(applied.value.contract, options.frameworkComponents); - const renameTableCalls = (applied?.value.renames ?? []).flatMap( - (rename): PostgresOpFactoryCall[] => { - const schemaName = - rename.namespaceId === UNBOUND_NAMESPACE_ID - ? UNBOUND_NAMESPACE_ID - : resolveDdlSchemaForNamespaceStorage(options.contract.storage, rename.namespaceId); - const previous = - options.fromContract?.storage.namespaces[rename.namespaceId]?.entries.table?.[ - rename.from - ]; - const next = - options.contract.storage.namespaces[rename.namespaceId]?.entries.table?.[rename.to]; - assertDefined( - previous, - `a resolved rename names table "${rename.from}" of the previous contract`, - ); - assertDefined(next, `a resolved rename names table "${rename.to}" of the next contract`); - return [ - new RenameTableCall(schemaName, rename.from, rename.to), - ...constraintRenamesForTableRename({ - schemaName, - from: rename.from, - to: rename.to, - previous, - next, - }), - ]; - }, - ); - const disallowedRenames = renameTableCalls.filter( - (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), - ); - if (disallowedRenames.length > 0) { - return plannerFailure( - disallowedRenames.map((call) => - conflictForDisallowedCall(call, options.policy.allowedOperationClasses), - ), - ); + // column, index and constraint changes normally. The table and + // constraint renames become the first operations of the plan. An intent + // that matches neither side, or a rename the policy's classes do not + // admit, fails the plan here rather than degrading to a drop and a create. + const tableRenames = this.planTableRenames(options); + if (!tableRenames.ok) { + return plannerFailure(tableRenames.failure); } + const { fromContract, previousSchema } = tableRenames.value; + const renameTableCalls = tableRenames.value.calls; // The one combined tree diff drives the whole plan: relational findings // become structural DDL via `planIssues`, policy findings become RLS ops @@ -529,7 +593,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * additive half, like the policy pass. */ private pairIndexRenames( - options: PlannerOptionsWithComponents, + options: Pick, issues: readonly SchemaDiffIssue[], ): { readonly calls: readonly RenameIndexCall[]; @@ -649,7 +713,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * plus a drop when `destructive` is allowed too. */ private pairCheckRenames( - options: PlannerOptionsWithComponents, + options: Pick, issues: readonly SchemaDiffIssue[], ): { readonly calls: readonly RenameConstraintCall[]; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts new file mode 100644 index 000000000000..6cfc5222ed2b --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts @@ -0,0 +1,104 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + type CheckConstraintInput, + type ForeignKeyInput, + type IndexInput, + type PrimaryKeyInput, + SqlStorage, + StorageTable, + type UniqueConstraintInput, +} from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { postgresCreateNamespace } from '../../src/core/postgres-schema'; + +export const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const text = { nativeType: 'text', codecId: 'pg/text@1', nullable: false }; +const int4 = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; +export const NICKNAME_CHECK = 'length(nickname) > 0'; + +export interface ProfileSpec { + readonly primaryKey?: PrimaryKeyInput; + readonly uniques?: readonly UniqueConstraintInput[]; + readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; + readonly indexes?: (tableName: string) => readonly IndexInput[]; + readonly checks?: (tableName: string) => readonly CheckConstraintInput[]; +} + +function profileTable(tableName: string, spec: ProfileSpec): StorageTable { + return new StorageTable({ + columns: { id: int4, email: text, handle: text, nickname: text, accountId: int4 }, + primaryKey: spec.primaryKey ?? { columns: ['id'], name: 'profile_pk' }, + uniques: spec.uniques ?? [], + indexes: spec.indexes?.(tableName) ?? [], + foreignKeys: spec.foreignKeys?.(tableName) ?? [], + checks: spec.checks?.(tableName) ?? [], + }); +} + +export function reference(tableName: string, columns: readonly string[]) { + return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; +} + +const accountTable = new StorageTable({ + columns: { id: int4 }, + primaryKey: { columns: ['id'], name: 'account_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], +}); + +export function postTable(profileTableName: string): StorageTable { + return new StorageTable({ + columns: { id: int4, profileId: int4 }, + primaryKey: { columns: ['id'], name: 'post_pk' }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference('post', ['profileId']), + target: reference(profileTableName, ['id']), + }, + ], + }); +} + +export function contractOf( + profileTableName: string, + spec: ProfileSpec, + hashSeed: string, + extraTables: (profileTableName: string) => Record = () => ({}), + namespaceId: string = UNBOUND_NAMESPACE_ID, +): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [namespaceId]: postgresCreateNamespace({ + id: namespaceId, + entries: { + table: { + [profileTableName]: profileTable(profileTableName, spec), + account: accountTable, + ...extraTables(profileTableName), + }, + policy: {}, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts index 9b58d635b4d4..8257cfad2436 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts @@ -4,114 +4,26 @@ * Wire-named indexes and checks whose prefix derives from the table name pair by content hash and plan as renames. Primary keys, uniques and foreign keys the contract leaves unnamed were named by the planner from the old table name, so each gets a companion constraint rename to the name the planner now derives from the new table name. Explicitly named constraints keep their names, and foreign keys on other tables keep theirs. */ -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import type { Contract } from '@internal/contract/types'; import { APP_SPACE_ID } from '@internal/framework-components/control'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { - type CheckConstraintInput, - type ForeignKeyInput, - type IndexInput, - type PrimaryKeyInput, +import type { + CheckConstraintInput, + ForeignKeyInput, + IndexInput, SqlStorage, - StorageTable, - type UniqueConstraintInput, } from '@internal/sql-contract/types'; import { computeCheckContentHash, computeIndexContentHash } from '@internal/sql-schema-ir/naming'; -import { applicationDomainOf } from '@repo/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; -import { postgresCreateNamespace } from '../../src/core/postgres-schema'; - -const stubLowerer: ExecuteRequestLowerer = { - lower: () => ({ sql: 'stub', params: [] }), - lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), -}; - -const text = { nativeType: 'text', codecId: 'pg/text@1', nullable: false }; -const int4 = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; -const NICKNAME_CHECK = 'length(nickname) > 0'; - -interface ProfileSpec { - readonly primaryKey?: PrimaryKeyInput; - readonly uniques?: readonly UniqueConstraintInput[]; - readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; - readonly indexes?: (tableName: string) => readonly IndexInput[]; - readonly checks?: (tableName: string) => readonly CheckConstraintInput[]; -} - -function profileTable(tableName: string, spec: ProfileSpec): StorageTable { - return new StorageTable({ - columns: { id: int4, email: text, handle: text, nickname: text, accountId: int4 }, - primaryKey: spec.primaryKey ?? { columns: ['id'], name: 'profile_pk' }, - uniques: spec.uniques ?? [], - indexes: spec.indexes?.(tableName) ?? [], - foreignKeys: spec.foreignKeys?.(tableName) ?? [], - checks: spec.checks?.(tableName) ?? [], - }); -} - -function reference(tableName: string, columns: readonly string[]) { - return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; -} - -const accountTable = new StorageTable({ - columns: { id: int4 }, - primaryKey: { columns: ['id'], name: 'account_pk' }, - uniques: [], - indexes: [], - foreignKeys: [], -}); - -function postTable(profileTableName: string): StorageTable { - return new StorageTable({ - columns: { id: int4, profileId: int4 }, - primaryKey: { columns: ['id'], name: 'post_pk' }, - uniques: [], - indexes: [], - foreignKeys: [ - { - source: reference('post', ['profileId']), - target: reference(profileTableName, ['id']), - }, - ], - }); -} - -function contractOf( - profileTableName: string, - spec: ProfileSpec, - hashSeed: string, - extraTables: (profileTableName: string) => Record = () => ({}), -): Contract { - return { - target: 'postgres', - targetFamily: 'sql', - profileHash: profileHash(hashSeed), - storage: new SqlStorage({ - storageHash: coreHash(hashSeed), - namespaces: { - [UNBOUND_NAMESPACE_ID]: postgresCreateNamespace({ - id: UNBOUND_NAMESPACE_ID, - entries: { - table: { - [profileTableName]: profileTable(profileTableName, spec), - account: accountTable, - ...extraTables(profileTableName), - }, - policy: {}, - }, - }), - }, - }), - roots: {}, - domain: applicationDomainOf({ models: {} }), - capabilities: {}, - extensions: {}, - meta: {}, - }; -} +import { + contractOf, + NICKNAME_CHECK, + type ProfileSpec, + postTable, + reference, + stubLowerer, +} from './rename-table-fixtures'; async function plannedOps(from: Contract, to: Contract) { const result = createPostgresMigrationPlanner(stubLowerer).plan({ diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts index 7c3d20e0df5c..6a9b35f3186a 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts @@ -1,54 +1,165 @@ /** - * `migration new --rename-table` on Postgres: the scaffold carries one rename call per intent, in the schema either side names. + * `migration new --rename-table` on Postgres: the scaffold carries the same rename operations `migration plan` plans for the stated renames, and refuses the intents `migration plan` refuses. */ -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import type { StorageEntityRename } from '@internal/framework-components/control'; +import type { Contract } from '@internal/contract/types'; +import type { + MigrationPlanWithAuthoringSurface, + StorageEntityRename, +} from '@internal/framework-components/control'; import { APP_SPACE_ID } from '@internal/framework-components/control'; import { keepInternalSpecifiers } from '@internal/framework-components/emission'; +import type { IndexInput, SqlStorage } from '@internal/sql-contract/types'; +import { computeIndexContentHash } from '@internal/sql-schema-ir/naming'; import { describe, expect, it } from 'vitest'; import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { contractOf, type ProfileSpec, reference, stubLowerer } from './rename-table-fixtures'; -const stubLowerer: ExecuteRequestLowerer = { - lower: () => ({ sql: 'stub', params: [] }), - lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +const FROM_HASH = 'a'.repeat(64); +const TO_HASH = 'b'.repeat(64); +const RENAME: StorageEntityRename = { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }; +const HANDLE_HASH = computeIndexContentHash({ columns: ['handle'], unique: false }); + +const withObjects: ProfileSpec = { + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [ + { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, + ], + indexes: (tableName): readonly IndexInput[] => [ + { + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash: HANDLE_HASH }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }, + ], }; -function scaffoldSource(renames: readonly StorageEntityRename[]): string { - return createPostgresMigrationPlanner(stubLowerer) - .emptyMigration( - { - packageDir: '/tmp/migration-pkg', - fromHash: 'a'.repeat(64), - toHash: 'b'.repeat(64), - snapshotsImportPath: '../../snapshots', - renames, +function scaffold(input: { + readonly from: Contract | null; + readonly to: Contract; + readonly intents?: readonly StorageEntityRename[]; +}) { + return createPostgresMigrationPlanner(stubLowerer).emptyMigration( + { + packageDir: '/tmp/migration-pkg', + fromHash: input.from?.storage.storageHash ?? null, + toHash: input.to.storage.storageHash, + snapshotsImportPath: '../../snapshots', + renames: { + intents: input.intents ?? [RENAME], + fromContract: input.from, + toContract: input.to, + frameworkComponents: [], }, - APP_SPACE_ID, - ) - .renderTypeScript(keepInternalSpecifiers); + }, + APP_SPACE_ID, + ); +} + +async function labelsOf(plan: MigrationPlanWithAuthoringSurface): Promise { + return (await Promise.all(plan.operations)).map((op) => op.label); } +const RENAME_LABELS = [ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + `Rename index "userProfile_handle_idx_${HANDLE_HASH}" to "UserProfile_handle_idx_${HANDLE_HASH}" on "UserProfile"`, +]; + describe('Postgres scaffold rename-table intents', () => { - it('leaves an unqualified intent unbound', () => { - expect( - scaffoldSource([{ from: { name: 'userProfile' }, to: { name: 'UserProfile' } }]), - ).toContain('this.renameTable({ table: "userProfile", to: "UserProfile" })'); + it('carries the same operations migration plan plans for the rename', async () => { + const from = contractOf('userProfile', withObjects, FROM_HASH); + const to = contractOf('UserProfile', withObjects, TO_HASH); + const planned = createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: postgresContractToSchema(from, []), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + renames: [RENAME], + }); + expect(planned.kind).toBe('success'); + if (planned.kind !== 'success') return; + + expect(await labelsOf(scaffold({ from, to }))).toEqual(RENAME_LABELS); + expect(await labelsOf(planned.plan)).toEqual(RENAME_LABELS); + }); + + it('carries only the renames, not the other changes between the contracts', async () => { + const from = contractOf('userProfile', withObjects, FROM_HASH); + const to = contractOf( + 'UserProfile', + { ...withObjects, uniques: [{ columns: ['email'] }, { columns: ['handle'] }] }, + TO_HASH, + ); + + expect(await labelsOf(scaffold({ from, to }))).toEqual(RENAME_LABELS); + }); + + it('renders every rename as a facade call', () => { + const source = scaffold({ + from: contractOf('userProfile', withObjects, FROM_HASH), + to: contractOf('UserProfile', withObjects, TO_HASH), + }).renderTypeScript(keepInternalSpecifiers); + + expect(source).toContain('this.renameTable({ table: "userProfile", to: "UserProfile" })'); + expect(source).toContain( + 'this.renameConstraint({ table: "UserProfile", kind: "unique", from: "userProfile_email_key", to: "UserProfile_email_key" })', + ); + }); + + it('renames in the schema that declares the table', () => { + const source = scaffold({ + from: contractOf('userProfile', {}, FROM_HASH, () => ({}), 'auth'), + to: contractOf('UserProfile', {}, TO_HASH, () => ({}), 'auth'), + }).renderTypeScript(keepInternalSpecifiers); + + expect(source).toContain( + 'this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', + ); + }); + + it('refuses with MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT when there is no previous contract', () => { + expect(() => scaffold({ from: null, to: contractOf('UserProfile', {}, TO_HASH) })).toThrow( + expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT' }), + ); }); - it('renames in the schema the old name is qualified with', () => { - expect( - scaffoldSource([ - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - ]), - ).toContain('this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })'); + it('refuses an intent that matches neither contract with MIGRATION.TABLE_RENAME_UNMATCHED', () => { + expect(() => + scaffold({ + from: contractOf('userProfile', {}, FROM_HASH), + to: contractOf('UserProfile', {}, TO_HASH), + intents: [{ from: { name: 'ghost' }, to: { name: 'UserProfile' } }], + }), + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "ghost" does not exist in the previous contract'), + }), + ); }); - it('renames in the schema the new name is qualified with when the old name is not', () => { - expect( - scaffoldSource([ - { from: { name: 'userProfile' }, to: { namespaceId: 'auth', name: 'UserProfile' } }, - ]), - ).toContain('this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })'); + it('scaffolds an empty body without renames', () => { + const empty = createPostgresMigrationPlanner(stubLowerer).emptyMigration( + { + packageDir: '/tmp/migration-pkg', + fromHash: null, + toHash: TO_HASH, + snapshotsImportPath: '../../snapshots', + }, + APP_SPACE_ID, + ); + + expect(empty.operations).toEqual([]); }); }); diff --git a/packages/3-targets/3-targets/sqlite/src/core/errors.ts b/packages/3-targets/3-targets/sqlite/src/core/errors.ts index 0b046da61133..4da420e9c7db 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/errors.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/errors.ts @@ -13,7 +13,6 @@ export type SqliteTargetErrorCode = | 'CONTRACT.TYPE_UNKNOWN' | 'MIGRATION.CONTRACT_SPACE_VIOLATION' | 'MIGRATION.SQLITE_CONTROL_STACK_MISSING' - | 'MIGRATION.TABLE_RENAME_UNMATCHED' | 'MIGRATION.TARGET_MISMATCH' | 'RUNTIME.DECODE_FAILED' | 'RUNTIME.ENCODE_FAILED'; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 66b2e5d496d9..0cdfd272b8d5 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -3,6 +3,7 @@ import type { MigrationOperationPolicy, SqlMigrationPlanner, SqlMigrationPlannerPlanOptions, + SqlPlannerConflict, SqlPlannerFailureResult, } from '@internal/family-sql/control'; import { @@ -11,25 +12,28 @@ import { extractCodecControlHooks, planFieldEventOperations, plannerFailure, - tableRenameIntentLabel, + tableRenameScaffoldError, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { MigrationPlanner, MigrationScaffoldContext, + MigrationScaffoldRenames, SchemaDiffIssue, SchemaOwnership, StorageEntityRename, } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; import { RelationalSchemaNodeKind, type SqlSchemaIR, SqlTableIR, } from '@internal/sql-schema-ir/types'; -import { sqliteError } from '../errors'; +import { blindCast } from '@internal/utils/casts'; +import { notOk, ok, type Result } from '@internal/utils/result'; import { buildSqlitePlanDiff, sqliteContractToSchema } from './diff-database-schema'; import { coalesceSubtreeIssues, @@ -37,13 +41,28 @@ import { issueNode, planIssues, } from './issue-planner'; -import { RenameTableCall } from './op-factory-call'; +import { RenameTableCall, type SqliteOpFactoryCall } from './op-factory-call'; import { type SqliteMigrationDestinationInfo, TypeScriptRenderableSqliteMigration, } from './planner-produced-sqlite-migration'; import { sqlitePlannerStrategies } from './planner-strategies'; import type { SqlitePlanTargetDetails } from './planner-target-details'; +import { pairRenamedTableIndexes } from './renamed-table-indexes'; + +interface PlannedTableRenames { + readonly fromContract: Contract | null; + readonly previousSchema: SqlMigrationPlannerPlanOptions['schema']; + readonly calls: readonly RenameTableCall[]; +} + +const SCAFFOLD_POLICY: MigrationOperationPolicy = { + allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'], +}; + +function renamedTableNames(calls: readonly RenameTableCall[]): ReadonlySet { + return new Set(calls.map((call) => call.tableName)); +} export function createSqliteMigrationPlanner( lowerer: ExecuteRequestLowerer, @@ -120,24 +139,13 @@ export class SqliteMigrationPlanner } emptyMigration( - context: MigrationScaffoldContext, + context: MigrationScaffoldContext<'sql', 'sqlite'>, spaceId: string, ): TypeScriptRenderableSqliteMigration { - const renameCalls = (context.renames ?? []).map((rename) => { - const namespaceId = rename.from.namespaceId ?? rename.to.namespaceId; - if (namespaceId !== undefined) { - throw sqliteError( - 'MIGRATION.TABLE_RENAME_UNMATCHED', - `--rename-table "${tableRenameIntentLabel(rename)}" names a namespace, "${namespaceId}", and SQLite has no namespaces.`, - { - why: 'A SQLite database has one namespace, so a rename intent names its tables without a qualifier.', - fix: 'Drop the namespace qualifier from the --rename-table value.', - meta: { from: rename.from.name, to: rename.to.name }, - }, - ); - } - return new RenameTableCall(rename.from.name, rename.to.name); - }); + const renameCalls = + context.renames === undefined + ? [] + : this.scaffoldTableRenames(context, context.renames, spaceId); return new TypeScriptRenderableSqliteMigration( renameCalls, { @@ -151,6 +159,84 @@ export class SqliteMigrationPlanner ); } + /** + * The rename operations `planSql` plans for the stated renames, and nothing else: the table renames, then the index drops and creates on the renamed tables. Every other difference between the two contracts is left to the author. + */ + private scaffoldTableRenames( + context: MigrationScaffoldContext<'sql', 'sqlite'>, + renames: MigrationScaffoldRenames<'sql', 'sqlite'>, + spaceId: string, + ): readonly SqliteOpFactoryCall[] { + const fromContract = blindCast< + Contract | null, + 'the family resolver only binds the SQLite planner for a SQL contract' + >(renames.fromContract); + const options: SqlMigrationPlannerPlanOptions = { + contract: blindCast< + Contract, + 'the family resolver only binds the SQLite planner for a SQL contract' + >(renames.toContract), + fromContract, + schema: sqliteContractToSchema(fromContract), + policy: SCAFFOLD_POLICY, + frameworkComponents: renames.frameworkComponents, + spaceId, + snapshotsImportPath: context.snapshotsImportPath, + renames: renames.intents, + }; + const planned = this.planTableRenames(options); + if (!planned.ok) { + throw tableRenameScaffoldError(planned.failure); + } + const { issues } = this.collectSchemaIssues({ + ...options, + schema: planned.value.previousSchema, + }); + return [ + ...planned.value.calls, + ...pairRenamedTableIndexes(issues, renamedTableNames(planned.value.calls)).calls, + ]; + } + + /** + * Applies the stated renames to the previous contract and plans the table renames. Without renames it hands back the previous contract and schema unchanged and no calls. + */ + private planTableRenames( + options: SqlMigrationPlannerPlanOptions, + ): Result { + const intents = options.renames ?? []; + if (intents.length === 0) { + return ok({ fromContract: options.fromContract, previousSchema: options.schema, calls: [] }); + } + const applied = applyTableRenameIntents({ + fromContract: options.fromContract, + toContract: options.contract, + intents, + renameTableReferences: undefined, + }); + if (!applied.ok) { + return notOk(applied.failure); + } + const calls = applied.value.renames.map( + (rename) => new RenameTableCall(rename.from, rename.to), + ); + const disallowed = calls.filter( + (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), + ); + if (disallowed.length > 0) { + return notOk( + disallowed.map((call) => + conflictForDisallowedCall(call, options.policy.allowedOperationClasses), + ), + ); + } + return ok({ + fromContract: applied.value.contract, + previousSchema: sqliteContractToSchema(applied.value.contract), + calls, + }); + } + private planSql(options: SqlMigrationPlannerPlanOptions): SqlitePlanResult { const policyResult = this.ensureAdditivePolicy(options.policy); if (policyResult) return policyResult; @@ -161,40 +247,33 @@ export class SqliteMigrationPlanner // themselves become the first operations of the plan; an intent that // matches neither side, or a rename the policy's classes do not admit, // fails the plan rather than degrading to a drop and a create. - const intents = options.renames ?? []; - const applied = - intents.length === 0 - ? undefined - : applyTableRenameIntents({ - fromContract: options.fromContract, - toContract: options.contract, - intents, - renameTableReferences: undefined, - }); - if (applied !== undefined && !applied.ok) { - return plannerFailure(applied.failure); + const tableRenames = this.planTableRenames(options); + if (!tableRenames.ok) { + return plannerFailure(tableRenames.failure); } - const fromContract = applied === undefined ? options.fromContract : applied.value.contract; - const previousSchema = - applied === undefined ? options.schema : sqliteContractToSchema(applied.value.contract); - const renameTableCalls = (applied?.value.renames ?? []).map( - (rename) => new RenameTableCall(rename.from, rename.to), - ); - const disallowedRenames = renameTableCalls.filter( + const { fromContract, previousSchema } = tableRenames.value; + const renameTableCalls = tableRenames.value.calls; + + const { + expected, + actual, + issues: diffIssues, + } = this.collectSchemaIssues({ + ...options, + schema: previousSchema, + }); + const renamedIndexes = pairRenamedTableIndexes(diffIssues, renamedTableNames(renameTableCalls)); + const disallowedIndexCalls = renamedIndexes.calls.filter( (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), ); - if (disallowedRenames.length > 0) { + if (disallowedIndexCalls.length > 0) { return plannerFailure( - disallowedRenames.map((call) => + disallowedIndexCalls.map((call) => conflictForDisallowedCall(call, options.policy.allowedOperationClasses), ), ); } - - const { expected, actual, issues } = this.collectSchemaIssues({ - ...options, - schema: previousSchema, - }); + const issues = diffIssues.filter((issue) => !renamedIndexes.consumed.has(issue)); const caseChangeConflicts = detectTableNameCaseChanges({ issues, tableOf: (issue) => { @@ -236,18 +315,10 @@ export class SqliteMigrationPlanner // toOp + importRequirements ride directly through the same emit path // as structural ops, no `RawSqlCall` wrap. The table renames run first: // every later operation addresses the renamed table by its new name. - // SQLite cannot rename an index and compares index names without case, so - // an index whose table-derived name changes only in case must be dropped - // before its replacement is created: index drops on a renamed table run - // right after the renames. - const renamedTables = new Set((applied?.value.renames ?? []).map((rename) => rename.to)); - const indexDropsOnRenamedTables = result.value.calls.filter( - (call) => call.factoryName === 'dropIndex' && renamedTables.has(call.tableName), - ); const calls = [ ...renameTableCalls, - ...indexDropsOnRenamedTables, - ...result.value.calls.filter((call) => !indexDropsOnRenamedTables.includes(call)), + ...renamedIndexes.calls, + ...result.value.calls, ...fieldEventOps, ]; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/renamed-table-indexes.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/renamed-table-indexes.ts new file mode 100644 index 000000000000..62dee22b6608 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/renamed-table-indexes.ts @@ -0,0 +1,66 @@ +import type { SchemaDiffIssue } from '@internal/framework-components/control'; +import { issueOutcome } from '@internal/framework-components/control'; +import { parseWireName } from '@internal/sql-schema-ir/naming'; +import { SqlIndexIR } from '@internal/sql-schema-ir/types'; +import { issueNode } from './issue-planner'; +import { CreateIndexCall, DropIndexCall, type SqliteOpFactoryCall } from './op-factory-call'; + +interface IndexFinding { + readonly issue: SchemaDiffIssue; + readonly index: SqlIndexIR; + readonly tableName: string; + readonly hash: string; +} + +function indexFindings( + issues: readonly SchemaDiffIssue[], + renamedTables: ReadonlySet, + outcome: 'not-found' | 'not-expected', +): IndexFinding[] { + return issues.flatMap((issue) => { + const node = issueNode(issue); + const tableName = issue.path[1]; + if (node === undefined || !SqlIndexIR.is(node) || tableName === undefined) return []; + if (!renamedTables.has(tableName) || issueOutcome(issue) !== outcome) return []; + const wire = parseWireName(node.name); + return wire === undefined ? [] : [{ issue, index: node, tableName, hash: wire.hash }]; + }); +} + +/** + * The index operations a table rename needs on SQLite. An index whose wire name derives from the table name keeps its old name through the rename, and SQLite cannot rename an index, so each old index that pairs with a new one by content hash on a renamed table is dropped and the new one created. Drops come first because SQLite compares index names without case, and a name that only changes case would otherwise collide. The paired issues are returned as consumed so the ordinary diff does not plan them again. + */ +export function pairRenamedTableIndexes( + issues: readonly SchemaDiffIssue[], + renamedTables: ReadonlySet, +): { + readonly calls: readonly SqliteOpFactoryCall[]; + readonly consumed: ReadonlySet; +} { + const missing = indexFindings(issues, renamedTables, 'not-found'); + const pairs = indexFindings(issues, renamedTables, 'not-expected').flatMap((old) => { + const replacement = missing.find( + (candidate) => + candidate.tableName === old.tableName && + candidate.hash === old.hash && + candidate.index.name !== old.index.name, + ); + if (replacement === undefined) return []; + missing.splice(missing.indexOf(replacement), 1); + return [{ old, replacement }]; + }); + return { + calls: [ + ...pairs.map(({ old }) => new DropIndexCall(old.tableName, old.index.name)), + ...pairs.map( + ({ replacement }) => + new CreateIndexCall( + replacement.tableName, + replacement.index.name, + replacement.index.columns ?? [], + ), + ), + ], + consumed: new Set(pairs.flatMap(({ old, replacement }) => [old.issue, replacement.issue])), + }; +} diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts new file mode 100644 index 000000000000..25f3a13151c1 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts @@ -0,0 +1,99 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + type ForeignKeyInput, + type IndexInput, + SqlStorage, + StorageTable, + type UniqueConstraintInput, +} from '@internal/sql-contract/types'; +import { computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; + +export const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const integer = { nativeType: 'integer', codecId: 'sqlite/integer@1', nullable: false }; +const text = { nativeType: 'text', codecId: 'sqlite/text@1', nullable: false }; + +export interface ProfileSpec { + readonly uniques?: readonly UniqueConstraintInput[]; + readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; + readonly indexes?: (tableName: string) => readonly IndexInput[]; +} + +export function reference(tableName: string, columns: readonly string[]) { + return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; +} + +export function contractOf( + profileTableName: string, + spec: ProfileSpec, + hashSeed: string, +): Contract { + return { + target: 'sqlite', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + [profileTableName]: new StorageTable({ + columns: { id: integer, email: text, handle: text, accountId: integer }, + primaryKey: { columns: ['id'] }, + uniques: spec.uniques ?? [], + indexes: spec.indexes?.(profileTableName) ?? [], + foreignKeys: spec.foreignKeys?.(profileTableName) ?? [], + }), + account: new StorageTable({ + columns: { id: integer }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + post: new StorageTable({ + columns: { id: integer, profileId: integer }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference('post', ['profileId']), + target: reference(profileTableName, ['id']), + }, + ], + }), + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +export const HANDLE_INDEX_HASH = computeIndexContentHash({ columns: ['handle'], unique: false }); + +export function handleIndex(tableName: string): IndexInput { + return { + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash: HANDLE_INDEX_HASH }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }; +} diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts index 58da64c1d40e..9d41c5204a8f 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts @@ -4,96 +4,18 @@ * SQLite renames a table's automatic indexes and rewrites foreign keys that reference it, and it names no primary key, unique or foreign key the contract leaves unnamed, so those need nothing beyond the rename. A wire-named index whose prefix derives from the table name keeps its old name through `ALTER TABLE ... RENAME TO`, and SQLite cannot rename an index, so it is dropped and created under the new name; an index holds no rows, so nothing is lost. */ -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import { APP_SPACE_ID } from '@internal/framework-components/control'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { - type ForeignKeyInput, - type IndexInput, - SqlStorage, - StorageTable, - type UniqueConstraintInput, -} from '@internal/sql-contract/types'; -import { computeIndexContentHash } from '@internal/sql-schema-ir/naming'; -import { applicationDomainOf } from '@repo/test-utils'; import { describe, expect, it } from 'vitest'; import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; -import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; - -const stubLowerer: ExecuteRequestLowerer = { - lower: () => ({ sql: 'stub', params: [] }), - lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), -}; - -const integer = { nativeType: 'integer', codecId: 'sqlite/integer@1', nullable: false }; -const text = { nativeType: 'text', codecId: 'sqlite/text@1', nullable: false }; - -interface ProfileSpec { - readonly uniques?: readonly UniqueConstraintInput[]; - readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; - readonly indexes?: (tableName: string) => readonly IndexInput[]; -} - -function reference(tableName: string, columns: readonly string[]) { - return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; -} - -function contractOf( - profileTableName: string, - spec: ProfileSpec, - hashSeed: string, -): Contract { - return { - target: 'sqlite', - targetFamily: 'sql', - profileHash: profileHash(hashSeed), - storage: new SqlStorage({ - storageHash: coreHash(hashSeed), - namespaces: { - [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ - id: UNBOUND_NAMESPACE_ID, - entries: { - table: { - [profileTableName]: new StorageTable({ - columns: { id: integer, email: text, handle: text, accountId: integer }, - primaryKey: { columns: ['id'] }, - uniques: spec.uniques ?? [], - indexes: spec.indexes?.(profileTableName) ?? [], - foreignKeys: spec.foreignKeys?.(profileTableName) ?? [], - }), - account: new StorageTable({ - columns: { id: integer }, - primaryKey: { columns: ['id'] }, - uniques: [], - indexes: [], - foreignKeys: [], - }), - post: new StorageTable({ - columns: { id: integer, profileId: integer }, - primaryKey: { columns: ['id'] }, - uniques: [], - indexes: [], - foreignKeys: [ - { - source: reference('post', ['profileId']), - target: reference(profileTableName, ['id']), - }, - ], - }), - }, - }, - }), - }, - }), - roots: {}, - domain: applicationDomainOf({ models: {} }), - capabilities: {}, - extensions: {}, - meta: {}, - }; -} +import { + contractOf, + HANDLE_INDEX_HASH, + handleIndex, + type ProfileSpec, + reference, + stubLowerer, +} from './rename-table-fixtures'; async function plannedLabels(spec: ProfileSpec): Promise { const from = contractOf('userProfile', spec, 'from'); @@ -116,20 +38,10 @@ const RENAME_TABLE = 'Rename table userProfile to UserProfile'; describe('SQLite planner rename-table intents with table objects', () => { it('drops an index whose prefix derives from the table name before creating it under the new name', async () => { - const hash = computeIndexContentHash({ columns: ['handle'], unique: false }); - const handleIndex = (tableName: string): IndexInput => ({ - columns: ['handle'], - naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash }, - where: undefined, - unique: false, - type: undefined, - options: undefined, - }); - expect(await plannedLabels({ indexes: (t) => [handleIndex(t)] })).toEqual([ RENAME_TABLE, - `Drop index userProfile_handle_idx_${hash} on UserProfile`, - `Create index UserProfile_handle_idx_${hash} on UserProfile`, + `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, ]); }); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts index 00222a9b08b6..dde45866cdcd 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts @@ -1,58 +1,144 @@ /** - * `migration new --rename-table` on SQLite: the scaffold carries one rename call per intent and refuses a namespace qualifier, as the planner does. + * `migration new --rename-table` on SQLite: the scaffold carries the same rename operations `migration plan` plans for the stated renames, and refuses the intents `migration plan` refuses, including a namespace qualifier SQLite does not have. */ -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import type { StorageEntityRename } from '@internal/framework-components/control'; +import type { Contract } from '@internal/contract/types'; +import type { + MigrationPlanWithAuthoringSurface, + StorageEntityRename, +} from '@internal/framework-components/control'; import { APP_SPACE_ID } from '@internal/framework-components/control'; import { keepInternalSpecifiers } from '@internal/framework-components/emission'; +import type { SqlStorage } from '@internal/sql-contract/types'; import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; +import { + contractOf, + HANDLE_INDEX_HASH, + handleIndex, + type ProfileSpec, + stubLowerer, +} from './rename-table-fixtures'; -const stubLowerer: ExecuteRequestLowerer = { - lower: () => ({ sql: 'stub', params: [] }), - lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +const FROM_HASH = 'a'.repeat(64); +const TO_HASH = 'b'.repeat(64); +const RENAME: StorageEntityRename = { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }; +const withIndex: ProfileSpec = { + uniques: [{ columns: ['email'] }], + indexes: (t) => [handleIndex(t)], }; -function scaffold(renames: readonly StorageEntityRename[]) { +function scaffold(input: { + readonly from: Contract | null; + readonly to: Contract; + readonly intents?: readonly StorageEntityRename[]; +}) { return createSqliteMigrationPlanner(stubLowerer).emptyMigration( { packageDir: '/tmp/migration-pkg', - fromHash: 'a'.repeat(64), - toHash: 'b'.repeat(64), + fromHash: input.from?.storage.storageHash ?? null, + toHash: input.to.storage.storageHash, snapshotsImportPath: '../../snapshots', - renames, + renames: { + intents: input.intents ?? [RENAME], + fromContract: input.from, + toContract: input.to, + frameworkComponents: [], + }, }, APP_SPACE_ID, ); } +async function labelsOf(plan: MigrationPlanWithAuthoringSurface): Promise { + return (await Promise.all(plan.operations)).map((op) => op.label); +} + +const RENAME_LABELS = [ + 'Rename table userProfile to UserProfile', + `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, +]; + describe('SQLite scaffold rename-table intents', () => { - it('carries one rename call per unqualified intent', () => { - const source = scaffold([ - { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, - ]).renderTypeScript(keepInternalSpecifiers); + it('carries the same operations migration plan plans for the rename', async () => { + const from = contractOf('userProfile', withIndex, FROM_HASH); + const to = contractOf('UserProfile', withIndex, TO_HASH); + const planned = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: sqliteContractToSchema(from), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + renames: [RENAME], + }); + expect(planned.kind).toBe('success'); + if (planned.kind !== 'success') return; + + expect(await labelsOf(scaffold({ from, to }))).toEqual(RENAME_LABELS); + expect(await labelsOf(planned.plan)).toEqual(RENAME_LABELS); + }); + + it('carries only the renames, not the other changes between the contracts', async () => { + const from = contractOf('userProfile', withIndex, FROM_HASH); + const to = contractOf('UserProfile', { ...withIndex, uniques: [] }, TO_HASH); + + expect(await labelsOf(scaffold({ from, to }))).toEqual(RENAME_LABELS); + }); + + it('renders every rename as a facade call', () => { + const source = scaffold({ + from: contractOf('userProfile', withIndex, FROM_HASH), + to: contractOf('UserProfile', withIndex, TO_HASH), + }).renderTypeScript(keepInternalSpecifiers); expect(source).toContain('this.renameTable({ table: "userProfile", to: "UserProfile" })'); + expect(source).toContain(`userProfile_handle_idx_${HANDLE_INDEX_HASH}`); + expect(source).toContain(`UserProfile_handle_idx_${HANDLE_INDEX_HASH}`); + }); + + it('refuses with MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT when there is no previous contract', () => { + expect(() => scaffold({ from: null, to: contractOf('UserProfile', {}, TO_HASH) })).toThrow( + expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT' }), + ); }); it.each([ [ 'the old name', { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - '"auth.userProfile=UserProfile" names a namespace, "auth"', ], [ 'the new name', { from: { name: 'userProfile' }, to: { namespaceId: 'auth', name: 'UserProfile' } }, - '"userProfile=auth.UserProfile" names a namespace, "auth"', ], - ])('refuses a namespace qualifier on %s, which SQLite does not have', (_side, intent, detail) => { - expect(() => scaffold([intent])).toThrow( - expect.objectContaining({ - code: 'MIGRATION.TABLE_RENAME_UNMATCHED', - message: `--rename-table ${detail}, and SQLite has no namespaces.`, - }), + ])( + 'refuses a namespace qualifier on %s with MIGRATION.TABLE_RENAME_UNMATCHED', + (_side, intent) => { + expect(() => + scaffold({ + from: contractOf('userProfile', {}, FROM_HASH), + to: contractOf('UserProfile', {}, TO_HASH), + intents: [intent], + }), + ).toThrow(expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' })); + }, + ); + + it('scaffolds an empty body without renames', () => { + const empty = createSqliteMigrationPlanner(stubLowerer).emptyMigration( + { + packageDir: '/tmp/migration-pkg', + fromHash: null, + toHash: TO_HASH, + snapshotsImportPath: '../../snapshots', + }, + APP_SPACE_ID, ); + + expect(empty.operations).toEqual([]); }); }); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-from.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-from.prisma deleted file mode 100644 index db26a7676ee3..000000000000 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-from.prisma +++ /dev/null @@ -1,8 +0,0 @@ -// use prisma-8 - -model UserProfile { - id Int @id - email String - - @@map("userProfile") -} diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-to.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-to.prisma deleted file mode 100644 index 79eac84583ef..000000000000 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-to.prisma +++ /dev/null @@ -1,6 +0,0 @@ -// use prisma-8 - -model UserProfile { - id Int @id - email String -} From a16128f22b3f97ef0ba61c87a33c232c22bdde5e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 13:07:46 +0200 Subject: [PATCH 23/55] test: migration new renames a table with constraints, indexes and RLS Journey R2 now uses the same table as R1: a unique constraint, a foreign key, an index, row-level security and a policy. It checks that the migration new scaffold self-emits the same operations migration plan plans, that migrate keeps the rows, the policy and RLS and renames every constraint and index, that a plan with no schema change is empty, and that a later migration removing the unique constraint, the foreign key and the index applies. R1 and R2 share those steps. The plain-table fixtures are gone. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/cli-journeys/README.md | 2 +- .../rename-table-migration.e2e.test.ts | 244 +++++++++--------- .../test/utils/journey-test-helpers.ts | 2 - 3 files changed, 122 insertions(+), 126 deletions(-) diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 4b5a704000cf..71f2e5ce630e 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -30,7 +30,7 @@ pnpm test:journeys | `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column, tests `--no-interactive` rejection, `--json` error envelope, and `--json -y` auto-accept. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | -| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, drop the model's `@@map`, confirm the bare plan is refused by the case-change guard and a stale `--rename-table` intent fails planning, then `migration plan --rename-table userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. Second journey: the same rename through `migration new --rename-table`, whose scaffold carries the `renameTable` call and self-emits the SQL | +| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard and a stale `--rename-table` intent fails planning, then `migration plan --rename-table userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. Journey R2 authors the same rename through `migration new --rename-table`, whose scaffold carries the same operations. In both, `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies | | `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts index afbb9a56d47a..2201868d1809 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -1,11 +1,13 @@ /** * Renaming a table keeps its rows (Postgres). * - * A model whose table name changes used to plan as `dropTable` plus `createTable`. With `--rename-table =` the operator states the rename and the plan carries a `renameTable` operation instead. + * A model whose table name changes used to plan as `dropTable` plus `createTable`. With `--rename-table =` the operator states the rename and the migration carries a `renameTable` operation instead, plus a rename of each constraint and index named after the old table. * - * Journey R1 (`migration plan`): create `userProfile` with rows, a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map` so it names `UserProfile`. Planning without the flag is refused by the case-change guard and a stale intent fails planning. The stated intent plans the rename plus a rename of each constraint and index named after the old table; `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. + * Both journeys create `userProfile` with rows, a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map` so it names `UserProfile`. After `migrate`, the rows, the policy and RLS are kept, every constraint and index carries the new table name, `db verify --schema-only` is clean, a plan with no schema change is empty, and a later migration that removes the unique constraint, the foreign key and the index applies. * - * Journey R2 (`migration new`): the same rename authored by hand on a table with no other objects. The flag pre-fills the scaffolded `migration.ts` with the rename call; running the file self-emits the op, and `migrate` applies it. + * Journey R1 (`migration plan`) also shows that planning without the flag is refused by the case-change guard and that a stale intent fails planning. + * + * Journey R2 (`migration new`) authors the rename by hand: the flag pre-fills the scaffolded `migration.ts` with the same operations `migration plan` would plan. */ import { readFileSync } from 'node:fs'; @@ -37,34 +39,45 @@ interface PlanDocument { readonly operations: readonly { id: string; label: string; operationClass: string }[]; } -interface OpsJson { - readonly id: string; - readonly execute: readonly { sql: string }[]; -} - -const SEED_ROWS = `INSERT INTO "public"."userProfile" (id, email) VALUES (1, 'alice@example.com'), (2, 'bob@example.com')`; +const RENAME_LABELS = [ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + 'Rename index "userProfile_accountId_idx_cbfb3085" to "UserProfile_accountId_idx_cbfb3085" on "UserProfile"', + 'Rename index "userProfile_handle_idx_b5b249e4" to "UserProfile_handle_idx_b5b249e4" on "UserProfile"', +]; -async function seedRenamedTable( +async function seedTableWithObjects( ctx: JourneyContext, connectionString: string, label: string, ): Promise { - swapPslContract(ctx, 'contract-rename-table-from'); + await sql(connectionString, 'CREATE ROLE app_user'); + swapPslContract(ctx, 'contract-rename-table-objects-from'); const emit = await runContractEmit(ctx); expect(emit.exitCode, `${label}.01: emit userProfile: ${emit.stderr}`).toBe(0); - const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); - expect(plan.exitCode, `${label}.02: plan initial: ${plan.stderr}`).toBe(0); - const apply = await runMigrate(ctx); - expect(apply.exitCode, `${label}.03: migrate initial: ${apply.stderr}`).toBe(0); - await sql(connectionString, SEED_ROWS); - - swapPslContract(ctx, 'contract-rename-table-to'); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `${label}.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `${label}.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + await sql( + connectionString, + `INSERT INTO "public"."Account" (id) VALUES (1); + INSERT INTO "public"."userProfile" (id, email, handle, tenant_id, "accountId") + VALUES (1, 'alice@example.com', 'alice', 1, 1), (2, 'bob@example.com', 'bob', 1, 1)`, + ); + swapPslContract(ctx, 'contract-rename-table-objects-to'); const emitRenamed = await runContractEmit(ctx); expect(emitRenamed.exitCode, `${label}.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); return latestMigrationDirName(ctx); } -async function expectRowsSurvived(connectionString: string, label: string): Promise { +async function expectRenameApplied( + ctx: JourneyContext, + connectionString: string, + label: string, +): Promise { const rows = await sql( connectionString, `SELECT id, email FROM "public"."UserProfile" ORDER BY id`, @@ -73,8 +86,77 @@ async function expectRowsSurvived(connectionString: string, label: string): Prom { id: 1, email: 'alice@example.com' }, { id: 2, email: 'bob@example.com' }, ]); - const old = await sql(connectionString, `SELECT to_regclass('"public"."userProfile"') AS old`); - expect(old.rows[0]?.['old'], `${label}: old name is gone`).toBeNull(); + const live = await sql( + connectionString, + `SELECT + to_regclass('"public"."userProfile"') AS old, + (SELECT array_agg(conname::text ORDER BY conname) FROM pg_constraint + WHERE conrelid = '"public"."UserProfile"'::regclass) AS constraints, + (SELECT array_agg(indexname::text ORDER BY indexname) FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'UserProfile') AS indexes, + (SELECT array_agg(policyname::text) FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'UserProfile') AS policies, + (SELECT relrowsecurity FROM pg_class + WHERE oid = '"public"."UserProfile"'::regclass) AS rls`, + ); + expect(live.rows[0], `${label}: old name gone, objects renamed, policy and RLS kept`).toEqual({ + old: null, + constraints: ['UserProfile_accountId_fkey', 'UserProfile_email_key', 'UserProfile_pkey'], + indexes: [ + 'UserProfile_accountId_idx_cbfb3085', + 'UserProfile_email_key', + 'UserProfile_handle_idx_b5b249e4', + 'UserProfile_pkey', + ], + policies: ['tenant_read_f8d5e783'], + rls: true, + }); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `${label}: db verify --schema-only: ${verify.stderr}`).toBe(0); +} + +async function expectLaterChangesApply( + ctx: JourneyContext, + connectionString: string, + label: string, +): Promise { + const migrationCount = getMigrationDirs(ctx).length; + const fresh = await runMigrationPlan(ctx, ['--from', latestMigrationDirName(ctx), '--json']); + expect(fresh.exitCode, `${label}.01: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, `${label}.01: plan is empty`).toBe(true); + expect(getMigrationDirs(ctx), `${label}.01: nothing written`).toHaveLength(migrationCount); + + swapPslContract(ctx, 'contract-rename-table-objects-dropped'); + const emitDropped = await runContractEmit(ctx); + expect(emitDropped.exitCode, `${label}.02: emit without objects: ${emitDropped.stderr}`).toBe(0); + const dropPlan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'drop-objects', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(dropPlan.exitCode, `${label}.02: plan the removal: ${dropPlan.stderr}`).toBe(0); + const applyDrop = await runMigrate(ctx); + expect(applyDrop.exitCode, `${label}.03: migrate the removal: ${applyDrop.stderr}`).toBe(0); + const remaining = await sql( + connectionString, + `SELECT + (SELECT array_agg(conname::text ORDER BY conname) FROM pg_constraint + WHERE conrelid = '"public"."UserProfile"'::regclass) AS constraints, + (SELECT array_agg(indexname::text) FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'UserProfile') AS indexes`, + ); + expect(remaining.rows[0], `${label}.03: unique, foreign key and indexes removed`).toEqual({ + constraints: ['UserProfile_pkey'], + indexes: ['UserProfile_pkey'], + }); + const verifyDropped = await runDbVerify(ctx, ['--schema-only']); + expect( + verifyDropped.exitCode, + `${label}.04: db verify --schema-only after the removal: ${verifyDropped.stderr}`, + ).toBe(0); } withTempDir(({ createTempDir }) => { @@ -89,24 +171,7 @@ withTempDir(({ createTempDir }) => { createTempDir, contractMode: 'psl', }); - await sql(db.connectionString, 'CREATE ROLE app_user'); - swapPslContract(ctx, 'contract-rename-table-objects-from'); - const emit = await runContractEmit(ctx); - expect(emit.exitCode, `R1.01: emit userProfile: ${emit.stderr}`).toBe(0); - const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); - expect(initial.exitCode, `R1.02: plan initial: ${initial.stderr}`).toBe(0); - const applyInitial = await runMigrate(ctx); - expect(applyInitial.exitCode, `R1.03: migrate initial: ${applyInitial.stderr}`).toBe(0); - await sql( - db.connectionString, - `INSERT INTO "public"."Account" (id) VALUES (1); - INSERT INTO "public"."userProfile" (id, email, handle, tenant_id, "accountId") - VALUES (1, 'alice@example.com', 'alice', 1, 1), (2, 'bob@example.com', 'bob', 1, 1)`, - ); - swapPslContract(ctx, 'contract-rename-table-objects-to'); - const emitRenamed = await runContractEmit(ctx); - expect(emitRenamed.exitCode, `R1.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); - const origin = latestMigrationDirName(ctx); + const origin = await seedTableWithObjects(ctx, db.connectionString, 'R1'); const bare = await runMigrationPlan(ctx, ['--name', 'bare', '--from', origin, '--json']); expect(bare.exitCode, 'R1.05: bare plan is refused').not.toBe(0); @@ -152,14 +217,7 @@ withTempDir(({ createTempDir }) => { expect( document.operations.map((op) => op.label), 'R1.07: the rename, then a rename of each object named after the old table', - ).toEqual([ - 'Rename table "userProfile" to "UserProfile"', - 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', - 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', - 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', - 'Rename index "userProfile_accountId_idx_cbfb3085" to "UserProfile_accountId_idx_cbfb3085" on "UserProfile"', - 'Rename index "userProfile_handle_idx_b5b249e4" to "UserProfile_handle_idx_b5b249e4" on "UserProfile"', - ]); + ).toEqual(RENAME_LABELS); expect( document.operations.every((op) => op.operationClass === 'widening'), 'R1.07: every operation is a widening rename', @@ -167,65 +225,8 @@ withTempDir(({ createTempDir }) => { const apply = await runMigrate(ctx); expect(apply.exitCode, `R1.08: migrate: ${apply.stderr}`).toBe(0); - await expectRowsSurvived(db.connectionString, 'R1.09'); - const live = await sql( - db.connectionString, - `SELECT - (SELECT array_agg(conname::text ORDER BY conname) FROM pg_constraint - WHERE conrelid = '"public"."UserProfile"'::regclass) AS constraints, - (SELECT array_agg(policyname::text) FROM pg_policies - WHERE schemaname = 'public' AND tablename = 'UserProfile') AS policies, - (SELECT relrowsecurity FROM pg_class - WHERE oid = '"public"."UserProfile"'::regclass) AS rls`, - ); - expect(live.rows[0], 'R1.09: constraints renamed, policy and RLS kept').toEqual({ - constraints: ['UserProfile_accountId_fkey', 'UserProfile_email_key', 'UserProfile_pkey'], - policies: [expect.stringMatching(/^tenant_read_[0-9a-f]{8}$/)], - rls: true, - }); - - const verify = await runDbVerify(ctx, ['--schema-only']); - expect(verify.exitCode, `R1.10: db verify --schema-only: ${verify.stderr}`).toBe(0); - - const fresh = await runMigrationPlan(ctx, [ - '--from', - latestMigrationDirName(ctx), - '--json', - ]); - expect(fresh.exitCode, `R1.11: plan with no schema change: ${fresh.stderr}`).toBe(0); - expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, 'R1.11: plan is empty').toBe(true); - expect(getMigrationDirs(ctx), 'R1.11: nothing written').toHaveLength(2); - - swapPslContract(ctx, 'contract-rename-table-objects-dropped'); - const emitDropped = await runContractEmit(ctx); - expect(emitDropped.exitCode, `R1.12: emit without objects: ${emitDropped.stderr}`).toBe(0); - const dropPlan = await planMigrationAndSelfEmit(ctx, [ - '--name', - 'drop-objects', - '--from', - latestMigrationDirName(ctx), - '--json', - ]); - expect(dropPlan.exitCode, `R1.12: plan the removal: ${dropPlan.stderr}`).toBe(0); - const applyDrop = await runMigrate(ctx); - expect(applyDrop.exitCode, `R1.13: migrate the removal: ${applyDrop.stderr}`).toBe(0); - const remaining = await sql( - db.connectionString, - `SELECT - (SELECT array_agg(conname::text ORDER BY conname) FROM pg_constraint - WHERE conrelid = '"public"."UserProfile"'::regclass) AS constraints, - (SELECT array_agg(indexname::text) FROM pg_indexes - WHERE schemaname = 'public' AND tablename = 'UserProfile') AS indexes`, - ); - expect(remaining.rows[0], 'R1.13: unique, foreign key and indexes removed').toEqual({ - constraints: ['UserProfile_pkey'], - indexes: ['UserProfile_pkey'], - }); - const verifyDropped = await runDbVerify(ctx, ['--schema-only']); - expect( - verifyDropped.exitCode, - `R1.14: db verify --schema-only after the removal: ${verifyDropped.stderr}`, - ).toBe(0); + await expectRenameApplied(ctx, db.connectionString, 'R1.09'); + await expectLaterChangesApply(ctx, db.connectionString, 'R1.10'); }, timeouts.spinUpPpgDev, ); @@ -235,14 +236,14 @@ withTempDir(({ createTempDir }) => { const db = useDevDatabase(); it( - 'the scaffold carries the rename call; self-emit renders the SQL; migrate keeps the rows', + 'the scaffold carries the same renames migration plan plans; migrate keeps rows and policies, and later changes apply', async () => { const ctx = setupJourney({ connectionString: db.connectionString, createTempDir, contractMode: 'psl', }); - await seedRenamedTable(ctx, db.connectionString, 'R2'); + await seedTableWithObjects(ctx, db.connectionString, 'R2'); const scaffold = await runMigrationNew(ctx, [ '--name', @@ -254,28 +255,25 @@ withTempDir(({ createTempDir }) => { const latest = getLatestMigrationDir(ctx); expect(latest, 'R2.05: scaffold dir').toBeDefined(); const packageDir = join(ctx.testDir, 'migrations', 'app', latest!); - expect(readFileSync(join(packageDir, 'migration.ts'), 'utf-8'), 'R2.05: call').toMatch( - /this\.renameTable\(\{ table: ['"]userProfile['"], to: ['"]UserProfile['"] \}\)/, - ); + const migrationTs = readFileSync(join(packageDir, 'migration.ts'), 'utf-8'); + expect(migrationTs, 'R2.05: table rename call').toMatch(/this\.renameTable\(/); + expect(migrationTs, 'R2.05: constraint rename calls').toMatch(/this\.renameConstraint\(/); + expect(migrationTs, 'R2.05: index rename calls').toMatch(/this\.renameIndex\(/); const emitted = await selfEmitMigration(ctx, ['--dir', `migrations/app/${latest}`]); expect(emitted.exitCode, `R2.06: self-emit: ${emitted.stderr}`).toBe(0); - const ops = JSON.parse(readFileSync(join(packageDir, 'ops.json'), 'utf-8')) as OpsJson[]; + const ops = JSON.parse(readFileSync(join(packageDir, 'ops.json'), 'utf-8')) as { + readonly label: string; + }[]; expect( - ops.map((op) => op.id), - 'R2.06: one rename op', - ).toEqual(['renameTable.userProfile']); - expect( - ops[0]?.execute.map((step) => step.sql), - 'R2.06: rendered SQL', - ).toEqual(['ALTER TABLE "userProfile" RENAME TO "UserProfile"']); + ops.map((op) => op.label), + 'R2.06: the same renames migration plan plans', + ).toEqual(RENAME_LABELS); const apply = await runMigrate(ctx); expect(apply.exitCode, `R2.07: migrate: ${apply.stderr}`).toBe(0); - await expectRowsSurvived(db.connectionString, 'R2.08'); - - const verify = await runDbVerify(ctx, ['--schema-only']); - expect(verify.exitCode, `R2.09: db verify --schema-only: ${verify.stderr}`).toBe(0); + await expectRenameApplied(ctx, db.connectionString, 'R2.08'); + await expectLaterChangesApply(ctx, db.connectionString, 'R2.09'); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index 86b92bee7504..a783b8bb6331 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -218,8 +218,6 @@ export const pslContractFixtures = { ), 'contract-rls-adopted': join(JOURNEY_FIXTURES_DIR, 'contract-rls-adopted.prisma'), 'contract-rls-wire': join(JOURNEY_FIXTURES_DIR, 'contract-rls-wire.prisma'), - 'contract-rename-table-from': join(JOURNEY_FIXTURES_DIR, 'contract-rename-table-from.prisma'), - 'contract-rename-table-to': join(JOURNEY_FIXTURES_DIR, 'contract-rename-table-to.prisma'), 'contract-rename-table-objects-from': join( JOURNEY_FIXTURES_DIR, 'contract-rename-table-objects-from.prisma', From 603ea74feae0685dec6b47594010d7b9af89a38f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 13:32:05 +0200 Subject: [PATCH 24/55] refactor(cli): name the rename flag --rename, free of SQL vocabulary The flag lived in the framework CLI but named a SQL concept, behind two lint suppressions, and put twelve new family-vocabulary lines into packages/1-framework, over the ratchet threshold. It is now --rename =: repeatable, on migration plan and migration new, either side .. It renames the storage a model maps to, which is a table in SQL; MongoDB still refuses. In the framework: rename-table-flag.ts is rename-flag.ts, parseRenameTableFlags is parseRenameFlags, the flag key renameTable is rename, and CLI.INVALID_RENAME_TABLE_FLAG is CLI.INVALID_RENAME_FLAG. The help text and messages speak of the storage a model maps to. Both suppressions are gone. pnpm lint:framework-vocabulary now counts 307, the threshold, so this slice adds none. The SQL family keeps its vocabulary and names the new flag in its messages: the case guard remedy is prisma migration plan --rename "=". The error reference, the CLI README, the tests and the journeys use --rename. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 12 +++---- .../src/control/control-migration-types.ts | 6 +--- packages/1-framework/3-tooling/cli/README.md | 3 +- .../control-api/operations/migration-new.ts | 2 +- .../control-api/operations/migration-plan.ts | 2 +- .../3-tooling/cli/src/orm/migration/new.ts | 9 +++-- .../3-tooling/cli/src/orm/migration/plan.ts | 9 +++-- .../{rename-table-flag.ts => rename-flag.ts} | 18 ++++------ .../test/orm/migration-new-renames.test.ts | 8 ++--- ....test.ts => migration-rename-flag.test.ts} | 29 ++++++++------- ...table-flag.test.ts => rename-flag.test.ts} | 36 +++++++++---------- .../core/migrations/table-name-case-guard.ts | 2 +- .../core/migrations/table-rename-intents.ts | 10 +++--- .../test/table-rename-intents.test.ts | 6 ++-- .../planner.table-name-case-guard.test.ts | 2 +- .../rename-table-planner.rls.test.ts | 2 +- ...rename-table-planner.table-objects.test.ts | 2 +- .../migrations/rename-table-planner.test.ts | 2 +- .../migrations/rename-table-scaffold.test.ts | 2 +- .../planner.table-name-case-guard.test.ts | 2 +- ...rename-table-planner.table-objects.test.ts | 2 +- .../migrations/rename-table-planner.test.ts | 2 +- .../migrations/rename-table-scaffold.test.ts | 2 +- test/integration/test/cli-journeys/README.md | 2 +- .../rename-table-migration.e2e.test.ts | 14 ++++---- .../rename-table-migration.sqlite.e2e.test.ts | 8 ++--- 26 files changed, 92 insertions(+), 102 deletions(-) rename packages/1-framework/3-tooling/cli/src/utils/{rename-table-flag.ts => rename-flag.ts} (80%) rename packages/1-framework/3-tooling/cli/test/orm/{migration-rename-table-flag.test.ts => migration-rename-flag.test.ts} (90%) rename packages/1-framework/3-tooling/cli/test/utils/{rename-table-flag.test.ts => rename-flag.test.ts} (66%) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7a5ef9a6092b..f1f2adc1b554 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -167,9 +167,9 @@ Raised by the commander `init` (deleted in the S5 cutover). On the engine-hosted The main CLI received a `--format` value other than `pretty` or `json`. Raised during global-flag resolution, before any command logic runs. Payload: `value`, `allowed`. -### CLI.INVALID_RENAME_TABLE_FLAG +### CLI.INVALID_RENAME_FLAG -A `--rename-table` value given to `migration plan` or `migration new` is not `=` (each side optionally qualified as `.`), names the same entity on both sides, qualifies its two sides with different namespaces (a rename cannot move a table between namespaces), or repeats an old or a new name already used by another value on the same command. Nothing is planned or written. Payload: `value`. +A `--rename` value given to `migration plan` or `migration new` is not `=` (each side optionally qualified as `.`), names the same storage on both sides, qualifies its two sides with different namespaces (a rename cannot move storage between namespaces), or repeats an old or a new name already used by another value on the same command. Nothing is planned or written. Payload: `value`. ### CLI.INVALID_VERIFY_MODE @@ -1280,7 +1280,7 @@ A reference parsed, but as the wrong kind for the argument position — e.g. a m ### MIGRATION.RENAME_UNSUPPORTED -A stated rename (`--rename-table =`) was given to `migration plan` or `migration new` on a MongoDB project. The MongoDB planner has no rename operation, so it refuses rather than planning a drop of the old collection and a create of the new one. Nothing is planned or written; on `migration plan` it is reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Rename the collection by hand with `renameCollection`, then run the command again without the rename. Payload: none. +A stated rename (`--rename =`) was given to `migration plan` or `migration new` on a MongoDB project. The MongoDB planner has no rename operation, so it refuses rather than planning a drop of the old collection and a create of the new one. Nothing is planned or written; on `migration plan` it is reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Rename the collection by hand with `renameCollection`, then run the command again without the rename. Payload: none. ### MIGRATION.RUNNER_FAILED @@ -1308,15 +1308,15 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.TABLE_NAME_CASE_CHANGED -The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename-table "X=Y"`, which plans one `renameTable` operation in place of the drop and the create. In a project that uses `db update`, which has no migration history, rename it by hand with `ALTER TABLE "X" RENAME TO "Y"`, after which the plan is empty. Payload: `droppedTable`, `createdTable`. +The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename "X=Y"`, which plans one `renameTable` operation in place of the drop and the create. In a project that uses `db update`, which has no migration history, rename it by hand with `ALTER TABLE "X" RENAME TO "Y"`, after which the plan is empty. Payload: `droppedTable`, `createdTable`. ### MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT -A `--rename-table =` intent was given to `migration plan` or `migration new`, but there is no previous contract to rename the table in: there is no migration history, so the migration starts from an empty database. Nothing is planned or written. `migration plan` reports it as a conflict inside `MIGRATION.PLANNING_FAILED`, one per intent; `migration new` raises it directly. Plan from the migration that created the table. A database managed with `db update` has no migration history, so rename the table there by hand with `ALTER TABLE "X" RENAME TO "Y"` and drop the flag. Payload: none. +A `--rename =` intent was given to `migration plan` or `migration new`, but there is no previous contract to rename the table in: there is no migration history, so the migration starts from an empty database. Nothing is planned or written. `migration plan` reports it as a conflict inside `MIGRATION.PLANNING_FAILED`, one per intent; `migration new` raises it directly. Plan from the migration that created the table. A database managed with `db update` has no migration history, so rename the table there by hand with `ALTER TABLE "X" RENAME TO "Y"` and drop the flag. Payload: none. ### MIGRATION.TABLE_RENAME_UNMATCHED -A `--rename-table =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, the two sides name different namespaces, or an earlier intent on the same command already resolved to the same old table or the same new table. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. `migration new --rename-table` raises it directly for the same mismatches, including a namespace qualifier on SQLite, which has no namespaces; nothing is written. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. +A `--rename =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, the two sides name different namespaces, or an earlier intent on the same command already resolved to the same old table or the same new table. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. `migration new --rename` raises it directly for the same mismatches, including a namespace qualifier on SQLite, which has no namespaces; nothing is written. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. ### MIGRATION.TARGET_MISMATCH diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index 28c07e58d04a..c1b1941f236e 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -431,11 +431,7 @@ export interface StorageEntityRenameCoordinate { } /** - * An operator-stated rename of one storage entity (a SQL table, a document - * collection) between the previous state and the next contract. The planner - * cannot infer a rename from a diff — the entity under the old name is gone - * and one under the new name appeared — so the operator states it and the - * planner applies it to the previous state before diffing. + * An operator-stated rename of the storage a model maps to, between the previous state and the next contract. The planner cannot infer a rename from a diff — the storage under the old name is gone and one under the new name appeared — so the operator states it and the planner applies it to the previous state before diffing. */ export interface StorageEntityRename { readonly from: StorageEntityRenameCoordinate; diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index a9690a04ae2c..315610958829 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -932,7 +932,7 @@ The `contract.output` field specifies the path to `contract.json`. This is the c Plan a migration from contract changes. Compares a starting contract against a destination contract and produces a new migration package with the required operations. No database connection is needed — fully offline. ```bash -prisma migration plan [--config ] [--name ] [--from ] [--to ] [--json] [-v] [-q] [--color/--no-color] +prisma migration plan [--config ] [--name ] [--from ] [--to ] [--rename =]... [--json] [-v] [-q] [--color/--no-color] ``` **Options:** @@ -940,6 +940,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t - `--name `: Name slug for the migration directory (default: `migration`) - `--from `: Starting contract reference (hash, prefix, ref name, migration directory, `^`, `@empty`, or filesystem path). `@empty` names the empty-database origin deliberately. Defaults to the `db` ref; when the ref is absent, greenfield only on an empty graph — over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed. - `--to `: Destination contract reference (same grammar as `--from`). Defaults to the emitted `contract.json`. Use `--to ^` to plan a rollback toward a predecessor state. +- `--rename =`: States that the storage a model maps to was renamed, so the plan renames it and keeps its data instead of dropping and recreating it. Either side may be `.`. Repeat once per rename. `migration new` accepts the same flag and scaffolds the operations `migration plan` would plan for it. MongoDB refuses the flag. - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output (debug info, timings) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index faa5c5367dba..773880e94c67 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -51,7 +51,7 @@ export interface MigrationNewOptions { readonly name?: string; readonly from?: string; /** - * Operator-stated renames (`--rename-table`): the scaffold starts with the rename operations `migration plan` would plan for them, and its manifest is attested over those ops, so a migration that only renames is complete as written. + * Operator-stated renames (`--rename`): the scaffold starts with the rename operations `migration plan` would plan for them, and its manifest is attested over those ops, so a migration that only renames is complete as written. */ readonly renames?: readonly StorageEntityRename[]; /** Renders the declarations of the destination snapshot from its `contract.json`. */ diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index 8540d0b3c62e..5c69ef24e1c7 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -70,7 +70,7 @@ export interface MigrationPlanOptions { readonly from?: string; readonly to?: string; /** - * Operator-stated renames (`--rename-table`), applied by the planner to the + * Operator-stated renames (`--rename`), applied by the planner to the * origin contract before it diffs; each becomes the first operations of * the planned migration. Only the delta leg receives them: an auto-baseline * leg plans the origin from nothing and has no previous state to rename. diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts index c56e515ad75b..2a88b8642cb2 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts @@ -9,7 +9,7 @@ import type { MigrationNewResult } from '../../control-api/operations/migration- import { executeMigrationNewCommand } from '../../control-api/operations/migration-new'; import type { CreateControlClient } from '../../control-api/types'; import { runCommandAction } from '../../utils/next-actions'; -import { parseRenameTableFlags } from '../../utils/rename-table-flag'; +import { parseRenameFlags } from '../../utils/rename-flag'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -84,17 +84,16 @@ export function createMigrationNewCommand(createClient: CreateControlClient) { brief: 'Starting contract hash (default: latest migration target)', placeholder: 'hash', }), - // biome-ignore lint/plugin/no-family-vocabulary: the flag is the SQL family's user-facing grammar for a stated rename - renameTable: flag.repeated({ + rename: flag.repeated({ brief: - 'A table this migration renames, as = (either side may be .); the scaffold starts with one rename call per value', + 'Storage a model maps to that this migration renames, as = (either side may be .); the scaffold starts with the operations migration plan would plan for it', placeholder: 'from=to', }), }, }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { - const renames = parseRenameTableFlags(args.flags.renameTable); + const renames = parseRenameFlags(args.flags.rename); if (!renames.ok) { return notOk(normalizeError(renames.failure)); } diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index c6649ec58323..67426ff99c7d 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -11,7 +11,7 @@ import { executeMigrationPlanCommand } from '../../control-api/operations/migrat import type { CreateControlClient } from '../../control-api/types'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; -import { parseRenameTableFlags } from '../../utils/rename-table-flag'; +import { parseRenameFlags } from '../../utils/rename-flag'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -234,17 +234,16 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { 'Destination contract reference; defaults to the emitted contract. Same grammar as --from', placeholder: 'contract', }), - // biome-ignore lint/plugin/no-family-vocabulary: the flag is the SQL family's user-facing grammar for a stated rename - renameTable: flag.repeated({ + rename: flag.repeated({ brief: - 'A table the contract change renames, as = (either side may be .); repeat per table', + 'Storage a model maps to that the contract change renames, as = (either side may be .); repeat per rename', placeholder: 'from=to', }), }, }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { - const renames = parseRenameTableFlags(args.flags.renameTable); + const renames = parseRenameFlags(args.flags.rename); if (!renames.ok) { return notOk(normalizeError(renames.failure)); } diff --git a/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts b/packages/1-framework/3-tooling/cli/src/utils/rename-flag.ts similarity index 80% rename from packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts rename to packages/1-framework/3-tooling/cli/src/utils/rename-flag.ts index b3a60a600092..45b66ff75fa8 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/rename-table-flag.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/rename-flag.ts @@ -9,11 +9,11 @@ const GRAMMAR = '=, each side optionally qualified as .=` value the operator gave, in the - * order given. A malformed value, a value whose two sides are the same - * entity, a value whose sides name different namespaces, and two values that - * share an old or a new name are each refused with - * `CLI.INVALID_RENAME_TABLE_FLAG`. + * Parses every `--rename =` value the operator gave, in the order given. A malformed value, a value whose two sides are the same, a value whose sides name different namespaces, and two values that share an old or a new name are each refused with `CLI.INVALID_RENAME_FLAG`. */ -export function parseRenameTableFlags( +export function parseRenameFlags( values: readonly string[] | undefined, ): Result { const renames: StorageEntityRename[] = []; @@ -81,7 +77,7 @@ export function parseRenameTableFlags( return notOk( invalid( value, - `a rename cannot move a table from namespace "${from.value.namespaceId}" to namespace "${to.value.namespaceId}"`, + `a rename cannot move "${coordinateLabel(from.value)}" to namespace "${to.value.namespaceId}"`, ), ); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts index fe16368110f4..9bf717d30a64 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts @@ -1,5 +1,5 @@ /** - * `migration new --rename-table` hands the target planner the stated renames together with the contract the migration starts from and the one it ends at, so the planner can scaffold the same rename operations `migration plan` would plan. + * `migration new --rename` hands the target planner the stated renames together with the contract the migration starts from and the one it ends at, so the planner can scaffold the same rename operations `migration plan` would plan. */ import { createTestCli } from '@prisma/cli-engine/testing'; @@ -47,7 +47,7 @@ async function scaffoldContext( return typeof received === 'object' && received !== null ? { ...received } : undefined; } -describe('migration new --rename-table', () => { +describe('migration new --rename', () => { it('passes the renames with the previous and the next contract', async () => { const project = await createOfflineProject({ storageHash: HASH_TO }); await seedMigrationPackage({ @@ -58,7 +58,7 @@ describe('migration new --rename-table', () => { }); await seedContractSnapshot({ migrationsDir: project.migrationsDir, storageHash: HASH_FROM }); - const context = await scaffoldContext(project, ['--rename-table', 'userProfile=UserProfile']); + const context = await scaffoldContext(project, ['--rename', 'userProfile=UserProfile']); expect(context?.['renames']).toMatchObject({ intents: [INTENT], @@ -71,7 +71,7 @@ describe('migration new --rename-table', () => { it('passes no previous contract when the project has no migration history', async () => { const project = await createOfflineProject({ storageHash: HASH_TO }); - const context = await scaffoldContext(project, ['--rename-table', 'userProfile=UserProfile']); + const context = await scaffoldContext(project, ['--rename', 'userProfile=UserProfile']); expect(context?.['renames']).toMatchObject({ intents: [INTENT], diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-rename-flag.test.ts similarity index 90% rename from packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts rename to packages/1-framework/3-tooling/cli/test/orm/migration-rename-flag.test.ts index 138ad850875e..1723c7b19332 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-rename-table-flag.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-rename-flag.test.ts @@ -1,5 +1,5 @@ /** - * `--rename-table` is parsed once, the same way, on `migration plan` and + * `--rename` is parsed once, the same way, on `migration plan` and * `migration new`, and the parsed intents reach each command's control * operation. The operations themselves are doubles here; what they do with the * intents is covered by the planner tests and the journeys. @@ -52,7 +52,7 @@ afterAll(() => { }); beforeEach(() => { - projectDir = createTestProjectDir('orm-migration-rename-table'); + projectDir = createTestProjectDir('orm-migration-rename'); projectDirs.push(projectDir); writeProjectManifest(projectDir); writeFileSync( @@ -116,16 +116,16 @@ const EXPECTED_RENAMES = [ { from: { namespaceId: 'auth', name: 'orderLine' }, to: { name: 'OrderLine' } }, ]; -describe('migration plan --rename-table', () => { +describe('migration plan --rename', () => { it('passes every parsed intent to the plan operation', async () => { const run = await harness().run( [ 'migration', 'plan', '--json', - '--rename-table', + '--rename', 'userProfile=UserProfile', - '--rename-table', + '--rename', 'auth.orderLine=OrderLine', ], { cwd: projectDir }, @@ -146,30 +146,29 @@ describe('migration plan --rename-table', () => { }); it('rejects a malformed value before the operation runs', async () => { - const run = await harness().run( - ['migration', 'plan', '--json', '--rename-table', 'userProfile'], - { cwd: projectDir }, - ); + const run = await harness().run(['migration', 'plan', '--json', '--rename', 'userProfile'], { + cwd: projectDir, + }); expect(run.exitCode).not.toBe(0); expect(envelopeOf(run.json)).toMatchObject({ ok: false, - error: { code: 'CLI.INVALID_RENAME_TABLE_FLAG' }, + error: { code: 'CLI.INVALID_RENAME_FLAG' }, }); expect(mocks.executeMigrationPlanCommand).not.toHaveBeenCalled(); }); }); -describe('migration new --rename-table', () => { +describe('migration new --rename', () => { it('passes every parsed intent to the scaffold operation', async () => { const run = await harness().run( [ 'migration', 'new', '--json', - '--rename-table', + '--rename', 'userProfile=UserProfile', - '--rename-table', + '--rename', 'auth.orderLine=OrderLine', ], { cwd: projectDir }, @@ -183,14 +182,14 @@ describe('migration new --rename-table', () => { }); it('rejects a malformed value before the operation runs', async () => { - const run = await harness().run(['migration', 'new', '--json', '--rename-table', 'a=b=c'], { + const run = await harness().run(['migration', 'new', '--json', '--rename', 'a=b=c'], { cwd: projectDir, }); expect(run.exitCode).not.toBe(0); expect(envelopeOf(run.json)).toMatchObject({ ok: false, - error: { code: 'CLI.INVALID_RENAME_TABLE_FLAG' }, + error: { code: 'CLI.INVALID_RENAME_FLAG' }, }); expect(mocks.executeMigrationNewCommand).not.toHaveBeenCalled(); }); diff --git a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts b/packages/1-framework/3-tooling/cli/test/utils/rename-flag.test.ts similarity index 66% rename from packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts rename to packages/1-framework/3-tooling/cli/test/utils/rename-flag.test.ts index 2439b10a777e..7f7f21c51518 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/rename-table-flag.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/rename-flag.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { parseRenameTableFlags } from '../../src/utils/rename-table-flag'; +import { parseRenameFlags } from '../../src/utils/rename-flag'; function parsedValue(values: readonly string[]) { - const result = parseRenameTableFlags(values); + const result = parseRenameFlags(values); expect(result.ok).toBe(true); return result.ok ? result.value : undefined; } function failureOf(values: readonly string[]) { - const result = parseRenameTableFlags(values); + const result = parseRenameFlags(values); expect(result.ok).toBe(false); return result.ok ? undefined : result.failure.toEnvelope(); } -describe('parseRenameTableFlags', () => { +describe('parseRenameFlags', () => { it('parses an unqualified pair', () => { expect(parsedValue(['userProfile=UserProfile'])).toEqual([ { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, @@ -28,7 +28,7 @@ describe('parseRenameTableFlags', () => { }); it('keeps repeated values in the order given', () => { - const result = parseRenameTableFlags(['a=b', 'c=d']); + const result = parseRenameFlags(['a=b', 'c=d']); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.map((intent) => `${intent.from.name}>${intent.to.name}`)).toEqual([ @@ -49,11 +49,11 @@ describe('parseRenameTableFlags', () => { ['empty namespace', '.userProfile=UserProfile'], ['empty name after the namespace', 'auth.=UserProfile'], ['blank value', ' '], - ])('rejects a malformed value (%s) with CLI.INVALID_RENAME_TABLE_FLAG', (_label, value) => { + ])('rejects a malformed value (%s) with CLI.INVALID_RENAME_FLAG', (_label, value) => { const envelope = failureOf([value]); expect(envelope).toMatchObject({ - code: 'CLI.INVALID_RENAME_TABLE_FLAG', - summary: expect.stringContaining(value.trim() === '' ? '--rename-table' : value), + code: 'CLI.INVALID_RENAME_FLAG', + summary: expect.stringContaining(value.trim() === '' ? '--rename' : value), }); expect(envelope?.fix).toContain('='); }); @@ -67,34 +67,34 @@ describe('parseRenameTableFlags', () => { ]); }); - it('rejects a value that moves a table to another namespace', () => { + it('rejects a value that moves the storage to another namespace', () => { expect(failureOf(['auth.userProfile=sales.UserProfile'])?.summary).toBe( - 'Invalid --rename-table value "auth.userProfile=sales.UserProfile": a rename cannot move a table from namespace "auth" to namespace "sales".', + 'Invalid --rename value "auth.userProfile=sales.UserProfile": a rename cannot move "auth.userProfile" to namespace "sales".', ); }); - it('rejects a value whose two sides are the same table', () => { + it('rejects a value whose two sides name the same storage', () => { expect(failureOf(['userProfile=userProfile'])).toMatchObject({ - code: 'CLI.INVALID_RENAME_TABLE_FLAG', + code: 'CLI.INVALID_RENAME_FLAG', summary: expect.stringContaining('userProfile=userProfile'), }); }); - it('rejects two values that rename the same table, naming the table', () => { + it('rejects two values that rename the same storage, naming it', () => { expect(failureOf(['userProfile=UserProfile', 'userProfile=Profile'])?.summary).toBe( - 'Invalid --rename-table value "userProfile=Profile": "userProfile" is renamed more than once.', + 'Invalid --rename value "userProfile=Profile": "userProfile" is renamed more than once.', ); }); - it('names the qualified table when two qualified values rename it', () => { + it('names the qualified storage when two qualified values rename it', () => { expect(failureOf(['auth.userProfile=UserProfile', 'auth.userProfile=Profile'])?.summary).toBe( - 'Invalid --rename-table value "auth.userProfile=Profile": "auth.userProfile" is renamed more than once.', + 'Invalid --rename value "auth.userProfile=Profile": "auth.userProfile" is renamed more than once.', ); }); - it('rejects two values that rename to the same table, naming the table', () => { + it('rejects two values that rename to the same name, naming it', () => { expect(failureOf(['userProfile=UserProfile', 'profile=UserProfile'])?.summary).toBe( - 'Invalid --rename-table value "profile=UserProfile": more than one value renames to "UserProfile".', + 'Invalid --rename value "profile=UserProfile": more than one value renames to "UserProfile".', ); }); }); diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index 30980f6eb6f0..2d64fb6eb8c0 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -61,7 +61,7 @@ export function detectTableNameCaseChanges(input: { conflicts.push({ kind: 'tableNameCaseChanged', summary: `${TABLE_NAME_CASE_CHANGED_CODE}: table "${create.tableName}" would be created and table "${drop.tableName}" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model ${create.tableName} points at "${create.tableName}" instead of "${drop.tableName}".`, - why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename-table "${drop.tableName}=${create.tableName}" (either side may be .), and the plan carries one renameTable operation in place of the drop and the create; in a project that uses db update, rename it by hand: ALTER TABLE "${drop.tableName}" RENAME TO "${create.tableName}" (schema-qualified where applicable), after which the plan is empty.`, + why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "${drop.tableName}=${create.tableName}" (either side may be .), and the plan carries one renameTable operation in place of the drop and the create; in a project that uses db update, rename it by hand: ALTER TABLE "${drop.tableName}" RENAME TO "${create.tableName}" (schema-qualified where applicable), after which the plan is empty.`, location: { namespaceId: create.namespaceId, entityKind: 'table', diff --git a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts index 6a4c234f2ca5..d2507f3f226c 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts @@ -38,7 +38,7 @@ export function tableRenameScaffoldError( ); } -/** A `--rename-table =` intent as the CLI parsed it. */ +/** A `--rename =` intent as the CLI parsed it. */ export type TableRenameIntent = StorageEntityRename; /** An intent after both sides were matched against the contracts. */ @@ -117,7 +117,7 @@ function resolvedTableLabel(namespaceId: string, tableName: string): string { function unmatched(intent: TableRenameIntent, reason: string): SqlPlannerConflict { return { kind: 'tableRenameUnmatched', - summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename-table "${tableRenameIntentLabel(intent)}" does not match the contracts: ${reason}.`, + summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename "${tableRenameIntentLabel(intent)}" does not match the contracts: ${reason}.`, why: 'A rename intent must name a table of the previous contract as its old name and a table of the next contract as its new name, and the new name must not already exist in the previous contract. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed.', meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: intent.from.name, to: intent.to.name }, }; @@ -317,7 +317,7 @@ export function applyTableRenameIntents( return notOk( input.intents.map((intent) => ({ kind: 'unsupportedOperation', - summary: `${TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE}: --rename-table "${tableRenameIntentLabel(intent)}" needs a previous contract to apply the rename to, and this plan starts from an empty database.`, + summary: `${TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE}: --rename "${tableRenameIntentLabel(intent)}" needs a previous contract to apply the rename to, and this plan starts from an empty database.`, why: 'A rename intent only makes sense between two contracts. Plan from the migration that created the table. A database managed with db update has no migration history: rename the table there by hand with ALTER TABLE ... RENAME TO ..., and drop the flag.', meta: { code: TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE }, })), @@ -343,14 +343,14 @@ export function applyTableRenameIntents( conflicts.push( unmatched( intent, - `table "${resolvedTableLabel(namespaceId, from)}" is already renamed by --rename-table "${tableRenameIntentLabel(earlierOld)}"`, + `table "${resolvedTableLabel(namespaceId, from)}" is already renamed by --rename "${tableRenameIntentLabel(earlierOld)}"`, ), ); } else if (earlierNew !== undefined) { conflicts.push( unmatched( intent, - `table "${resolvedTableLabel(namespaceId, to)}" is already the new name in --rename-table "${tableRenameIntentLabel(earlierNew)}"`, + `table "${resolvedTableLabel(namespaceId, to)}" is already the new name in --rename "${tableRenameIntentLabel(earlierNew)}"`, ), ); } else { diff --git a/packages/2-sql/9-family/test/table-rename-intents.test.ts b/packages/2-sql/9-family/test/table-rename-intents.test.ts index 59811fa99cc4..650a8f2b1b76 100644 --- a/packages/2-sql/9-family/test/table-rename-intents.test.ts +++ b/packages/2-sql/9-family/test/table-rename-intents.test.ts @@ -375,7 +375,7 @@ describe('applyTableRenameIntents', () => { expect(result.failure).toEqual([ { kind: 'unsupportedOperation', - summary: `${TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE}: --rename-table "userProfile=UserProfile" needs a previous contract to apply the rename to, and this plan starts from an empty database.`, + summary: `${TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE}: --rename "userProfile=UserProfile" needs a previous contract to apply the rename to, and this plan starts from an empty database.`, why: 'A rename intent only makes sense between two contracts. Plan from the migration that created the table. A database managed with db update has no migration history: rename the table there by hand with ALTER TABLE ... RENAME TO ..., and drop the flag.', meta: { code: TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE }, }, @@ -406,7 +406,7 @@ describe('applyTableRenameIntents', () => { ).toEqual([ expect.objectContaining({ kind: 'tableRenameUnmatched', - summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename-table "auth.userProfile=Member" does not match the contracts: table "auth.userProfile" is already renamed by --rename-table "userProfile=Profile".`, + summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename "auth.userProfile=Member" does not match the contracts: table "auth.userProfile" is already renamed by --rename "userProfile=Profile".`, meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: 'userProfile', to: 'Member' }, }), ]); @@ -421,7 +421,7 @@ describe('applyTableRenameIntents', () => { ).toEqual([ expect.objectContaining({ kind: 'tableRenameUnmatched', - summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename-table "account=auth.Profile" does not match the contracts: table "auth.Profile" is already the new name in --rename-table "userProfile=Profile".`, + summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename "account=auth.Profile" does not match the contracts: table "auth.Profile" is already the new name in --rename "userProfile=Profile".`, }), ]); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index b46f3f43c186..bba629feb1e0 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -168,7 +168,7 @@ describe('Postgres planner table-name case guard', () => { ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); expect(result.conflicts[0]?.why).toContain( - 'in a project with migration history, state the rename when planning: prisma migration plan --rename-table "userProfile=UserProfile"', + 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile"', ); expect(result.conflicts[0]?.why).toContain( 'in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile"', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts index 680c88456218..b8974f222327 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts @@ -1,5 +1,5 @@ /** - * `--rename-table` on a row-level-security table: the Postgres `rls` marker and every policy name the table they attach to, so a rename moves them with the table. `ALTER TABLE ... RENAME TO` keeps RLS enabled and the policies attached, and policy names do not derive from the table name, so the plan is the rename alone. + * `--rename` on a row-level-security table: the Postgres `rls` marker and every policy name the table they attach to, so a rename moves them with the table. `ALTER TABLE ... RENAME TO` keeps RLS enabled and the policies attached, and policy names do not derive from the table name, so the plan is the rename alone. */ import { type Contract, coreHash, profileHash } from '@internal/contract/types'; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts index 8257cfad2436..46a19e0a1aad 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts @@ -1,5 +1,5 @@ /** - * `--rename-table` on a Postgres table that carries indexes, constraints and foreign keys. + * `--rename` on a Postgres table that carries indexes, constraints and foreign keys. * * Wire-named indexes and checks whose prefix derives from the table name pair by content hash and plan as renames. Primary keys, uniques and foreign keys the contract leaves unnamed were named by the planner from the old table name, so each gets a companion constraint rename to the name the planner now derives from the new table name. Explicitly named constraints keep their names, and foreign keys on other tables keep theirs. */ diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts index 43dbeff7ba80..34a22c763ab0 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts @@ -1,5 +1,5 @@ /** - * `--rename-table` intents on the Postgres planner: the stated tables are + * `--rename` intents on the Postgres planner: the stated tables are * renamed in the previous contract before the diff, one `renameTable` op is * prepended per intent, and an intent that matches neither side is a * planning failure rather than a warning. diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts index 6a9b35f3186a..82d8d39372fd 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts @@ -1,5 +1,5 @@ /** - * `migration new --rename-table` on Postgres: the scaffold carries the same rename operations `migration plan` plans for the stated renames, and refuses the intents `migration plan` refuses. + * `migration new --rename` on Postgres: the scaffold carries the same rename operations `migration plan` plans for the stated renames, and refuses the intents `migration plan` refuses. */ import type { Contract } from '@internal/contract/types'; diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts index 082a144b7e39..a58a45c3b321 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts @@ -138,7 +138,7 @@ describe('SQLite planner table-name case guard', () => { ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); expect(result.conflicts[0]?.why).toContain( - 'in a project with migration history, state the rename when planning: prisma migration plan --rename-table "userProfile=UserProfile"', + 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile"', ); expect(result.conflicts[0]?.why).toContain( 'in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile"', diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts index 9d41c5204a8f..8e669fca25b4 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts @@ -1,5 +1,5 @@ /** - * `--rename-table` on a SQLite table that carries indexes, constraints and foreign keys. + * `--rename` on a SQLite table that carries indexes, constraints and foreign keys. * * SQLite renames a table's automatic indexes and rewrites foreign keys that reference it, and it names no primary key, unique or foreign key the contract leaves unnamed, so those need nothing beyond the rename. A wire-named index whose prefix derives from the table name keeps its old name through `ALTER TABLE ... RENAME TO`, and SQLite cannot rename an index, so it is dropped and created under the new name; an index holds no rows, so nothing is lost. */ diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts index 1e52cf6fbfab..9fce44990f0b 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts @@ -1,5 +1,5 @@ /** - * `--rename-table` intents on the SQLite planner: the stated tables are + * `--rename` intents on the SQLite planner: the stated tables are * renamed in the previous contract before the diff, one `renameTable` op is * prepended per intent, and an intent that matches neither side is a * planning failure rather than a warning. diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts index dde45866cdcd..0aeeaea8c255 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts @@ -1,5 +1,5 @@ /** - * `migration new --rename-table` on SQLite: the scaffold carries the same rename operations `migration plan` plans for the stated renames, and refuses the intents `migration plan` refuses, including a namespace qualifier SQLite does not have. + * `migration new --rename` on SQLite: the scaffold carries the same rename operations `migration plan` plans for the stated renames, and refuses the intents `migration plan` refuses, including a namespace qualifier SQLite does not have. */ import type { Contract } from '@internal/contract/types'; diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 71f2e5ce630e..eb8e7f29de68 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -30,7 +30,7 @@ pnpm test:journeys | `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column, tests `--no-interactive` rejection, `--json` error envelope, and `--json -y` auto-accept. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | -| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard and a stale `--rename-table` intent fails planning, then `migration plan --rename-table userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. Journey R2 authors the same rename through `migration new --rename-table`, whose scaffold carries the same operations. In both, `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies | +| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard and a stale `--rename` intent fails planning, then `migration plan --rename userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. Journey R2 authors the same rename through `migration new --rename`, whose scaffold carries the same operations. In both, `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies | | `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts index 2201868d1809..361fa28f3a74 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -1,7 +1,7 @@ /** * Renaming a table keeps its rows (Postgres). * - * A model whose table name changes used to plan as `dropTable` plus `createTable`. With `--rename-table =` the operator states the rename and the migration carries a `renameTable` operation instead, plus a rename of each constraint and index named after the old table. + * A model whose table name changes used to plan as `dropTable` plus `createTable`. With `--rename =` the operator states the rename and the migration carries a `renameTable` operation instead, plus a rename of each constraint and index named after the old table. * * Both journeys create `userProfile` with rows, a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map` so it names `UserProfile`. After `migrate`, the rows, the policy and RLS are kept, every constraint and index carries the new table name, `db verify --schema-only` is clean, a plan with no schema change is empty, and a later migration that removes the unique constraint, the foreign key and the index applies. * @@ -160,7 +160,7 @@ async function expectLaterChangesApply( } withTempDir(({ createTempDir }) => { - describe('Journey R1: rename a table with migration plan --rename-table', () => { + describe('Journey R1: rename a table with migration plan --rename', () => { const db = useDevDatabase(); it( @@ -183,7 +183,7 @@ withTempDir(({ createTempDir }) => { expect( `${bareError?.why}\n${bareError?.summary}\n${JSON.stringify(bareError?.nextActions)}`, 'R1.05: guard points at the flag', - ).toContain('--rename-table'); + ).toContain('prisma migration plan --rename "userProfile=UserProfile"'); expect(getMigrationDirs(ctx), 'R1.05: nothing written').toHaveLength(1); const stale = await runMigrationPlan(ctx, [ @@ -191,7 +191,7 @@ withTempDir(({ createTempDir }) => { 'stale', '--from', origin, - '--rename-table', + '--rename', 'userProfile=Nope', '--json', ]); @@ -208,7 +208,7 @@ withTempDir(({ createTempDir }) => { 'rename-user-profile', '--from', origin, - '--rename-table', + '--rename', 'userProfile=UserProfile', '--json', ]); @@ -232,7 +232,7 @@ withTempDir(({ createTempDir }) => { ); }); - describe('Journey R2: rename a table with migration new --rename-table', () => { + describe('Journey R2: rename a table with migration new --rename', () => { const db = useDevDatabase(); it( @@ -248,7 +248,7 @@ withTempDir(({ createTempDir }) => { const scaffold = await runMigrationNew(ctx, [ '--name', 'rename-user-profile', - '--rename-table', + '--rename', 'userProfile=UserProfile', ]); expect(scaffold.exitCode, `R2.05: migration new: ${scaffold.stderr}`).toBe(0); diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts index 759bb9fbc365..f987a0dad188 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -1,7 +1,7 @@ /** * Renaming a table keeps its rows (SQLite). * - * The SQLite twin of `rename-table-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. Create `userProfile` with rows, a unique constraint, a foreign key and an index, then drop the `@@map` so the model names `UserProfile`. `migration plan --rename-table` plans the rename plus a drop and a create of each index named after the old table; `migrate` keeps the rows; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. + * The SQLite twin of `rename-table-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. Create `userProfile` with rows, a unique constraint, a foreign key and an index, then drop the `@@map` so the model names `UserProfile`. `migration plan --rename` plans the rename plus a drop and a create of each index named after the old table; `migrate` keeps the rows; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. */ import { readFileSync, writeFileSync } from 'node:fs'; @@ -100,7 +100,7 @@ function withDatabase(dbPath: string, run: (db: DatabaseSync) => T): T { } withTempDir(({ createTempDir }) => { - describe('Journey R3 (SQLite): rename a table with migration plan --rename-table', () => { + describe('Journey R3 (SQLite): rename a table with migration plan --rename', () => { it( 'stale intent fails; stated intent renames the table and recreates its named indexes, keeps the rows, and later changes apply', async () => { @@ -136,7 +136,7 @@ withTempDir(({ createTempDir }) => { 'stale', '--from', origin, - '--rename-table', + '--rename', 'userProfile=Nope', '--json', ]); @@ -151,7 +151,7 @@ withTempDir(({ createTempDir }) => { 'rename-user-profile', '--from', origin, - '--rename-table', + '--rename', 'userProfile=UserProfile', '--json', ]); From 8ff541191ae5434640d9378efa5dd016c59762c0 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 13:33:25 +0200 Subject: [PATCH 25/55] docs: put two doc blocks back above the declarations they describe MigrationScaffoldRenames was inserted between the MigrationScaffoldContext doc block and its interface, and on SQLite caseOnly and viaName were inserted under the block that explains executeSteps. Both blocks are back above their declarations. A sweep of the doc blocks this branch adds found no other case; the stacked block above MigrationRunnerPerSpaceOptions was already on main. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-migration-types.ts | 14 +++++++------- .../sqlite/src/core/migrations/op-factory-call.ts | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index c1b1941f236e..20e262262214 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -659,13 +659,6 @@ export interface TargetMigrationsCapability< // Migration Scaffolding SPI // ============================================================================ -/** - * Context for rendering migration source files. - * - * Kept minimal: only the paths a target might need to compute relative imports - * (e.g. the contract `.d.ts` import for typed-contract builders). Passed to - * `MigrationPlanner.emptyMigration(context)`. - */ /** * Operator-stated renames for a scaffold, with the two contracts a planner resolves them against. `fromContract` is `null` when the migration has no previous contract. */ @@ -679,6 +672,13 @@ export interface MigrationScaffoldRenames< readonly frameworkComponents: ReadonlyArray>; } +/** + * Context for rendering migration source files. + * + * Kept minimal: only the paths a target might need to compute relative imports + * (e.g. the contract `.d.ts` import for typed-contract builders). Passed to + * `MigrationPlanner.emptyMigration(context)`. + */ export interface MigrationScaffoldContext< TFamilyId extends string = string, TTargetId extends string = string, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index 5e456cb90cc7..deec663a33d9 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -332,11 +332,6 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { }; } - /** - * SQLite compares table names case-insensitively, so a rename that only - * changes case (`userProfile` to `UserProfile`) is refused as "already - * exists" when done in one statement. It goes through a temporary name. - */ private caseOnly(): boolean { return this.oldTableName.toLowerCase() === this.tableName.toLowerCase(); } @@ -345,6 +340,11 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { return `_prisma_rename_${this.tableName}`; } + /** + * SQLite compares table names case-insensitively, so a rename that only + * changes case (`userProfile` to `UserProfile`) is refused as "already + * exists" when done in one statement. It goes through a temporary name. + */ private executeSteps(): Op['execute'] { const from = quoteIdentifier(this.oldTableName); const to = quoteIdentifier(this.tableName); From 937b6b6c0ebb5eea9c16a5549b56332cff276bc1 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 13:34:17 +0200 Subject: [PATCH 26/55] fix(postgres): rename an unnamed constraint to the explicit name the next contract gives it When a renamed table had a constraint the previous contract left unnamed and the next contract named explicitly, no rename was planned. The database kept the name derived from the old table, while later plans used the contract name, so dropping that constraint would fail. The companion rename now goes from the old derived name to the next contract explicit name when there is one, and to the new derived name otherwise. It plans nothing when the two names are equal. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../table-rename-constraint-renames.ts | 47 +++++++++---------- ...rename-table-planner.table-objects.test.ts | 36 +++++++++++++- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts index 209b7cfd3fe8..2de77dba1c16 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts @@ -19,53 +19,48 @@ function sameColumns(left: readonly string[], right: readonly string[]): boolean } /** - * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the previous contract left unnamed carries a name the planner derived from the old table name, and every later plan derives it from the new one, so it is renamed to match. A constraint the previous contract named keeps its name, and so does one the next contract names explicitly. Indexes and checks are not handled here: their wire names pair by content hash in the ordinary rename passes. + * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the previous contract left unnamed carries a name the planner derived from the old table name. It is renamed to the name the next contract gives it explicitly, or otherwise to the name the planner now derives from the new table name. A constraint the previous contract named keeps its name. Indexes and checks are not handled here: their wire names pair by content hash in the ordinary rename passes. */ export function constraintRenamesForTableRename( input: TableRenameConstraintInput, ): readonly RenameConstraintCall[] { const { schemaName, from, to, previous, next } = input; - const rename = ( + const renameIfChanged = ( kind: 'primaryKey' | 'unique' | 'foreignKey', oldName: string, newName: string, - ): RenameConstraintCall => new RenameConstraintCall(schemaName, to, kind, oldName, newName); + ): readonly RenameConstraintCall[] => + oldName === newName ? [] : [new RenameConstraintCall(schemaName, to, kind, oldName, newName)]; const primaryKey = - previous.primaryKey !== undefined && - previous.primaryKey.name === undefined && - next.primaryKey?.name === undefined - ? [rename('primaryKey', defaultPrimaryKeyName(from), defaultPrimaryKeyName(to))] + previous.primaryKey !== undefined && previous.primaryKey.name === undefined + ? renameIfChanged( + 'primaryKey', + defaultPrimaryKeyName(from), + next.primaryKey?.name ?? defaultPrimaryKeyName(to), + ) : []; const uniques = previous.uniques - .filter( - (unique) => - unique.name === undefined && - next.uniques.find((candidate) => sameColumns(candidate.columns, unique.columns))?.name === - undefined, - ) - .map((unique) => - rename( + .filter((unique) => unique.name === undefined) + .flatMap((unique) => + renameIfChanged( 'unique', defaultUniqueName(from, unique.columns), - defaultUniqueName(to, unique.columns), + next.uniques.find((candidate) => sameColumns(candidate.columns, unique.columns))?.name ?? + defaultUniqueName(to, unique.columns), ), ); const foreignKeys = previous.foreignKeys - .filter( - (fk) => - fk.name === undefined && - next.foreignKeys.find((candidate) => - sameColumns(candidate.source.columns, fk.source.columns), - )?.name === undefined, - ) - .map((fk) => - rename( + .filter((fk) => fk.name === undefined) + .flatMap((fk) => + renameIfChanged( 'foreignKey', defaultForeignKeyName(from, fk.source.columns), - defaultForeignKeyName(to, fk.source.columns), + next.foreignKeys.find((candidate) => + sameColumns(candidate.source.columns, fk.source.columns), + )?.name ?? defaultForeignKeyName(to, fk.source.columns), ), ); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts index 46a19e0a1aad..1abd631898b0 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts @@ -165,7 +165,7 @@ describe('Postgres planner rename-table intents with table objects', () => { expect(await planRename(spec)).toEqual([RENAME_TABLE]); }); - it('keeps the old name of an unnamed unique that the next contract names explicitly', async () => { + it('plans nothing more for an unnamed unique the next contract names with its current name', async () => { expect( await planRename( { uniques: [{ columns: ['email'] }] }, @@ -173,5 +173,39 @@ describe('Postgres planner rename-table intents with table objects', () => { ), ).toEqual([RENAME_TABLE]); }); + + it('renames unnamed constraints to the explicit names the next contract gives them', async () => { + expect( + await planRename( + { + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [ + { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, + ], + }, + { + primaryKey: { columns: ['id'], name: 'profile_pk' }, + uniques: [{ columns: ['email'], name: 'profile_email_unique' }], + foreignKeys: (tableName) => [ + { + source: reference(tableName, ['accountId']), + target: reference('account', ['id']), + name: 'profile_account_fk', + }, + ], + }, + ), + ).toEqual([ + RENAME_TABLE, + ['ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "profile_pk"'], + [ + 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_email_key" TO "profile_email_unique"', + ], + [ + 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_accountId_fkey" TO "profile_account_fk"', + ], + ]); + }); }); }); From 95e5893d496dfaf073e35f626fd382c4b2709d2e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 13:36:09 +0200 Subject: [PATCH 27/55] fix(sqlite): check unique constraints by column set, not by count, after a rebuild Round 1 made a table rebuild postcheck the number of UNIQUE constraint indexes. SQLite folds a unique that repeats a non-integer primary key into the primary key index, so after a correct rebuild of such a table the count was one short, and the migration was refused. The same folding could hide an extra unique that was not removed, because the counts still matched. The postcheck now fails while any UNIQUE constraint index matches none of the expected uniques by column set. New tests run the postchecks against a real SQLite database: they hold after a rebuild whose unique repeats a text primary key, and fail while a removed unique is still there. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/operations/tables.ts | 34 ++++++----- .../recreate-postchecks.execution.test.ts | 57 +++++++++++++++++++ .../migrations/recreate-postchecks.test.ts | 6 +- 3 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.execution.test.ts diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts index 4763e72a03a8..d82c2b4ef8c0 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts @@ -245,6 +245,16 @@ function quoteSqlList(values: readonly string[]): string { return values.map((v) => `'${escapeLiteral(v)}'`).join(', '); } +/** + * A condition on the index aliased `l`: it covers exactly these columns. Order is not checked, because SQLite identifies a unique index by its column set. + */ +function indexCoversExactly(columns: readonly string[]): string { + return ( + `(SELECT COUNT(*) FROM pragma_index_info(l.name)) = ${columns.length}` + + ` AND (SELECT COUNT(*) FROM pragma_index_info(l.name) WHERE name IN (${quoteSqlList(columns)})) = ${columns.length}` + ); +} + function columnNameFromNode(issue: SchemaDiffIssue): string | undefined { const node = issue.expected ?? issue.actual; if (node === undefined) return undefined; @@ -382,32 +392,30 @@ export function buildRecreatePostchecks( if (hasUniqueIssue) { for (const u of spec.uniques ?? []) { - const colCount = u.columns.length; const description = u.name ? `verify unique constraint "${u.name}" on "${tableName}"` : `verify unique constraint (${u.columns.join(', ')}) on "${tableName}"`; - // Match any unique index whose covered columns are exactly the expected - // set. Order is intentionally not checked — SQLite's unique-index - // identity is column-set, not column-sequence. checks.push({ description, sql: `SELECT EXISTS (SELECT 1 FROM pragma_index_list('${t}') l` + - ` WHERE l."unique" = 1` + - ` AND (SELECT COUNT(*) FROM pragma_index_info(l.name)) = ${colCount}` + - ` AND (SELECT COUNT(*) FROM pragma_index_info(l.name) WHERE name IN (${quoteSqlList(u.columns)})) = ${colCount})`, + ` WHERE l."unique" = 1 AND ${indexCoversExactly(u.columns)})`, }); } } - // The checks above only prove expected uniques exist, so removing the last - // one would leave the postcheck already true and the runner would skip the - // recreate. `origin = 'u'` counts UNIQUE constraints, not CREATE INDEX. + // The checks above only prove expected uniques exist, so removing one would + // leave the postcheck already true and the runner would skip the recreate. + // This check fails while a UNIQUE constraint index (`origin = 'u'`) matches + // no expected unique. It does not count indexes: SQLite folds a unique that + // repeats a non-integer primary key into the primary key's index. if (hasUniqueIssue) { - const expected = spec.uniques?.length ?? 0; + const expected = (spec.uniques ?? []).map((u) => `(${indexCoversExactly(u.columns)})`); checks.push({ - description: `verify "${tableName}" has exactly ${expected} unique constraints`, - sql: `SELECT (SELECT COUNT(*) FROM pragma_index_list('${t}') WHERE origin = 'u') = ${expected}`, + description: `verify "${tableName}" has no unique constraint besides the expected ones`, + sql: + `SELECT NOT EXISTS (SELECT 1 FROM pragma_index_list('${t}') l` + + ` WHERE l.origin = 'u' AND NOT (${expected.length === 0 ? '0' : expected.join(' OR ')}))`, }); } diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.execution.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.execution.test.ts new file mode 100644 index 000000000000..a1471a3260a8 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.execution.test.ts @@ -0,0 +1,57 @@ +/** + * The recreate postchecks, run against a real SQLite database in the shape a rebuild leaves behind. They must hold for a correct rebuild and fail while a removed constraint remains. + */ + +import { DatabaseSync } from 'node:sqlite'; +import { describe, expect, it } from 'vitest'; +import type { SqliteTableSpec } from '../../src/core/migrations/operations/shared'; +import { buildRecreatePostchecks } from '../../src/core/migrations/operations/tables'; +import { issue, unique } from './node-issue-helpers'; + +const codesSpec: SqliteTableSpec = { + columns: [ + { name: 'code', typeSql: 'TEXT', defaultSql: '', nullable: false }, + { name: 'label', typeSql: 'TEXT', defaultSql: '', nullable: false }, + ], + primaryKey: { columns: ['code'] }, + uniques: [{ columns: ['code'] }], + foreignKeys: [], +}; + +const uniqueIssues = [ + issue({ path: ['database', 'codes', 'unique:code'], expected: unique(['code']) }), +]; + +function postcheckResults(createTableSql: string): Record { + const db = new DatabaseSync(':memory:'); + try { + db.exec(createTableSql); + return Object.fromEntries( + buildRecreatePostchecks('codes', uniqueIssues, codesSpec).map((check) => [ + check.description, + Object.values(db.prepare(check.sql).get() ?? {})[0] === 1, + ]), + ); + } finally { + db.close(); + } +} + +describe('buildRecreatePostchecks on SQLite', () => { + it('holds after a rebuild whose unique constraint repeats a text primary key', () => { + const results = postcheckResults( + 'CREATE TABLE "codes" ("code" TEXT NOT NULL, "label" TEXT NOT NULL, PRIMARY KEY ("code"), UNIQUE ("code"))', + ); + + expect(Object.values(results)).toEqual(Object.values(results).map(() => true)); + expect(Object.keys(results).length).toBeGreaterThan(1); + }); + + it('fails while a unique constraint the rebuild removes is still there', () => { + const results = postcheckResults( + 'CREATE TABLE "codes" ("code" TEXT NOT NULL, "label" TEXT NOT NULL, PRIMARY KEY ("code"), UNIQUE ("code"), UNIQUE ("label"))', + ); + + expect(Object.values(results)).toContain(false); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts index 455ba668ac3a..2416e1273ecf 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts @@ -248,7 +248,7 @@ describe('buildRecreatePostchecks — constraints', () => { expect(fkChecks[1]!.sql).toContain('HAVING COUNT(*) = 2'); }); - it('checks the unique constraint count, so removing the last unique is not already satisfied', () => { + it('checks that no unexpected unique remains, so removing the last unique is not already satisfied', () => { const spec = tableSpec({ columns: [colSpec({ name: 'email' })], uniques: [] }); const issues = [ issue({ path: ['database', 'users', 'unique:email'], actual: unique(['email']) }), @@ -256,8 +256,8 @@ describe('buildRecreatePostchecks — constraints', () => { expect(buildRecreatePostchecks('users', issues, spec)).toEqual([ { - description: 'verify "users" has exactly 0 unique constraints', - sql: `SELECT (SELECT COUNT(*) FROM pragma_index_list('users') WHERE origin = 'u') = 0`, + description: 'verify "users" has no unique constraint besides the expected ones', + sql: `SELECT NOT EXISTS (SELECT 1 FROM pragma_index_list('users') l WHERE l.origin = 'u' AND NOT (0))`, }, ]); }); From 820e50d38bf08729d85e6b241486c2c0fadcbe23 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 13:37:07 +0200 Subject: [PATCH 28/55] docs(upgrade): detect factory-name checks for renameCheckConstraint The extension fragment asks authors to change code that compares a call factoryName with renameCheckConstraint, but its only predicate matched the class name, so a file that tests the factory name without importing the class was never flagged. Three predicates now match a comparison in either order and a switch case on the quoted name. None matches this.renameCheckConstraint( calls, which stay valid. All were tested against five matching lines and five near misses. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../pending/rename-constraint-call/extension/instructions.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md b/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md index ea0e70b7c3fd..3a60c47f4dd8 100644 --- a/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md +++ b/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md @@ -6,6 +6,9 @@ changes: glob: "**/*.{ts,mts,cts,js,mjs,cjs}" matches: - '(? Date: Thu, 17 Sep 2026 13:38:40 +0200 Subject: [PATCH 29/55] fix(sql): print the code once in migration new rename refusals The scaffold error reused the planner conflict summaries as its message, and those start with the error code, so the CLI printed the code twice. The message now drops the leading ": " from each summary. The Postgres scaffold tests pin the whole message. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/table-rename-intents.ts | 14 ++++++++++++-- .../test/migrations/rename-table-scaffold.test.ts | 9 +++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts index d2507f3f226c..b827686e2451 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts @@ -31,13 +31,23 @@ export function tableRenameScaffoldError( const noPreviousContract = conflicts.every( (conflict) => conflict.meta?.['code'] === TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE, ); + const code = noPreviousContract + ? TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE + : TABLE_RENAME_UNMATCHED_CODE; return sqlFamilyError( - noPreviousContract ? TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE : TABLE_RENAME_UNMATCHED_CODE, - conflicts.map((conflict) => conflict.summary).join('\n'), + code, + conflicts.map(summaryWithoutCode).join('\n'), ifDefined('why', conflicts[0]?.why), ); } +function summaryWithoutCode(conflict: SqlPlannerConflict): string { + const prefix = `${String(conflict.meta?.['code'])}: `; + return conflict.summary.startsWith(prefix) + ? conflict.summary.slice(prefix.length) + : conflict.summary; +} + /** A `--rename =` intent as the CLI parsed it. */ export type TableRenameIntent = StorageEntityRename; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts index 82d8d39372fd..c301939e590b 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts @@ -130,7 +130,11 @@ describe('Postgres scaffold rename-table intents', () => { it('refuses with MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT when there is no previous contract', () => { expect(() => scaffold({ from: null, to: contractOf('UserProfile', {}, TO_HASH) })).toThrow( - expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT' }), + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT', + message: + '--rename "userProfile=UserProfile" needs a previous contract to apply the rename to, and this plan starts from an empty database.', + }), ); }); @@ -144,7 +148,8 @@ describe('Postgres scaffold rename-table intents', () => { ).toThrow( expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED', - message: expect.stringContaining('table "ghost" does not exist in the previous contract'), + message: + '--rename "ghost=UserProfile" does not match the contracts: table "ghost" does not exist in the previous contract.', }), ); }); From 32270de56d18b64568c68e82f6da8bdce1176d6c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 13:38:43 +0200 Subject: [PATCH 30/55] fix(mongo): give the rename refusal conflict its code in meta Every other rename conflict carries meta.code, so JSON consumers of MIGRATION.PLANNING_FAILED can find it without parsing the summary. The Mongo refusal now carries meta: { code: "MIGRATION.RENAME_UNSUPPORTED" }. The framework MigrationPlannerConflict gains the optional meta field the SQL family conflicts already had, and which the CLI already forwards. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../framework-components/src/control/control-migration-types.ts | 2 ++ .../3-mongo-target/1-mongo-target/src/core/mongo-planner.ts | 1 + .../1-mongo-target/test/mongo-planner.renames.test.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index 20e262262214..d32dd05d7646 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -266,6 +266,8 @@ export interface MigrationPlannerConflict { readonly summary: string; /** Optional explanation of why this conflict occurred. */ readonly why?: string; + /** Structured details for machine consumers, such as the conflict's error `code`. */ + readonly meta?: Readonly>; } /** diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts index 574e1721d674..4365246d37f5 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts @@ -330,6 +330,7 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> kind: 'renameUnsupported', summary: `${RENAME_UNSUPPORTED_CODE}: MongoDB does not support stated renames (${renamesLabel(options.renames)}), so nothing was planned.`, why: renamesUnsupportedWhy('plan'), + meta: { code: RENAME_UNSUPPORTED_CODE }, }, ], }; diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts index 075a2b012ec1..9aaefda0026b 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts @@ -58,6 +58,7 @@ describe('MongoMigrationPlanner stated renames', () => { summary: 'MIGRATION.RENAME_UNSUPPORTED: MongoDB does not support stated renames ("userProfile=UserProfile"), so nothing was planned.', why: 'The MongoDB planner has no rename operation, so it cannot keep the documents of a renamed collection. Rename the collection by hand with renameCollection, then plan again without the rename.', + meta: { code: 'MIGRATION.RENAME_UNSUPPORTED' }, }, ], }); From 5b35b6d130bc09a2c50a3722573fbfa36c1c5a56 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 13:55:25 +0200 Subject: [PATCH 31/55] test: read the case guard remedy from the action labels in journey R1 R1.05 looked for the --rename remedy in a JSON string of the next actions, where the quotes around userProfile=UserProfile are escaped, so the check failed although the remedy was there. It now joins the action labels and checks those. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/cli-journeys/rename-table-migration.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts index 361fa28f3a74..d24d1a133db4 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -181,7 +181,7 @@ withTempDir(({ createTempDir }) => { 'MIGRATION.TABLE_NAME_CASE_CHANGED', ); expect( - `${bareError?.why}\n${bareError?.summary}\n${JSON.stringify(bareError?.nextActions)}`, + bareError?.nextActions?.map((action) => action.label).join('\n'), 'R1.05: guard points at the flag', ).toContain('prisma migration plan --rename "userProfile=UserProfile"'); expect(getMigrationDirs(ctx), 'R1.05: nothing written').toHaveLength(1); From a24e11c96b7f08358ed479a440d7c6a01748272a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 14:00:20 +0200 Subject: [PATCH 32/55] docs(projects): slice 2 spec records the neutral --rename flag and companion constraint renames Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/rename-table-operation/spec.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md index 154fc23ee4a3..7dd87f334cf2 100644 --- a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md +++ b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md @@ -7,7 +7,7 @@ Today a model whose table name changes plans as `DropTable` plus `CreateTable`, and the rows are gone. After this slice: ```bash -prisma migration plan --rename-table userProfile=UserProfile +prisma migration plan --rename userProfile=UserProfile ``` plans one `renameTable` operation followed by whatever else the diff needs, `migrate` runs `ALTER TABLE "userProfile" RENAME TO "UserProfile"`, and the rows survive. The `MIGRATION.TABLE_NAME_CASE_CHANGED` guard's second remedy points at this flag instead of a by-hand `ALTER TABLE`. @@ -16,7 +16,11 @@ plans one `renameTable` operation followed by whatever else the diff needs, `mig ## Chosen design -**Intent comes from the CLI, never from inference.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The operator states it: `--rename-table =`, repeatable, on `migration plan` (which feeds the planner) and on `migration new` (which scaffolds the operation into the hand-authored file). Either side may be schema-qualified, `auth.userProfile=UserProfile`; an unqualified name means the target's default namespace. Nothing is added to the schema; there is no attribute and no prompt. +**Intent comes from the CLI, never from inference.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The operator states it: `--rename =`, repeatable, on `migration plan` (which feeds the planner) and on `migration new` (which scaffolds the operation into the hand-authored file). Either side may be schema-qualified, `auth.userProfile=UserProfile`; an unqualified name means the target's default namespace. Nothing is added to the schema; there is no attribute and no prompt. + +**The flag name is family-neutral.** The flag is registered by the framework CLI, which serves every family, so it may not carry SQL vocabulary: the repo's framework-vocabulary check fails CI above its threshold, and its rule allows no suppression for a SQL-only concept. `--rename` renames what a model maps to, a table in SQL or a collection in Mongo. Mongo refuses it with `MIGRATION.RENAME_UNSUPPORTED` until it has a rename operation. SQL vocabulary (`renameTable`, `MIGRATION.TABLE_RENAME_*`) stays in the SQL family and target packages. Amended after review: the first draft named the flag `--rename-table`, which added twelve framework lines over the vocabulary threshold. + +**Every name derived from the table follows it.** Postgres names unnamed primary keys, unique constraints, foreign keys, indexes and check constraints after the table, and `ALTER TABLE ... RENAME TO` renames none of them. The planner therefore emits a companion rename for each such object owned by the renamed table, so a later plan's derived names match the database. Explicitly named objects, and foreign keys on other tables, keep their names. On SQLite, index names derived from the table are dropped and recreated, since SQLite cannot rename an index. `migration new --rename` scaffolds the same operations with the same code. Amended after review: the first draft renamed only the table, so the next migration touching a constraint failed. **Rename intents are applied to the previous schema before the diff runs.** Rather than pairing issues after the fact, the planner takes the previous state (the prior contract's schema IR, or the introspected live schema) and renames the stated tables in it, then runs the ordinary diff against the next contract, then prepends one `RenameTableCall` per intent to the operations. The differ therefore sees the table under its new name and plans any column, index or constraint changes on it normally, and the case-change guard never sees a drop and create pair for it. Existing index and check rename pairing continues to work on the renamed table, since it pairs by wire-name hash. @@ -24,7 +28,7 @@ plans one `renameTable` operation followed by whatever else the diff needs, `mig **The operation.** `renameTable` in the Postgres and SQLite operation sets, following the `renameCheckConstraint` precedent (PR #29894) end to end: op builder with precheck (`from` exists, `to` absent), execute (`ALTER TABLE ... RENAME TO ...`, schema-qualified on Postgres), postcheck; `RenameTableCall` in the op-factory call union with `renderTypeScript` and import requirements so a hand-written migration can call it; the migration facade method; the public op-factory export. Operation class `widening`, idempotency class per ADR 038 (effect-idempotent with equivalence check: pre-state compatible if `from` or `to` already holds the table). Under an `additive`-only policy the op is refused the same way other `widening` ops are, not degraded to drop and create. -**Guard remedy.** `table-name-case-guard.ts` second way out becomes: `prisma migration plan --rename-table "="` (Postgres and SQLite), keeping the by-hand `ALTER TABLE` sentence only for Mongo. The error reference entry and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` are updated to match. +**Guard remedy.** `table-name-case-guard.ts` second way out becomes: `prisma migration plan --rename "="` (Postgres and SQLite), keeping the by-hand `ALTER TABLE` sentence only for Mongo. The error reference entry and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` are updated to match. ## Scope @@ -36,8 +40,8 @@ plans one `renameTable` operation followed by whatever else the diff needs, `mig - Op level, both targets: `renameTable` renders the expected SQL; precheck fails when `from` is missing or `to` exists; `renderTypeScript` round-trips through the migration file parser. - Planner, both targets: with one intent and otherwise identical tables, the plan is exactly one rename op; with an intent and an added column, the plan is the rename op followed by the add-column op on the new name; an intent whose `from` is absent, or whose `to` already exists, yields a failure conflict naming it; the case-change guard does not fire when the pair is covered by an intent and still fires when it is not; an `additive`-only policy refuses the rename as it refuses other widening ops. -- CLI: `--rename-table` parses repeated and schema-qualified values, rejects a malformed value with a clear error, reaches the planner options on `migration plan`, and on `migration new` produces a migration file that round-trips through the loader and whose operation renders the expected SQL. -- End to end, Postgres (PGlite) and SQLite, under `test/integration/test/cli-journeys/`: create a table with rows under the old name, change the model's table name, run `migration plan --rename-table` then `migrate`, and prove the rows are present under the new name and `db verify --schema-only` is clean. +- CLI: `--rename` parses repeated and schema-qualified values, rejects a malformed value with a clear error, reaches the planner options on `migration plan`, and on `migration new` produces a migration file that round-trips through the loader and whose operation renders the expected SQL. +- End to end, Postgres (PGlite) and SQLite, under `test/integration/test/cli-journeys/`: create a table with rows under the old name, change the model's table name, run `migration plan --rename` then `migrate`, and prove the rows are present under the new name and `db verify --schema-only` is clean. - Guard text: the existing guard tests pin the new remedy string. ## Done conditions From 8e1214c90bcdc8089ea248e296dfa4070960a116 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 14:08:04 +0200 Subject: [PATCH 33/55] docs(migration): describe what a stated rename plans, and list --rename in the subsystem doc The case guard remedy and the MIGRATION.TABLE_NAME_CASE_CHANGED entry said the plan carries one renameTable operation. Since the companion renames, a rename also renames the constraints and indexes named after the table, so both now say the plan renames the table and the objects named after it instead of dropping and recreating the table. The guard tests on Postgres and SQLite pin the new wording. The migration subsystem doc lists --rename in the migration plan and migration new synopses, in family-neutral wording. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture docs/subsystems/7. Migration System.md | 4 ++-- docs/reference/error-reference.md | 2 +- .../9-family/src/core/migrations/table-name-case-guard.ts | 2 +- .../test/migrations/planner.table-name-case-guard.test.ts | 3 ++- .../test/migrations/planner.table-name-case-guard.test.ts | 3 ++- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 7c6dfd9aa593..c7a83b16a6d0 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -528,8 +528,8 @@ Top-level verbs: Migration namespace (artifacts and graph): -- `prisma migration plan [--from ] [--to ] --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags accept the full [contract-reference grammar](#refs-environment-targets). Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. -- `prisma migration new [--from --to ] --name ` — scaffold an empty `migration.ts` for hand-authoring. +- `prisma migration plan [--from ] [--to ] [--rename =]... --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags accept the full [contract-reference grammar](#refs-environment-targets). `--rename =` (repeatable, either side `.`) states that the storage a model maps to was renamed, so the plan renames it and keeps its data instead of dropping and recreating it. Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. +- `prisma migration new [--from --to ] [--rename =]... --name ` — scaffold an empty `migration.ts` for hand-authoring. With `--rename`, the scaffold starts with the operations `migration plan` would plan for the stated renames. - `prisma migration status [--db ] [--to ] [--from ]` — path/pending question. Live (uses marker) or offline (uses `--from`). - `prisma migration log --db ` — applied execution history (reads the marker; offline reading of the ledger is also supported). - `prisma migration list` — enumerate migrations on disk in topological order. Offline. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index f1f2adc1b554..fea0ef4e0cb7 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1308,7 +1308,7 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.TABLE_NAME_CASE_CHANGED -The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename "X=Y"`, which plans one `renameTable` operation in place of the drop and the create. In a project that uses `db update`, which has no migration history, rename it by hand with `ALTER TABLE "X" RENAME TO "Y"`, after which the plan is empty. Payload: `droppedTable`, `createdTable`. +The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename "X=Y"`: the plan renames the table and the constraints and indexes named after it instead of dropping and recreating the table. In a project that uses `db update`, which has no migration history, rename it by hand with `ALTER TABLE "X" RENAME TO "Y"`, after which the plan is empty. Payload: `droppedTable`, `createdTable`. ### MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index 2d64fb6eb8c0..6392ef96ce30 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -61,7 +61,7 @@ export function detectTableNameCaseChanges(input: { conflicts.push({ kind: 'tableNameCaseChanged', summary: `${TABLE_NAME_CASE_CHANGED_CODE}: table "${create.tableName}" would be created and table "${drop.tableName}" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model ${create.tableName} points at "${create.tableName}" instead of "${drop.tableName}".`, - why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "${drop.tableName}=${create.tableName}" (either side may be .), and the plan carries one renameTable operation in place of the drop and the create; in a project that uses db update, rename it by hand: ALTER TABLE "${drop.tableName}" RENAME TO "${create.tableName}" (schema-qualified where applicable), after which the plan is empty.`, + why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "${drop.tableName}=${create.tableName}" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand: ALTER TABLE "${drop.tableName}" RENAME TO "${create.tableName}" (schema-qualified where applicable), after which the plan is empty.`, location: { namespaceId: create.namespaceId, entityKind: 'table', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index bba629feb1e0..df18521ee84d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -168,8 +168,9 @@ describe('Postgres planner table-name case guard', () => { ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); expect(result.conflicts[0]?.why).toContain( - 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile"', + 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table;', ); + expect(result.conflicts[0]?.why).not.toContain('one renameTable operation'); expect(result.conflicts[0]?.why).toContain( 'in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile"', ); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts index a58a45c3b321..8d33e82b4439 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts @@ -138,8 +138,9 @@ describe('SQLite planner table-name case guard', () => { ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); expect(result.conflicts[0]?.why).toContain( - 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile"', + 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table;', ); + expect(result.conflicts[0]?.why).not.toContain('one renameTable operation'); expect(result.conflicts[0]?.why).toContain( 'in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile"', ); From 10957c08cd2112d8eb9273a3ba70e0e9daeecd7e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 14:25:39 +0200 Subject: [PATCH 34/55] docs(upgrade): describe stated renames and by-hand renames in the verbatim table name fragments The app and extension fragments quoted a case guard remedy that said Prisma 8 has no rename-table operation. The quote is now the text the guard prints, generated from the built guard. The section on adopting the verbatim names describes what the product does: - With migration history, migration plan --rename "=" renames the table and the constraints and indexes named after it; migration new --rename scaffolds the same operations. - Postgres with db update: ALTER TABLE ... RENAME TO ..., then db update renames the indexes named after the old table. Unnamed primary keys, unique constraints and foreign keys keep their old names, with the RENAME CONSTRAINT statements for the default names. - SQLite with db update: a case-only rename in one statement fails, so the table is renamed through a temporary name, and the indexes named after the old table are dropped first. Otherwise db update fails creating them under a name that differs only in case. - MongoDB refuses --rename with MIGRATION.RENAME_UNSUPPORTED; rename the collection by hand. The Mongo sentence that said Mongo has no planner now says a migration planned without the codemod drops the old collection. The codemod copies are unchanged. The Postgres and SQLite db update behaviour was checked with a temporary journey that was not committed. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../app/instructions.md | 46 +++++++++++++++++-- .../extension/instructions.md | 46 +++++++++++++++++-- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md index d954de26816a..a6504473398e 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md @@ -37,11 +37,51 @@ If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma mi ```text ✘ [MIGRATION.PLANNING_FAILED] Migration planning failed why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile". -→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. Prisma 8 has no rename-table operation, so a deliberate rename is done by hand: run ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty. +→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty. ``` -The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no planner and gives no error: an unmapped model silently reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before deploying. +The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. -To adopt the verbatim names on purpose instead of mapping, rename the storage by hand and skip the codemod for those models. Postgres and SQLite: `ALTER TABLE "userProfile" RENAME TO "UserProfile"` (`ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"` inside a schema). Mongo: `db.userProfile.renameCollection("UserProfile")`. After the rename the migration plan is empty and `db verify` is clean. +To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and rename the storage. + +**Postgres or SQLite, with migration history.** State each rename when you plan the migration: + +```bash +prisma migration plan --rename "userProfile=UserProfile" +``` + +Repeat `--rename` once per model, and write either side as `.` for a table in a named schema. The plan renames the table and the constraints and indexes named after it, and the rows stay. `prisma migration new --rename "userProfile=UserProfile"` puts the same operations into a hand-written migration. Without migration history the flag is refused with `MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT`; rename by hand as below. + +**Postgres, managed with `db update`.** Rename the table by hand, then run `prisma db update`: + +```sql +ALTER TABLE "userProfile" RENAME TO "UserProfile"; +``` + +Inside a named schema, qualify the old name: `ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"`. The rows stay. Until `db update` runs, `db verify --schema-only` reports the indexes named after the old table as missing; `db update` renames them, and after that `db verify --schema-only` is clean. Primary keys, unique constraints and foreign keys that you did not name with `map:` keep the names derived from the old table, and neither `db verify` nor `db update` reports it. Rename them as well, because a migration you plan later derives these names from the new table name: + +```sql +ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"; +ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__key" TO "UserProfile__key"; +ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__fkey" TO "UserProfile__fkey"; +``` + +`` is the constraint's column names joined with `_`, such as `email` or `tenantId_email`. Add one statement per unique constraint and per foreign key. Inside a named schema, write the table as `"auth"."UserProfile"`. + +**SQLite, managed with `db update`.** SQLite compares table and index names without case, so rename the table through a temporary name, and drop each index whose name starts with the old table name so that `db update` can create it under the new name: + +```sql +ALTER TABLE "userProfile" RENAME TO "userProfile_tmp"; +ALTER TABLE "userProfile_tmp" RENAME TO "UserProfile"; +DROP INDEX "userProfile_handle_idx_b5b249e4"; +``` + +`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'UserProfile'` lists the indexes; drop the ones that start with `userProfile_`, and leave the `sqlite_autoindex_` ones, which SQLite renames itself. Then run `prisma db update`, which creates the indexes under the new name, and `db verify --schema-only` is clean. The rows stay. + +**MongoDB.** `--rename` is refused with `MIGRATION.RENAME_UNSUPPORTED`. Rename the collection by hand before you plan: + +```js +db.userProfile.renameCollection("UserProfile") +``` `contract infer` follows the same rule: a table whose name already equals the model name (`"UserProfile"`, `"User"`) infers to a model with no `@@map` and verifies clean, where the previous output pointed the model at a lowercase table that did not exist. A snake_case table still infers with `@@map("user_profile")`. There is nothing to detect for this: the inferred text for such a table is the same as before, it is now correct. diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md index e7410d41fe18..6bac90c2ea16 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md @@ -37,11 +37,51 @@ If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma mi ```text ✘ [MIGRATION.PLANNING_FAILED] Migration planning failed why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile". -→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. Prisma 8 has no rename-table operation, so a deliberate rename is done by hand: run ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty. +→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty. ``` -The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no planner and gives no error: an unmapped model silently reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before deploying. +The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. -To adopt the verbatim names on purpose instead of mapping, rename the storage by hand and skip the codemod for those models. Postgres and SQLite: `ALTER TABLE "userProfile" RENAME TO "UserProfile"` (`ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"` inside a schema). Mongo: `db.userProfile.renameCollection("UserProfile")`. After the rename the migration plan is empty and `db verify` is clean. +To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and rename the storage. + +**Postgres or SQLite, with migration history.** State each rename when you plan the migration: + +```bash +prisma migration plan --rename "userProfile=UserProfile" +``` + +Repeat `--rename` once per model, and write either side as `.` for a table in a named schema. The plan renames the table and the constraints and indexes named after it, and the rows stay. `prisma migration new --rename "userProfile=UserProfile"` puts the same operations into a hand-written migration. Without migration history the flag is refused with `MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT`; rename by hand as below. + +**Postgres, managed with `db update`.** Rename the table by hand, then run `prisma db update`: + +```sql +ALTER TABLE "userProfile" RENAME TO "UserProfile"; +``` + +Inside a named schema, qualify the old name: `ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"`. The rows stay. Until `db update` runs, `db verify --schema-only` reports the indexes named after the old table as missing; `db update` renames them, and after that `db verify --schema-only` is clean. Primary keys, unique constraints and foreign keys that you did not name with `map:` keep the names derived from the old table, and neither `db verify` nor `db update` reports it. Rename them as well, because a migration you plan later derives these names from the new table name: + +```sql +ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"; +ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__key" TO "UserProfile__key"; +ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__fkey" TO "UserProfile__fkey"; +``` + +`` is the constraint's column names joined with `_`, such as `email` or `tenantId_email`. Add one statement per unique constraint and per foreign key. Inside a named schema, write the table as `"auth"."UserProfile"`. + +**SQLite, managed with `db update`.** SQLite compares table and index names without case, so rename the table through a temporary name, and drop each index whose name starts with the old table name so that `db update` can create it under the new name: + +```sql +ALTER TABLE "userProfile" RENAME TO "userProfile_tmp"; +ALTER TABLE "userProfile_tmp" RENAME TO "UserProfile"; +DROP INDEX "userProfile_handle_idx_b5b249e4"; +``` + +`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'UserProfile'` lists the indexes; drop the ones that start with `userProfile_`, and leave the `sqlite_autoindex_` ones, which SQLite renames itself. Then run `prisma db update`, which creates the indexes under the new name, and `db verify --schema-only` is clean. The rows stay. + +**MongoDB.** `--rename` is refused with `MIGRATION.RENAME_UNSUPPORTED`. Rename the collection by hand before you plan: + +```js +db.userProfile.renameCollection("UserProfile") +``` `contract infer` follows the same rule: a table whose name already equals the model name (`"UserProfile"`, `"User"`) infers to a model with no `@@map` and verifies clean, where the previous output pointed the model at a lowercase table that did not exist. A snake_case table still infers with `@@map("user_profile")`. There is nothing to detect for this: the inferred text for such a table is the same as before, it is now correct. From cc4eb66c8e9e67bbdd030a7af14b36ea93368f40 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 14:37:42 +0200 Subject: [PATCH 35/55] docs(projects): slice 2 spec and design notes match the three guard remedies and the rename operation Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- projects/psl-verbatim-table-names/design-notes.md | 2 +- .../slices/rename-table-operation/spec.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/psl-verbatim-table-names/design-notes.md b/projects/psl-verbatim-table-names/design-notes.md index 2d00adf4d02b..cbd9fa232c46 100644 --- a/projects/psl-verbatim-table-names/design-notes.md +++ b/projects/psl-verbatim-table-names/design-notes.md @@ -13,7 +13,7 @@ The storage name of a model with no `@@map` is the model name. This was already - **Keep the lowered-first-letter default and fix only `contract infer`** (the original TML-3248 fix). Rejected: it leaves a default nobody knew about, that matches no convention, and that contradicts every other authoring surface. - **Have infer always write `@@map`.** Rejected for the same reason; it papers over the default instead of removing it. -- **Detect the rename in the planner and emit a table rename.** Rejected: there is no rename-table operation, and a structural-match heuristic would also fire on genuine renames later. The planner detects and refuses; the codemod is the fix. +- **Detect the rename in the planner and emit a table rename.** Rejected: a structural-match heuristic would fire on genuine drops and creates, and carry rows into the wrong table. The planner detects and refuses; the codemod keeps existing tables, and slice 2 adds a rename operation the operator requests explicitly with `--rename`. - **Regenerate all repo fixtures with the new names.** Rejected: adding `@@map` to existing repo schemas keeps every emitted artifact byte-identical and exercises the user codemod on real schemas. ## Decisions taken in discussion (2026-09-16) diff --git a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md index 7dd87f334cf2..27895bf52a1a 100644 --- a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md +++ b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md @@ -28,7 +28,7 @@ plans one `renameTable` operation followed by whatever else the diff needs, `mig **The operation.** `renameTable` in the Postgres and SQLite operation sets, following the `renameCheckConstraint` precedent (PR #29894) end to end: op builder with precheck (`from` exists, `to` absent), execute (`ALTER TABLE ... RENAME TO ...`, schema-qualified on Postgres), postcheck; `RenameTableCall` in the op-factory call union with `renderTypeScript` and import requirements so a hand-written migration can call it; the migration facade method; the public op-factory export. Operation class `widening`, idempotency class per ADR 038 (effect-idempotent with equivalence check: pre-state compatible if `from` or `to` already holds the table). Under an `additive`-only policy the op is refused the same way other `widening` ops are, not degraded to drop and create. -**Guard remedy.** `table-name-case-guard.ts` second way out becomes: `prisma migration plan --rename "="` (Postgres and SQLite), keeping the by-hand `ALTER TABLE` sentence only for Mongo. The error reference entry and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` are updated to match. +**Guard remedy.** `MIGRATION.TABLE_NAME_CASE_CHANGED` offers three ways out: add `@@map` to keep the old table; in a project with migration history, plan with `--rename "="`; in a project managed with `db update`, rename the table by hand with the statement the target supplies, after which the plan is empty. The by-hand statement comes from the target because SQLite cannot rename a table in one statement when only the case changes. The error reference and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` describe the same three paths, plus the by-hand constraint renames on Postgres and the by-hand collection rename on Mongo, which refuses the flag. Amended after review: the first draft kept the by-hand sentence only for Mongo, and the fragments were not updated until review found them stale. ## Scope From eb13562763c96a268c4bed6b85ef2db87bf5a017 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 14:43:26 +0200 Subject: [PATCH 36/55] fix(migration): by-hand table renames that work on SQLite, and db update after them The case guard told db update users to rename with ALTER TABLE "X" RENAME TO "Y" on every target. SQLite refuses that in one statement when only the case changes, which is exactly the rename the guard fires on. The family guard now takes the by-hand statements from the target, as the family rename helper takes the RLS references from Postgres, so the guard stays free of target code: - Postgres: ALTER TABLE "X" RENAME TO "Y", qualified by schema where it applies, from the same function the renameTable operation uses. - SQLite: the steps the renameTable operation executes, which go through _prisma_rename_ when only the case changes. The remedy ends "then run db update again" instead of "after which the plan is empty": a table with indexes named after it still needs db update to rename or recreate them. After a by-hand rename, SQLite db update failed. It created each index under its new name before dropping the old one, and SQLite compares index names without case. The pairing that orders index drops before creates for stated renames moves to index-replacements.ts and takes a match rule. The planner now also pairs an index whose name changes only in case on the same table and columns. migration new keeps using the rename rule alone. The error reference and both upgrade fragments quote the guard text generated by running both planners. The SQLite db update steps no longer tell users to drop indexes by hand. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 2 +- .../core/migrations/table-name-case-guard.ts | 10 ++- .../2-sql/9-family/src/exports/control.ts | 5 +- .../src/core/migrations/operations/tables.ts | 7 +- .../postgres/src/core/migrations/planner.ts | 19 +++- .../planner.table-name-case-guard.test.ts | 5 +- .../src/core/migrations/index-replacements.ts | 86 +++++++++++++++++++ .../src/core/migrations/op-factory-call.ts | 54 +++--------- .../src/core/migrations/operations/tables.ts | 32 +++++++ .../sqlite/src/core/migrations/planner.ts | 25 ++++-- .../core/migrations/renamed-table-indexes.ts | 66 -------------- .../planner.case-only-index-names.test.ts | 41 +++++++++ .../planner.table-name-case-guard.test.ts | 9 +- .../app/instructions.md | 13 +-- .../extension/instructions.md | 13 +-- 15 files changed, 249 insertions(+), 138 deletions(-) create mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts delete mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/renamed-table-indexes.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index fea0ef4e0cb7..6c73f2ed085e 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1308,7 +1308,7 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.TABLE_NAME_CASE_CHANGED -The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename "X=Y"`: the plan renames the table and the constraints and indexes named after it instead of dropping and recreating the table. In a project that uses `db update`, which has no migration history, rename it by hand with `ALTER TABLE "X" RENAME TO "Y"`, after which the plan is empty. Payload: `droppedTable`, `createdTable`. +The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename "X=Y"`: the plan renames the table and the constraints and indexes named after it instead of dropping and recreating the table. In a project that uses `db update`, which has no migration history, rename the table by hand with the statements the message gives, then run `db update` again. On Postgres that is `ALTER TABLE "X" RENAME TO "Y"`, schema-qualified inside a named schema. On SQLite, which refuses in one statement a rename that only changes case, it is `ALTER TABLE "X" RENAME TO "_prisma_rename_Y"; ALTER TABLE "_prisma_rename_Y" RENAME TO "Y"`. Payload: `droppedTable`, `createdTable`. ### MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index 6392ef96ce30..6a3863d99214 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -9,6 +9,13 @@ export interface TableNameCaseGuardTable { readonly name: string; } +/** A table rename the operator performs by hand, for the target to write the statements of. */ +export interface TableRenameByHand { + readonly namespaceId: string; + readonly from: string; + readonly to: string; +} + interface PlannedTable { readonly namespaceId: string; readonly tableName: string; @@ -37,6 +44,7 @@ export function detectTableNameCaseChanges(input: { readonly issues: readonly SchemaDiffIssue[]; readonly tableOf: (issue: SchemaDiffIssue) => TableNameCaseGuardTable | undefined; readonly namespaceIdOf: (issue: SchemaDiffIssue) => string; + readonly renameByHandStatements: (rename: TableRenameByHand) => readonly string[]; }): SqlPlannerConflict[] { const dropped: PlannedTable[] = []; const created: PlannedTable[] = []; @@ -61,7 +69,7 @@ export function detectTableNameCaseChanges(input: { conflicts.push({ kind: 'tableNameCaseChanged', summary: `${TABLE_NAME_CASE_CHANGED_CODE}: table "${create.tableName}" would be created and table "${drop.tableName}" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model ${create.tableName} points at "${create.tableName}" instead of "${drop.tableName}".`, - why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "${drop.tableName}=${create.tableName}" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand: ALTER TABLE "${drop.tableName}" RENAME TO "${create.tableName}" (schema-qualified where applicable), after which the plan is empty.`, + why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "${drop.tableName}=${create.tableName}" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand with ${input.renameByHandStatements({ namespaceId: create.namespaceId, from: drop.tableName, to: create.tableName }).join('; ')}, then run db update again.`, location: { namespaceId: create.namespaceId, entityKind: 'table', diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 0c32c6427ce1..0841e3babd13 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -54,7 +54,10 @@ export type { SqlSchemaDiffInput, SqlSchemaDiffResult, } from '../core/migrations/schema-differ'; -export type { TableNameCaseGuardTable } from '../core/migrations/table-name-case-guard'; +export type { + TableNameCaseGuardTable, + TableRenameByHand, +} from '../core/migrations/table-name-case-guard'; export { detectTableNameCaseChanges, TABLE_NAME_CASE_CHANGED_CODE, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts index c83102347ea3..1bd746a44b7b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts @@ -24,13 +24,16 @@ export async function dropTable( }; } +export function renameTableStatement(schemaName: string, fromName: string, toName: string): string { + return `ALTER TABLE ${qualifyTableName(schemaName, fromName)} RENAME TO ${quoteIdentifier(toName)}`; +} + export async function renameTable( schemaName: string, fromName: string, toName: string, lowerer: ExecuteRequestLowerer, ): Promise { - const qualified = qualifyTableName(schemaName, fromName); const fromChecks = tableExistsAst(schemaName, fromName); const toChecks = tableExistsAst(schemaName, toName); const fromPresent = await lowerer.lowerToExecuteRequest(fromChecks.tablePresent()); @@ -49,7 +52,7 @@ export async function renameTable( execute: [ step( `rename table "${fromName}" to "${toName}"`, - `ALTER TABLE ${qualified} RENAME TO ${quoteIdentifier(toName)}`, + renameTableStatement(schemaName, fromName, toName), ), ], // Both postchecks: the runner skips an operation whose postcheck already diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 193863f04916..b80585db09b8 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -69,6 +69,7 @@ import { RenamePostgresRlsPolicyCall, RenameTableCall, } from './op-factory-call'; +import { renameTableStatement } from './operations/tables'; import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration'; import { postgresPlannerStrategies } from './planner-strategies'; import { postgresContractToSchema } from './postgres-contract-to-schema'; @@ -93,6 +94,12 @@ interface PlannedTableRenames { readonly calls: readonly PostgresOpFactoryCall[]; } +function emissionSchemaForNamespace(contract: Contract, namespaceId: string): string { + return namespaceId === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : resolveDdlSchemaForNamespaceStorage(contract.storage, namespaceId); +} + const SCAFFOLD_POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'], }; @@ -276,10 +283,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return notOk(applied.failure); } const calls = applied.value.renames.flatMap((rename): PostgresOpFactoryCall[] => { - const schemaName = - rename.namespaceId === UNBOUND_NAMESPACE_ID - ? UNBOUND_NAMESPACE_ID - : resolveDdlSchemaForNamespaceStorage(options.contract.storage, rename.namespaceId); + const schemaName = emissionSchemaForNamespace(options.contract, rename.namespaceId); const previous = options.fromContract?.storage.namespaces[rename.namespaceId]?.entries.table?.[rename.from]; const next = @@ -449,6 +453,13 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr }, namespaceIdOf: (issue) => resolveNamespaceIdForDdlSchema(options.contract, issueSchemaName(issue) ?? schemaName), + renameByHandStatements: (rename) => [ + renameTableStatement( + emissionSchemaForNamespace(options.contract, rename.namespaceId), + rename.from, + rename.to, + ), + ], }); if (caseChangeConflicts.length > 0) { return plannerFailure(caseChangeConflicts); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index df18521ee84d..67b7351ce41c 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -172,7 +172,7 @@ describe('Postgres planner table-name case guard', () => { ); expect(result.conflicts[0]?.why).not.toContain('one renameTable operation'); expect(result.conflicts[0]?.why).toContain( - 'in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile"', + 'in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "UserProfile", then run db update again.', ); }); @@ -197,6 +197,9 @@ describe('Postgres planner table-name case guard', () => { entityKind: 'table', entityName: 'UserProfile', }); + expect(result.conflicts[0]?.why).toContain( + 'rename it by hand with ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile", then run db update again.', + ); }); it('plans nothing once the model maps back to userProfile', async () => { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts new file mode 100644 index 000000000000..126753f20f3e --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts @@ -0,0 +1,86 @@ +import type { SchemaDiffIssue } from '@internal/framework-components/control'; +import { issueOutcome } from '@internal/framework-components/control'; +import { parseWireName } from '@internal/sql-schema-ir/naming'; +import { SqlIndexIR } from '@internal/sql-schema-ir/types'; +import { issueNode } from './issue-planner'; +import { CreateIndexCall, DropIndexCall, type SqliteOpFactoryCall } from './op-factory-call'; + +export interface IndexFinding { + readonly issue: SchemaDiffIssue; + readonly index: SqlIndexIR; + readonly tableName: string; +} + +/** Whether an index the plan drops is replaced by one the plan creates. */ +export type IndexReplacementMatch = (old: IndexFinding, replacement: IndexFinding) => boolean; + +function indexFindings( + issues: readonly SchemaDiffIssue[], + outcome: 'not-found' | 'not-expected', +): IndexFinding[] { + return issues.flatMap((issue) => { + const node = issueNode(issue); + const tableName = issue.path[1]; + if (node === undefined || !SqlIndexIR.is(node) || tableName === undefined) return []; + return issueOutcome(issue) === outcome ? [{ issue, index: node, tableName }] : []; + }); +} + +/** + * An index on a renamed table whose wire name derives from the table name: the old and the new index share their content hash and differ in name. + */ +export function renamedTableIndex(renamedTables: ReadonlySet): IndexReplacementMatch { + return (old, replacement) => { + const oldWire = parseWireName(old.index.name); + const replacementWire = parseWireName(replacement.index.name); + return ( + renamedTables.has(old.tableName) && + old.tableName === replacement.tableName && + oldWire !== undefined && + oldWire.hash === replacementWire?.hash && + old.index.name !== replacement.index.name + ); + }; +} + +/** + * An index whose name changes only in case on the same table and columns, as after a table renamed by hand. + */ +export const indexNameCaseChange: IndexReplacementMatch = (old, replacement) => + old.tableName === replacement.tableName && + old.index.name !== replacement.index.name && + old.index.name.toLowerCase() === replacement.index.name.toLowerCase() && + JSON.stringify(old.index.columns ?? []) === JSON.stringify(replacement.index.columns ?? []); + +/** + * Pairs each index the plan drops with the index that replaces it, and plans every paired drop before every paired create. SQLite cannot rename an index, and it compares index names without case, so a replacement whose name differs from the old one only in case would otherwise collide with it. The paired issues are returned as consumed so the ordinary diff does not plan them again. + */ +export function pairIndexReplacements( + issues: readonly SchemaDiffIssue[], + matches: IndexReplacementMatch, +): { + readonly calls: readonly SqliteOpFactoryCall[]; + readonly consumed: ReadonlySet; +} { + const missing = indexFindings(issues, 'not-found'); + const pairs = indexFindings(issues, 'not-expected').flatMap((old) => { + const replacement = missing.find((candidate) => matches(old, candidate)); + if (replacement === undefined) return []; + missing.splice(missing.indexOf(replacement), 1); + return [{ old, replacement }]; + }); + return { + calls: [ + ...pairs.map(({ old }) => new DropIndexCall(old.tableName, old.index.name)), + ...pairs.map( + ({ replacement }) => + new CreateIndexCall( + replacement.tableName, + replacement.index.name, + replacement.index.columns ?? [], + ), + ), + ], + consumed: new Set(pairs.flatMap(({ old, replacement }) => [old.issue, replacement.issue])), + }; +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index deec663a33d9..4277f5daeeff 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -30,7 +30,12 @@ import { quoteIdentifier } from '../sql-utils'; import { addColumnExecuteSql, dropColumnExecuteSql } from './operations/columns'; import type { SqliteColumnSpec, SqliteIndexSpec, SqliteTableSpec } from './operations/shared'; import { step } from './operations/shared'; -import { recreateTable } from './operations/tables'; +import { + recreateTable, + renameChangesOnlyCase, + renameTableSteps, + renameTableViaName, +} from './operations/tables'; import { buildCreateIndexSql, buildDropIndexSql } from './planner-ddl-builders'; import type { SqlitePlanTargetDetails } from './planner-target-details'; import { buildTargetDetails } from './planner-target-details'; @@ -293,8 +298,9 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { const toChecks = tableExistsAst(this.tableName); const fromPresent = await lowerer.lowerToExecuteRequest(fromChecks.tablePresent()); const toAbsent = await lowerer.lowerToExecuteRequest(toChecks.tableAbsent()); - const viaAbsent = this.caseOnly() - ? await lowerer.lowerToExecuteRequest(tableExistsAst(this.viaName()).tableAbsent()) + const viaName = renameTableViaName(this.tableName); + const viaAbsent = renameChangesOnlyCase(this.oldTableName, this.tableName) + ? await lowerer.lowerToExecuteRequest(tableExistsAst(viaName).tableAbsent()) : undefined; const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); const fromAbsent = await lowerer.lowerToExecuteRequest(fromChecks.tableAbsent()); @@ -311,13 +317,13 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { ? [] : [ step( - `ensure table "${this.viaName()}" does not exist (a rename that only changes case passes through this temporary name, because SQLite compares table names without case)`, + `ensure table "${viaName}" does not exist (a rename that only changes case passes through this temporary name, because SQLite compares table names without case)`, viaAbsent.sql, viaAbsent.params, ), ]), ], - execute: this.executeSteps(), + execute: renameTableSteps(this.oldTableName, this.tableName), // Both postchecks: the runner skips an operation whose postcheck // already holds, and "the new table exists" alone would skip a rename // that never happened when both tables exist. @@ -332,44 +338,6 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { }; } - private caseOnly(): boolean { - return this.oldTableName.toLowerCase() === this.tableName.toLowerCase(); - } - - private viaName(): string { - return `_prisma_rename_${this.tableName}`; - } - - /** - * SQLite compares table names case-insensitively, so a rename that only - * changes case (`userProfile` to `UserProfile`) is refused as "already - * exists" when done in one statement. It goes through a temporary name. - */ - private executeSteps(): Op['execute'] { - const from = quoteIdentifier(this.oldTableName); - const to = quoteIdentifier(this.tableName); - if (!this.caseOnly()) { - return [ - step( - `rename table "${this.oldTableName}" to "${this.tableName}"`, - `ALTER TABLE ${from} RENAME TO ${to}`, - ), - ]; - } - const viaName = this.viaName(); - const via = quoteIdentifier(viaName); - return [ - step( - `rename table "${this.oldTableName}" to "${viaName}" (SQLite table names are case-insensitive)`, - `ALTER TABLE ${from} RENAME TO ${via}`, - ), - step( - `rename table "${viaName}" to "${this.tableName}"`, - `ALTER TABLE ${via} RENAME TO ${to}`, - ), - ]; - } - renderTypeScript(): string { return `this.renameTable({ table: ${jsonToTsSource(this.oldTableName)}, to: ${jsonToTsSource(this.tableName)} })`; } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts index d82c2b4ef8c0..c212b9ec384a 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts @@ -77,6 +77,38 @@ export async function createTable( }; } +export function renameTableViaName(toName: string): string { + return `_prisma_rename_${toName}`; +} + +export function renameChangesOnlyCase(fromName: string, toName: string): boolean { + return fromName.toLowerCase() === toName.toLowerCase(); +} + +/** + * SQLite compares table names case-insensitively, so a rename that only + * changes case (`userProfile` to `UserProfile`) is refused as "already + * exists" when done in one statement. It goes through a temporary name. + */ +export function renameTableSteps(fromName: string, toName: string): Op['execute'] { + const from = quoteIdentifier(fromName); + const to = quoteIdentifier(toName); + if (!renameChangesOnlyCase(fromName, toName)) { + return [ + step(`rename table "${fromName}" to "${toName}"`, `ALTER TABLE ${from} RENAME TO ${to}`), + ]; + } + const viaName = renameTableViaName(toName); + const via = quoteIdentifier(viaName); + return [ + step( + `rename table "${fromName}" to "${viaName}" (SQLite table names are case-insensitive)`, + `ALTER TABLE ${from} RENAME TO ${via}`, + ), + step(`rename table "${viaName}" to "${toName}"`, `ALTER TABLE ${via} RENAME TO ${to}`), + ]; +} + export async function dropTable(tableName: string, lowerer: ExecuteRequestLowerer): Promise { const { present, absent } = await tableExistsSteps(lowerer, tableName); return { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 0cdfd272b8d5..ed96771fabc0 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -35,6 +35,11 @@ import { import { blindCast } from '@internal/utils/casts'; import { notOk, ok, type Result } from '@internal/utils/result'; import { buildSqlitePlanDiff, sqliteContractToSchema } from './diff-database-schema'; +import { + indexNameCaseChange, + pairIndexReplacements, + renamedTableIndex, +} from './index-replacements'; import { coalesceSubtreeIssues, conflictForDisallowedCall, @@ -42,13 +47,13 @@ import { planIssues, } from './issue-planner'; import { RenameTableCall, type SqliteOpFactoryCall } from './op-factory-call'; +import { renameTableSteps } from './operations/tables'; import { type SqliteMigrationDestinationInfo, TypeScriptRenderableSqliteMigration, } from './planner-produced-sqlite-migration'; import { sqlitePlannerStrategies } from './planner-strategies'; import type { SqlitePlanTargetDetails } from './planner-target-details'; -import { pairRenamedTableIndexes } from './renamed-table-indexes'; interface PlannedTableRenames { readonly fromContract: Contract | null; @@ -194,7 +199,8 @@ export class SqliteMigrationPlanner }); return [ ...planned.value.calls, - ...pairRenamedTableIndexes(issues, renamedTableNames(planned.value.calls)).calls, + ...pairIndexReplacements(issues, renamedTableIndex(renamedTableNames(planned.value.calls))) + .calls, ]; } @@ -262,8 +268,13 @@ export class SqliteMigrationPlanner ...options, schema: previousSchema, }); - const renamedIndexes = pairRenamedTableIndexes(diffIssues, renamedTableNames(renameTableCalls)); - const disallowedIndexCalls = renamedIndexes.calls.filter( + const onRenamedTable = renamedTableIndex(renamedTableNames(renameTableCalls)); + const replacedIndexes = pairIndexReplacements( + diffIssues, + (old, replacement) => + onRenamedTable(old, replacement) || indexNameCaseChange(old, replacement), + ); + const disallowedIndexCalls = replacedIndexes.calls.filter( (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), ); if (disallowedIndexCalls.length > 0) { @@ -273,7 +284,7 @@ export class SqliteMigrationPlanner ), ); } - const issues = diffIssues.filter((issue) => !renamedIndexes.consumed.has(issue)); + const issues = diffIssues.filter((issue) => !replacedIndexes.consumed.has(issue)); const caseChangeConflicts = detectTableNameCaseChanges({ issues, tableOf: (issue) => { @@ -281,6 +292,8 @@ export class SqliteMigrationPlanner return node instanceof SqlTableIR ? node : undefined; }, namespaceIdOf: () => UNBOUND_NAMESPACE_ID, + renameByHandStatements: (rename) => + renameTableSteps(rename.from, rename.to).map((renameStep) => renameStep.sql), }); if (caseChangeConflicts.length > 0) { return plannerFailure(caseChangeConflicts); @@ -317,7 +330,7 @@ export class SqliteMigrationPlanner // every later operation addresses the renamed table by its new name. const calls = [ ...renameTableCalls, - ...renamedIndexes.calls, + ...replacedIndexes.calls, ...result.value.calls, ...fieldEventOps, ]; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/renamed-table-indexes.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/renamed-table-indexes.ts deleted file mode 100644 index 62dee22b6608..000000000000 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/renamed-table-indexes.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { SchemaDiffIssue } from '@internal/framework-components/control'; -import { issueOutcome } from '@internal/framework-components/control'; -import { parseWireName } from '@internal/sql-schema-ir/naming'; -import { SqlIndexIR } from '@internal/sql-schema-ir/types'; -import { issueNode } from './issue-planner'; -import { CreateIndexCall, DropIndexCall, type SqliteOpFactoryCall } from './op-factory-call'; - -interface IndexFinding { - readonly issue: SchemaDiffIssue; - readonly index: SqlIndexIR; - readonly tableName: string; - readonly hash: string; -} - -function indexFindings( - issues: readonly SchemaDiffIssue[], - renamedTables: ReadonlySet, - outcome: 'not-found' | 'not-expected', -): IndexFinding[] { - return issues.flatMap((issue) => { - const node = issueNode(issue); - const tableName = issue.path[1]; - if (node === undefined || !SqlIndexIR.is(node) || tableName === undefined) return []; - if (!renamedTables.has(tableName) || issueOutcome(issue) !== outcome) return []; - const wire = parseWireName(node.name); - return wire === undefined ? [] : [{ issue, index: node, tableName, hash: wire.hash }]; - }); -} - -/** - * The index operations a table rename needs on SQLite. An index whose wire name derives from the table name keeps its old name through the rename, and SQLite cannot rename an index, so each old index that pairs with a new one by content hash on a renamed table is dropped and the new one created. Drops come first because SQLite compares index names without case, and a name that only changes case would otherwise collide. The paired issues are returned as consumed so the ordinary diff does not plan them again. - */ -export function pairRenamedTableIndexes( - issues: readonly SchemaDiffIssue[], - renamedTables: ReadonlySet, -): { - readonly calls: readonly SqliteOpFactoryCall[]; - readonly consumed: ReadonlySet; -} { - const missing = indexFindings(issues, renamedTables, 'not-found'); - const pairs = indexFindings(issues, renamedTables, 'not-expected').flatMap((old) => { - const replacement = missing.find( - (candidate) => - candidate.tableName === old.tableName && - candidate.hash === old.hash && - candidate.index.name !== old.index.name, - ); - if (replacement === undefined) return []; - missing.splice(missing.indexOf(replacement), 1); - return [{ old, replacement }]; - }); - return { - calls: [ - ...pairs.map(({ old }) => new DropIndexCall(old.tableName, old.index.name)), - ...pairs.map( - ({ replacement }) => - new CreateIndexCall( - replacement.tableName, - replacement.index.name, - replacement.index.columns ?? [], - ), - ), - ], - consumed: new Set(pairs.flatMap(({ old, replacement }) => [old.issue, replacement.issue])), - }; -} diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts new file mode 100644 index 000000000000..3f241367389e --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts @@ -0,0 +1,41 @@ +/** + * After a table is renamed by hand, `db update` finds each index named after the old table under a name that differs only in case from the one the contract wants. SQLite compares index names without case, so the old index must be dropped before the new one is created. + */ + +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; +import { contractOf, HANDLE_INDEX_HASH, handleIndex, stubLowerer } from './rename-table-fixtures'; + +async function plannedLabels(liveIndexTable: string, contractIndexTable: string) { + const live = contractOf('UserProfile', { indexes: () => [handleIndex(liveIndexTable)] }, 'live'); + const result = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: contractOf('UserProfile', { indexes: () => [handleIndex(contractIndexTable)] }, 'to'), + schema: sqliteContractToSchema(live), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: null, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return []; + return (await Promise.all(result.plan.operations)).map((op) => op.label); +} + +describe('SQLite planner index names that differ only in case', () => { + it('drops the old index before creating the one whose name differs only in case', async () => { + expect(await plannedLabels('userProfile', 'UserProfile')).toEqual([ + `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); + + it('keeps the usual order for an index whose name changes by more than case', async () => { + expect(await plannedLabels('user_profile', 'UserProfile')).toEqual([ + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Drop index user_profile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts index 8d33e82b4439..b1f0e7a73f36 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts @@ -141,8 +141,15 @@ describe('SQLite planner table-name case guard', () => { 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table;', ); expect(result.conflicts[0]?.why).not.toContain('one renameTable operation'); + }); + + it('gives a by-hand rename through a temporary name, because SQLite refuses a case-only rename in one statement', () => { + const result = planFromLive(['userProfile'], 'UserProfile')(); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; expect(result.conflicts[0]?.why).toContain( - 'in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile"', + 'in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.', ); }); diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md index a6504473398e..c6e3c62cee60 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md @@ -37,9 +37,11 @@ If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma mi ```text ✘ [MIGRATION.PLANNING_FAILED] Migration planning failed why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile". -→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty. +→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "UserProfile", then run db update again. ``` +That is the Postgres output. On SQLite the last clause gives the two statements SQLite needs for a rename that only changes case: `in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.` + The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and rename the storage. @@ -68,15 +70,14 @@ ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__fkey" TO "Use `` is the constraint's column names joined with `_`, such as `email` or `tenantId_email`. Add one statement per unique constraint and per foreign key. Inside a named schema, write the table as `"auth"."UserProfile"`. -**SQLite, managed with `db update`.** SQLite compares table and index names without case, so rename the table through a temporary name, and drop each index whose name starts with the old table name so that `db update` can create it under the new name: +**SQLite, managed with `db update`.** SQLite refuses in one statement a rename that only changes case, so rename the table through a temporary name, then run `prisma db update`: ```sql -ALTER TABLE "userProfile" RENAME TO "userProfile_tmp"; -ALTER TABLE "userProfile_tmp" RENAME TO "UserProfile"; -DROP INDEX "userProfile_handle_idx_b5b249e4"; +ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; +ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"; ``` -`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'UserProfile'` lists the indexes; drop the ones that start with `userProfile_`, and leave the `sqlite_autoindex_` ones, which SQLite renames itself. Then run `prisma db update`, which creates the indexes under the new name, and `db verify --schema-only` is clean. The rows stay. +The rows stay, and SQLite renames its automatic indexes itself. `db update` drops each index named after the old table and creates it under the new name. Dropping an index needs your consent: `db update` asks for it, or, without a terminal, takes the database name it prints with `--confirm`. After that `db verify --schema-only` is clean. **MongoDB.** `--rename` is refused with `MIGRATION.RENAME_UNSUPPORTED`. Rename the collection by hand before you plan: diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md index 6bac90c2ea16..024e473e4781 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md @@ -37,9 +37,11 @@ If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma mi ```text ✘ [MIGRATION.PLANNING_FAILED] Migration planning failed why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile". -→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand: ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty. +→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "UserProfile", then run db update again. ``` +That is the Postgres output. On SQLite the last clause gives the two statements SQLite needs for a rename that only changes case: `in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.` + The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and rename the storage. @@ -68,15 +70,14 @@ ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__fkey" TO "Use `` is the constraint's column names joined with `_`, such as `email` or `tenantId_email`. Add one statement per unique constraint and per foreign key. Inside a named schema, write the table as `"auth"."UserProfile"`. -**SQLite, managed with `db update`.** SQLite compares table and index names without case, so rename the table through a temporary name, and drop each index whose name starts with the old table name so that `db update` can create it under the new name: +**SQLite, managed with `db update`.** SQLite refuses in one statement a rename that only changes case, so rename the table through a temporary name, then run `prisma db update`: ```sql -ALTER TABLE "userProfile" RENAME TO "userProfile_tmp"; -ALTER TABLE "userProfile_tmp" RENAME TO "UserProfile"; -DROP INDEX "userProfile_handle_idx_b5b249e4"; +ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; +ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"; ``` -`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'UserProfile'` lists the indexes; drop the ones that start with `userProfile_`, and leave the `sqlite_autoindex_` ones, which SQLite renames itself. Then run `prisma db update`, which creates the indexes under the new name, and `db verify --schema-only` is clean. The rows stay. +The rows stay, and SQLite renames its automatic indexes itself. `db update` drops each index named after the old table and creates it under the new name. Dropping an index needs your consent: `db update` asks for it, or, without a terminal, takes the database name it prints with `--confirm`. After that `db verify --schema-only` is clean. **MongoDB.** `--rename` is refused with `MIGRATION.RENAME_UNSUPPORTED`. Rename the collection by hand before you plan: From 12b19b7f689632be494efc3cdd875d921aaed7d9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 14:45:09 +0200 Subject: [PATCH 37/55] test: rename a SQLite table by hand in a db update project Journey R4 follows the by-hand path the upgrade fragment gives SQLite users: - db update creates userProfile with rows, a unique constraint, a foreign key and an index. - After the model drops its @@map, db update is refused by the case guard, which gives the two statements through _prisma_rename_UserProfile. - The journey runs them by hand and then db update again, which drops each index named after the old table before creating it under the new name. - The rows are present, the indexes carry the new name, and db verify --schema-only is clean. Before the fix, the guard step failed on the one-statement remedy, and db update failed with "index UserProfile_accountId_idx_cbfb3085 already exists". Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/cli-journeys/README.md | 2 +- .../rename-table-migration.sqlite.e2e.test.ts | 76 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index eb8e7f29de68..d731674a6b47 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -31,7 +31,7 @@ pnpm test:journeys | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | | `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard and a stale `--rename` intent fails planning, then `migration plan --rename userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. Journey R2 authors the same rename through `migration new --rename`, whose scaffold carries the same operations. In both, `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies | -| `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration | +| `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration. A second journey follows the by-hand path of a `db update` project: the case guard gives the two-statement rename through `_prisma_rename_UserProfile`, and after running it `db update` drops and recreates the indexes named after the old table, keeps the rows and verifies clean | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts index f987a0dad188..a8652d804bf7 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -1,7 +1,9 @@ /** * Renaming a table keeps its rows (SQLite). * - * The SQLite twin of `rename-table-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. Create `userProfile` with rows, a unique constraint, a foreign key and an index, then drop the `@@map` so the model names `UserProfile`. `migration plan --rename` plans the rename plus a drop and a create of each index named after the old table; `migrate` keeps the rows; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. + * The SQLite twin of `rename-table-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. Journey R3: create `userProfile` with rows, a unique constraint, a foreign key and an index, then drop the `@@map` so the model names `UserProfile`. `migration plan --rename` plans the rename plus a drop and a create of each index named after the old table; `migrate` keeps the rows; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. + * + * Journey R4 follows the by-hand path of a project managed with `db update`: the case guard refuses and gives the two-statement rename, the statements are run by hand, and `db update` then drops each index named after the old table before creating it under the new name, keeping the rows. */ import { readFileSync, writeFileSync } from 'node:fs'; @@ -17,6 +19,7 @@ import { parseJsonOutput, planMigrationAndSelfEmit, runContractEmit, + runDbUpdate, runDbVerify, runMigrate, runMigrationPlan, @@ -247,4 +250,75 @@ withTempDir(({ createTempDir }) => { timeouts.spinUpPpgDev, ); }); + + describe('Journey R4 (SQLite): rename a table by hand in a project managed with db update', () => { + it( + 'the guard gives the two-statement rename; after running it, db update recreates the indexes and verify is clean', + async () => { + const ctx = setupSqliteJourney(createTempDir); + + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `R4.01: emit userProfile: ${emit.stderr}`).toBe(0); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `R4.02: db update creates userProfile: ${create.stderr}`).toBe(0); + withDatabase(ctx.dbPath, (db) => { + db.exec( + `INSERT INTO "Account" (id) VALUES (1); + INSERT INTO "userProfile" (id, email, handle, "accountId") + VALUES (1, 'alice@example.com', 'alice', 1), (2, 'bob@example.com', 'bob', 1)`, + ); + }); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), TO_PSL, 'utf-8'); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `R4.03: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); + + const refused = await runDbUpdate(ctx, ['--json']); + expect(refused.exitCode, 'R4.04: db update is refused by the guard').not.toBe(0); + const refusal = engineError(refused); + expect(refusal?.why, 'R4.04: guard names the case change').toContain( + 'MIGRATION.TABLE_NAME_CASE_CHANGED', + ); + expect( + refusal?.nextActions?.map((action) => action.label).join('\n'), + 'R4.04: guard gives the SQLite by-hand statements', + ).toContain( + 'rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.', + ); + + withDatabase(ctx.dbPath, (db) => { + db.exec('ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"'); + db.exec('ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"'); + }); + + const update = await runDbUpdate(ctx, ['--json', '--confirm', 'journey.db']); + expect(update.exitCode, `R4.05: db update after the rename: ${update.stdout}`).toBe(0); + const state = withDatabase(ctx.dbPath, (db) => ({ + rows: db.prepare(`SELECT id, email FROM "UserProfile" ORDER BY id`).all(), + objects: db + .prepare( + `SELECT type, name FROM sqlite_master WHERE tbl_name = 'UserProfile' ORDER BY type, name`, + ) + .all() + .map((row) => `${row['type']} ${row['name']}`), + })); + expect(state, 'R4.06: rows kept, indexes under the new name').toEqual({ + rows: [ + { id: 1, email: 'alice@example.com' }, + { id: 2, email: 'bob@example.com' }, + ], + objects: [ + 'index UserProfile_accountId_idx_cbfb3085', + 'index UserProfile_handle_idx_b5b249e4', + 'index sqlite_autoindex_UserProfile_1', + 'table UserProfile', + ], + }); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `R4.07: db verify --schema-only: ${verify.stderr}`).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); }); From c4498cb4afb6218541739b00c02bc4682db28c3e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 14:56:12 +0200 Subject: [PATCH 38/55] docs(projects): slice 2 spec, the by-hand rename is followed by db update Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/rename-table-operation/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md index 27895bf52a1a..036fb450ed5c 100644 --- a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md +++ b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md @@ -28,7 +28,7 @@ plans one `renameTable` operation followed by whatever else the diff needs, `mig **The operation.** `renameTable` in the Postgres and SQLite operation sets, following the `renameCheckConstraint` precedent (PR #29894) end to end: op builder with precheck (`from` exists, `to` absent), execute (`ALTER TABLE ... RENAME TO ...`, schema-qualified on Postgres), postcheck; `RenameTableCall` in the op-factory call union with `renderTypeScript` and import requirements so a hand-written migration can call it; the migration facade method; the public op-factory export. Operation class `widening`, idempotency class per ADR 038 (effect-idempotent with equivalence check: pre-state compatible if `from` or `to` already holds the table). Under an `additive`-only policy the op is refused the same way other `widening` ops are, not degraded to drop and create. -**Guard remedy.** `MIGRATION.TABLE_NAME_CASE_CHANGED` offers three ways out: add `@@map` to keep the old table; in a project with migration history, plan with `--rename "="`; in a project managed with `db update`, rename the table by hand with the statement the target supplies, after which the plan is empty. The by-hand statement comes from the target because SQLite cannot rename a table in one statement when only the case changes. The error reference and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` describe the same three paths, plus the by-hand constraint renames on Postgres and the by-hand collection rename on Mongo, which refuses the flag. Amended after review: the first draft kept the by-hand sentence only for Mongo, and the fragments were not updated until review found them stale. +**Guard remedy.** `MIGRATION.TABLE_NAME_CASE_CHANGED` offers three ways out: add `@@map` to keep the old table; in a project with migration history, plan with `--rename "="`; in a project managed with `db update`, rename the table by hand with the statements the target supplies, then run `db update` again, which renames or recreates the indexes named after the old table. The statements come from the target because SQLite cannot rename a table in one statement when only the case changes; it goes through a temporary name. On SQLite, `db update` drops an index before creating one whose name differs only in case, since SQLite compares index names without regard to case. The error reference and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` describe the same three paths, plus the by-hand constraint renames on Postgres and the by-hand collection rename on Mongo, which refuses the flag. Amended after review: the first draft kept the by-hand sentence only for Mongo, and the fragments were not updated until review found them stale. ## Scope From 1d83f88cdf399f3e632766a81e303ea05691be87 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 15:13:00 +0200 Subject: [PATCH 39/55] docs(upgrade): extension authors ship a table rename as a contract-space migration The extension fragment copied the application guide's by-hand db update steps. Those rename the table only in the author's own database. Applications apply an extension's contract space only through the migrations its descriptor ships, so every application kept the old table. The rename section now says how to ship the rename: plan it in the extension package with migration plan --from --rename, add the migration to contractSpace.migrations, and move the head ref to its hash. It also says this only works when the package's history is in migrations/app/, where migration plan reads it. With migrations directly under migrations/, the layout Prisma's own extension packages use, the command cannot see the history and refuses, so a deliberate rename is not supported there yet and the author keeps @@map. Mongo contract spaces refuse --rename and keep @@map. Checked with throwaway journeys on Postgres and SQLite: planning in a package with flat history is refused, planning with history in migrations/app/ gives the rename operations, and an application that declares a descriptor shipping that migration renames the table with migration plan and db migrate, keeps its rows, and passes db verify --schema-only. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../extension/instructions.md | 39 +++---------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md index 024e473e4781..355206c6dff6 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md @@ -44,45 +44,18 @@ That is the Postgres output. On SQLite the last clause gives the two statements The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. -To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and rename the storage. +To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and ship the rename as a migration of your contract space. Applications change your tables only by running the migrations your descriptor lists in `contractSpace.migrations`: their `prisma migration plan` copies each one into `migrations//`, and `prisma db migrate` applies it. If you rename the table by hand in your own database and run `db update`, only that database changes. Every application keeps the old table. -**Postgres or SQLite, with migration history.** State each rename when you plan the migration: +**Postgres or SQLite.** From the extension package root, plan the rename from the package's latest migration: ```bash -prisma migration plan --rename "userProfile=UserProfile" +prisma migration plan --from --rename "userProfile=UserProfile" ``` -Repeat `--rename` once per model, and write either side as `.` for a table in a named schema. The plan renames the table and the constraints and indexes named after it, and the rows stay. `prisma migration new --rename "userProfile=UserProfile"` puts the same operations into a hand-written migration. Without migration history the flag is refused with `MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT`; rename by hand as below. +Repeat `--rename` once per model, and write either side as `.` for a table in a named schema. `--from` is needed because the package has no database, so it has no `db` ref. The new migration renames the table and the constraints and indexes named after it, and the rows stay. Add the new migration's `migration.json` and `ops.json` to `contractSpace.migrations` in the descriptor. In the head ref the descriptor passes as `headRef`, set `hash` to the new migration's `to` hash and keep `invariants` as they are. Applications get the rename when they upgrade the extension and run `prisma migration plan` and `prisma db migrate`, and their rows stay. -**Postgres, managed with `db update`.** Rename the table by hand, then run `prisma db update`: +This works only if the package's migration history is in `migrations/app/`, where `migration plan` reads and writes it. If the package keeps its migration directories directly under `migrations/`, as Prisma's own extension packages do, `migration plan` cannot see that history. Without `--from` it refuses the flag with `MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT`, and `--from` fails with `MIGRATION.REF_NOT_FOUND` for a directory name or `MIGRATION.SNAPSHOT_MISSING` for a hash. A deliberate rename is not supported for that layout yet, so keep the `@@map`. -```sql -ALTER TABLE "userProfile" RENAME TO "UserProfile"; -``` - -Inside a named schema, qualify the old name: `ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"`. The rows stay. Until `db update` runs, `db verify --schema-only` reports the indexes named after the old table as missing; `db update` renames them, and after that `db verify --schema-only` is clean. Primary keys, unique constraints and foreign keys that you did not name with `map:` keep the names derived from the old table, and neither `db verify` nor `db update` reports it. Rename them as well, because a migration you plan later derives these names from the new table name: - -```sql -ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"; -ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__key" TO "UserProfile__key"; -ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__fkey" TO "UserProfile__fkey"; -``` - -`` is the constraint's column names joined with `_`, such as `email` or `tenantId_email`. Add one statement per unique constraint and per foreign key. Inside a named schema, write the table as `"auth"."UserProfile"`. - -**SQLite, managed with `db update`.** SQLite refuses in one statement a rename that only changes case, so rename the table through a temporary name, then run `prisma db update`: - -```sql -ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; -ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"; -``` - -The rows stay, and SQLite renames its automatic indexes itself. `db update` drops each index named after the old table and creates it under the new name. Dropping an index needs your consent: `db update` asks for it, or, without a terminal, takes the database name it prints with `--confirm`. After that `db verify --schema-only` is clean. - -**MongoDB.** `--rename` is refused with `MIGRATION.RENAME_UNSUPPORTED`. Rename the collection by hand before you plan: - -```js -db.userProfile.renameCollection("UserProfile") -``` +**MongoDB.** `--rename` is refused with `MIGRATION.RENAME_UNSUPPORTED`, so a deliberate rename is not supported for a Mongo contract space yet. Keep the `@@map`. `contract infer` follows the same rule: a table whose name already equals the model name (`"UserProfile"`, `"User"`) infers to a model with no `@@map` and verifies clean, where the previous output pointed the model at a lowercase table that did not exist. A snake_case table still infers with `@@map("user_profile")`. There is nothing to detect for this: the inferred text for such a table is the same as before, it is now correct. From 937bdca4c5b3e7e8877034d640b88309f2d3f65b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 15:15:57 +0200 Subject: [PATCH 40/55] docs(projects): record the extension migrations layout mismatch as a follow-up Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- projects/psl-verbatim-table-names/plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/psl-verbatim-table-names/plan.md b/projects/psl-verbatim-table-names/plan.md index 1db34cd492da..2c8f9469a7e2 100644 --- a/projects/psl-verbatim-table-names/plan.md +++ b/projects/psl-verbatim-table-names/plan.md @@ -29,6 +29,8 @@ Slice 2 and slice 3 are independent of each other and run in parallel after slic ## Follow-ups filed outside this project +- Extension packages keep migrations directly under `migrations/` (per `.agents/rules/contract-space-package-layout.mdc` and every shipped extension), but `migration plan` reads and writes history under `migrations/app/`. Inside such a package `migration plan` cannot see the history, so `--from` fails and a stated rename cannot be planned. Found while writing the slice 2 extension upgrade note; it predates this project and affects every `migration plan` run in an extension package. + - Whether `contract infer` should keep names verbatim like Prisma 7 instead of re-casing to PascalCase. - Mongo has no planner guard; the operator chose not to address it in this project. From 3baae7d9cbad5a5c43aa59b50266b7af191e3145 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 15:37:18 +0200 Subject: [PATCH 41/55] fix(migration): qualify the guard's suggested --rename when the table name is not unique The case guard always suggested --rename "=". When another namespace declares the old table name, --rename refuses that unqualified value as ambiguous, so pasting the suggestion failed with MIGRATION.TABLE_RENAME_UNMATCHED. The guard now writes both sides as . when the table is outside the target's default namespace, or when another namespace declares the same name: another table the plan drops, or a table the next contract keeps. A table of the default namespace with a unique name keeps the short form. The planners pass the next contract and their default namespace (public on Postgres, the unbound namespace on SQLite, which never qualifies). Tests plan with the suggested value and check that it renames the table: two namespaces with the same table name, a table in a non-default namespace, and a unique name in the default namespace. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 2 +- .../core/migrations/table-name-case-guard.ts | 31 ++++- .../postgres/src/core/migrations/planner.ts | 3 + .../planner.table-name-case-guard.test.ts | 130 +++++++++++++++++- .../sqlite/src/core/migrations/planner.ts | 2 + 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 6c73f2ed085e..bcb82597babf 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1308,7 +1308,7 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.TABLE_NAME_CASE_CHANGED -The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename "X=Y"`: the plan renames the table and the constraints and indexes named after it instead of dropping and recreating the table. In a project that uses `db update`, which has no migration history, rename the table by hand with the statements the message gives, then run `db update` again. On Postgres that is `ALTER TABLE "X" RENAME TO "Y"`, schema-qualified inside a named schema. On SQLite, which refuses in one statement a rename that only changes case, it is `ALTER TABLE "X" RENAME TO "_prisma_rename_Y"; ALTER TABLE "_prisma_rename_Y" RENAME TO "Y"`. Payload: `droppedTable`, `createdTable`. +The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename "X=Y"`, which the message writes as `.X=.Y` when the table is outside the default namespace or another namespace declares `X`: the plan renames the table and the constraints and indexes named after it instead of dropping and recreating the table. In a project that uses `db update`, which has no migration history, rename the table by hand with the statements the message gives, then run `db update` again. On Postgres that is `ALTER TABLE "X" RENAME TO "Y"`, schema-qualified inside a named schema. On SQLite, which refuses in one statement a rename that only changes case, it is `ALTER TABLE "X" RENAME TO "_prisma_rename_Y"; ALTER TABLE "_prisma_rename_Y" RENAME TO "Y"`. Payload: `droppedTable`, `createdTable`. ### MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index 6a3863d99214..5eb3fad37f39 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -1,5 +1,8 @@ +import type { Contract } from '@internal/contract/types'; import type { SchemaDiffIssue } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; import type { SqlPlannerConflict } from './types'; export const TABLE_NAME_CASE_CHANGED_CODE = 'MIGRATION.TABLE_NAME_CASE_CHANGED'; @@ -29,6 +32,27 @@ function plannedTable(node: TableNameCaseGuardTable, namespaceId: string): Plann return { namespaceId, tableName: node.name }; } +function renameNeedsNamespace( + drop: PlannedTable, + dropped: readonly PlannedTable[], + contract: Contract, + defaultNamespaceId: string, +): boolean { + if (drop.namespaceId !== UNBOUND_NAMESPACE_ID && drop.namespaceId !== defaultNamespaceId) { + return true; + } + const declaredElsewhere = (namespaceId: string, tableName: string) => + namespaceId !== drop.namespaceId && tableName === drop.tableName; + return ( + dropped.some((other) => declaredElsewhere(other.namespaceId, other.tableName)) || + Object.values(contract.storage.namespaces).some((namespace) => + Object.keys(namespace.entries.table ?? {}).some((tableName) => + declaredElsewhere(namespace.id, tableName), + ), + ) + ); +} + /** * Finds every (drop `X`, create `Y`) pair in the same namespace where `X` is * `Y` with its first letter lowered. That shape is the signature of a schema @@ -45,6 +69,8 @@ export function detectTableNameCaseChanges(input: { readonly tableOf: (issue: SchemaDiffIssue) => TableNameCaseGuardTable | undefined; readonly namespaceIdOf: (issue: SchemaDiffIssue) => string; readonly renameByHandStatements: (rename: TableRenameByHand) => readonly string[]; + readonly contract: Contract; + readonly defaultNamespaceId: string; }): SqlPlannerConflict[] { const dropped: PlannedTable[] = []; const created: PlannedTable[] = []; @@ -66,10 +92,13 @@ export function detectTableNameCaseChanges(input: { candidate.tableName === lowerFirst(create.tableName), ); if (drop === undefined) continue; + const renameFlag = renameNeedsNamespace(drop, dropped, input.contract, input.defaultNamespaceId) + ? `${drop.namespaceId}.${drop.tableName}=${create.namespaceId}.${create.tableName}` + : `${drop.tableName}=${create.tableName}`; conflicts.push({ kind: 'tableNameCaseChanged', summary: `${TABLE_NAME_CASE_CHANGED_CODE}: table "${create.tableName}" would be created and table "${drop.tableName}" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model ${create.tableName} points at "${create.tableName}" instead of "${drop.tableName}".`, - why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "${drop.tableName}=${create.tableName}" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand with ${input.renameByHandStatements({ namespaceId: create.namespaceId, from: drop.tableName, to: create.tableName }).join('; ')}, then run db update again.`, + why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "${renameFlag}" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand with ${input.renameByHandStatements({ namespaceId: create.namespaceId, from: drop.tableName, to: create.tableName }).join('; ')}, then run db update again.`, location: { namespaceId: create.namespaceId, entityKind: 'table', diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index b80585db09b8..3d795cd788e1 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -39,6 +39,7 @@ import { assertDefined } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; +import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; import { postgresNodeStorageCoordinate } from '../schema-ir/node-storage-coordinate'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; @@ -460,6 +461,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr rename.to, ), ], + contract: options.contract, + defaultNamespaceId: DEFAULT_NAMESPACE_ID, }); if (caseChangeConflicts.length > 0) { return plannerFailure(caseChangeConflicts); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index 67b7351ce41c..154a2dee0c94 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -12,16 +12,19 @@ import { type Contract, type ControlPolicy, coreHash, profileHash } from '@internal/contract/types'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { APP_SPACE_ID, type StorageEntityRename } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; import { describe, expect, it } from 'vitest'; +import { postgresResolveDefault } from '../../src/core/default-normalizer'; +import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; -import { postgresCreateNamespace } from '../../src/core/postgres-schema'; +import { type PostgresContract, postgresCreateNamespace } from '../../src/core/postgres-schema'; import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; +import { postgresRenderDefault } from '../../src/exports/control'; const stubLowerer: ExecuteRequestLowerer = { lower(_ast, _ctx) { @@ -244,3 +247,126 @@ describe('Postgres planner table-name case guard', () => { expect(ids).toContain('table.Accounts'); }); }); + +function namespacedContract( + tablesByNamespace: Readonly>, + hashSeed: string, +): PostgresContract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: Object.fromEntries( + Object.entries(tablesByNamespace).map(([namespaceId, tableName]) => [ + namespaceId, + postgresCreateNamespace({ + id: namespaceId, + entries: { + table: { [tableName]: storageTable(undefined, undefined, tableName) }, + policy: {}, + }, + }), + ]), + ), + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +function planMigration( + from: PostgresContract, + to: PostgresContract, + renames: readonly StorageEntityRename[] = [], +) { + return createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: contractToPostgresDatabaseSchemaNode(from, { + annotationNamespace: 'pg', + renderDefault: postgresRenderDefault, + resolveDefault: postgresResolveDefault, + }), + policy: DESTRUCTIVE_POLICY, + fromContract: from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + ...(renames.length === 0 ? {} : { renames }), + }); +} + +function suggestedRenameFlag(why: string | undefined): string | undefined { + return /prisma migration plan --rename "([^"]+)"/.exec(why ?? '')?.[1]; +} + +function coordinate(side: string): StorageEntityRename['from'] { + const dot = side.indexOf('.'); + return dot === -1 + ? { name: side } + : { namespaceId: side.slice(0, dot), name: side.slice(dot + 1) }; +} + +function renameFromFlag(flag: string): StorageEntityRename { + const [from = '', to = ''] = flag.split('='); + return { from: coordinate(from), to: coordinate(to) }; +} + +describe('the --rename the Postgres case guard suggests', () => { + async function planWithSuggestion(from: PostgresContract, to: PostgresContract) { + const refused = planMigration(from, to); + expect(refused.kind).toBe('failure'); + if (refused.kind !== 'failure') throw new Error('the case guard did not refuse'); + const flag = suggestedRenameFlag(refused.conflicts[0]?.why); + if (flag === undefined) throw new Error('the case guard suggested no --rename'); + const renamed = planMigration(from, to, [renameFromFlag(flag)]); + expect( + renamed.kind === 'failure' ? renamed.conflicts.map((conflict) => conflict.summary) : [], + ).toEqual([]); + if (renamed.kind !== 'success') throw new Error('planning with the suggestion failed'); + const statements = (await Promise.all(renamed.plan.operations)).map((op) => + op.execute.map((step) => step.sql), + ); + return { flag, statements }; + } + + it('names the namespace when another namespace declares the old table name, and planning with it renames the table', async () => { + const result = await planWithSuggestion( + namespacedContract({ public: 'userProfile', auth: 'userProfile' }, 'two-namespaces-from'), + namespacedContract({ public: 'UserProfile', auth: 'userProfile' }, 'two-namespaces-to'), + ); + + expect(result).toEqual({ + flag: 'public.userProfile=public.UserProfile', + statements: [['ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"']], + }); + }); + + it('names the namespace when the table is not in the default namespace', async () => { + const result = await planWithSuggestion( + namespacedContract({ auth: 'userProfile' }, 'auth-from'), + namespacedContract({ auth: 'UserProfile' }, 'auth-to'), + ); + + expect(result).toEqual({ + flag: 'auth.userProfile=auth.UserProfile', + statements: [['ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"']], + }); + }); + + it('stays unqualified for a table of the default namespace whose name no other namespace declares', async () => { + const result = await planWithSuggestion( + namespacedContract({ public: 'userProfile', auth: 'account' }, 'public-from'), + namespacedContract({ public: 'UserProfile', auth: 'account' }, 'public-to'), + ); + + expect(result).toEqual({ + flag: 'userProfile=UserProfile', + statements: [['ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"']], + }); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index ed96771fabc0..2319296001d7 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -294,6 +294,8 @@ export class SqliteMigrationPlanner namespaceIdOf: () => UNBOUND_NAMESPACE_ID, renameByHandStatements: (rename) => renameTableSteps(rename.from, rename.to).map((renameStep) => renameStep.sql), + contract: options.contract, + defaultNamespaceId: UNBOUND_NAMESPACE_ID, }); if (caseChangeConflicts.length > 0) { return plannerFailure(caseChangeConflicts); From a474cfe72979c93260baf4e7fba76c34722c7fa2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 15:39:43 +0200 Subject: [PATCH 42/55] fix(migration): migration new --rename leaves out renames the control policy suppresses on Postgres migration plan passes its table, constraint, index and check renames through the control-policy partition, so an external, observed or tolerated table gets no rename DDL. The Postgres scaffold behind migration new --rename returned the same calls without that partition, so it wrote rename operations for those tables. The scaffold now runs its calls through the partition planning uses. Both go through one helper in the Postgres planner, which also replaces the four copies of the partition call inside planning. tolerated allows only creating an object that is absent; a rename changes an existing table, so planning suppresses it and the scaffold now does too. SQLite has no control-policy partition in planning or in the scaffold, so it needs no change. Tests scaffold and plan a rename of an external, observed and tolerated table and expect no operations from either. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../postgres/src/core/migrations/planner.ts | 71 ++++++++++--------- .../test/migrations/rename-table-fixtures.ts | 4 +- .../migrations/rename-table-scaffold.test.ts | 38 +++++++--- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 3d795cd788e1..d89cdb246c7f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -101,6 +101,18 @@ function emissionSchemaForNamespace(contract: Contract, namespaceId: : resolveDdlSchemaForNamespaceStorage(contract.storage, namespaceId); } +function partitionPostgresCallsByControlPolicy( + calls: readonly TCall[], + contract: Contract, +) { + return partitionCallsByControlPolicy({ + calls, + contract, + resolveControlPolicySubject: (call) => resolvePostgresCallControlPolicySubject(call, contract), + resolveFactoryName: (call) => call.factoryName, + }); +} + const SCAFFOLD_POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'], }; @@ -214,7 +226,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr } /** - * The rename operations `planSql` plans for the stated renames, and nothing else: the table and constraint renames, then the index and check renames on the renamed tables. Every other difference between the two contracts is left to the author. + * The rename operations `planSql` plans for the stated renames, and nothing else: the table and constraint renames, then the index and check renames on the renamed tables, less those the tables' control policies suppress. Every other difference between the two contracts is left to the author. */ private scaffoldTableRenames( context: MigrationScaffoldContext<'sql', 'postgres'>, @@ -257,11 +269,14 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr renamedTables.some( (renamed) => renamed.schemaName === call.schemaName && renamed.tableName === call.tableName, ); - return [ - ...planned.value.calls, - ...this.pairIndexRenames(options, relationalIssues).calls.filter(onRenamedTable), - ...this.pairCheckRenames(options, relationalIssues).calls.filter(onRenamedTable), - ]; + return partitionPostgresCallsByControlPolicy( + [ + ...planned.value.calls, + ...this.pairIndexRenames(options, relationalIssues).calls.filter(onRenamedTable), + ...this.pairCheckRenames(options, relationalIssues).calls.filter(onRenamedTable), + ], + contract, + ).kept; } /** @@ -493,29 +508,20 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return plannerFailure([...(result.ok ? [] : result.failure), ...schemaDiff.conflicts]); } - const renameTablePartition = partitionCallsByControlPolicy({ - calls: renameTableCalls, - contract: options.contract, - resolveControlPolicySubject: (call) => - resolvePostgresCallControlPolicySubject(call, options.contract), - resolveFactoryName: (call) => call.factoryName, - }); + const renameTablePartition = partitionPostgresCallsByControlPolicy( + renameTableCalls, + options.contract, + ); - const indexRenamePartition = partitionCallsByControlPolicy({ - calls: [...indexRenames.calls, ...checkRenames.calls], - contract: options.contract, - resolveControlPolicySubject: (call) => - resolvePostgresCallControlPolicySubject(call, options.contract), - resolveFactoryName: (call) => call.factoryName, - }); + const indexRenamePartition = partitionPostgresCallsByControlPolicy( + [...indexRenames.calls, ...checkRenames.calls], + options.contract, + ); - const schemaDiffPartition = partitionCallsByControlPolicy({ - calls: schemaDiff.calls, - contract: options.contract, - resolveControlPolicySubject: (call) => - resolvePostgresCallControlPolicySubject(call, options.contract), - resolveFactoryName: (call) => call.factoryName, - }); + const schemaDiffPartition = partitionPostgresCallsByControlPolicy( + schemaDiff.calls, + options.contract, + ); // Inline `onFieldEvent`-emitted ops after structural DDL. The fixed // ordering is `structural → added → dropped → altered`, with @@ -533,13 +539,10 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr readonly PostgresOpFactoryCall[], 'Codec hook ops conform to PostgresOpFactoryCall at the app emitter boundary' >(fieldEventOps); - const fieldEventPartition = partitionCallsByControlPolicy({ - calls: fieldEventPostgresCalls, - contract: options.contract, - resolveControlPolicySubject: (call) => - resolvePostgresCallControlPolicySubject(call, options.contract), - resolveFactoryName: (call) => call.factoryName, - }); + const fieldEventPartition = partitionPostgresCallsByControlPolicy( + fieldEventPostgresCalls, + options.contract, + ); // The table renames run first: every later operation addresses the // renamed table by its new name. const calls = [ diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts index 6cfc5222ed2b..ac9a252a7e82 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts @@ -1,4 +1,4 @@ -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { type Contract, type ControlPolicy, coreHash, profileHash } from '@internal/contract/types'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { @@ -28,6 +28,7 @@ export interface ProfileSpec { readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; readonly indexes?: (tableName: string) => readonly IndexInput[]; readonly checks?: (tableName: string) => readonly CheckConstraintInput[]; + readonly control?: ControlPolicy; } function profileTable(tableName: string, spec: ProfileSpec): StorageTable { @@ -38,6 +39,7 @@ function profileTable(tableName: string, spec: ProfileSpec): StorageTable { indexes: spec.indexes?.(tableName) ?? [], foreignKeys: spec.foreignKeys?.(tableName) ?? [], checks: spec.checks?.(tableName) ?? [], + ...(spec.control === undefined ? {} : { control: spec.control }), }); } diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts index c301939e590b..7e91667b1bfe 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts @@ -61,6 +61,19 @@ function scaffold(input: { ); } +function plan(from: Contract, to: Contract) { + return createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: postgresContractToSchema(from, []), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + renames: [RENAME], + }); +} + async function labelsOf(plan: MigrationPlanWithAuthoringSurface): Promise { return (await Promise.all(plan.operations)).map((op) => op.label); } @@ -77,16 +90,7 @@ describe('Postgres scaffold rename-table intents', () => { it('carries the same operations migration plan plans for the rename', async () => { const from = contractOf('userProfile', withObjects, FROM_HASH); const to = contractOf('UserProfile', withObjects, TO_HASH); - const planned = createPostgresMigrationPlanner(stubLowerer).plan({ - contract: to, - schema: postgresContractToSchema(from, []), - policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, - fromContract: from, - frameworkComponents: [], - spaceId: APP_SPACE_ID, - snapshotsImportPath: '../../snapshots', - renames: [RENAME], - }); + const planned = plan(from, to); expect(planned.kind).toBe('success'); if (planned.kind !== 'success') return; @@ -154,6 +158,20 @@ describe('Postgres scaffold rename-table intents', () => { ); }); + it.each(['external', 'observed', 'tolerated'] as const)( + 'carries no rename for a %s table, as migration plan plans none', + async (control) => { + const from = contractOf('userProfile', { ...withObjects, control }, FROM_HASH); + const to = contractOf('UserProfile', { ...withObjects, control }, TO_HASH); + const planned = plan(from, to); + expect(planned.kind).toBe('success'); + if (planned.kind !== 'success') return; + + expect(await labelsOf(planned.plan)).toEqual([]); + expect(await labelsOf(scaffold({ from, to }))).toEqual([]); + }, + ); + it('scaffolds an empty body without renames', () => { const empty = createPostgresMigrationPlanner(stubLowerer).emptyMigration( { From f8a9aa5694c9862039b7a6a1a999ca4b433e0ac6 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 15:47:07 +0200 Subject: [PATCH 43/55] fix(migration): rename a changed constraint after the new table, not to its replacement's name A table rename on Postgres renames each primary key, unique constraint and foreign key the previous contract left unnamed. It picked the new name by matching only the primary key's presence or a foreign key's source columns. When the next contract changed the constraint and gave the replacement an explicit name, the old constraint took that name. The diff then dropped the constraint under the name derived from the new table, which no longer existed, and migrate failed with the schema not matching the contract. The companion rename now uses the explicit name only for a constraint the diff finds unchanged: it compares the renamed previous table and the next table as schema nodes, with the same node id and isEqualTo the diff uses (primary key columns; unique columns; foreign key source columns, target and referential actions). A changed constraint is renamed to the name derived from the new table, which is the name the diff's drop uses, and the diff then creates the replacement. Tests: a planner test for a rename with a foreign key pointed at another table under an explicit name; a scaffold test for a rename with a primary key column change (migration plan refuses the key change with or without a rename, and the scaffold now renames the key after the new table instead of to the replacement's name); journey R5 migrates the foreign key case, keeps the rows and verifies clean. Before the fix the journey's migrate failed. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../postgres/src/core/migrations/planner.ts | 38 +++--- .../table-rename-constraint-renames.ts | 51 ++++---- ...rename-table-planner.table-objects.test.ts | 46 +++++++ .../migrations/rename-table-scaffold.test.ts | 20 ++++ test/integration/test/cli-journeys/README.md | 2 +- .../rename-table-migration.e2e.test.ts | 112 +++++++++++++++++- 6 files changed, 231 insertions(+), 38 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index d89cdb246c7f..1259f55ef7ac 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -1,6 +1,7 @@ import type { Contract } from '@internal/contract/types'; import type { MigrationOperationPolicy, + ResolvedTableRename, SqlMigrationPlannerPlanOptions, SqlPlannerConflict, SqlPlannerFailureResult, @@ -101,6 +102,19 @@ function emissionSchemaForNamespace(contract: Contract, namespaceId: : resolveDdlSchemaForNamespaceStorage(contract.storage, namespaceId); } +function renamedTableNode( + schema: PostgresDatabaseSchemaNode, + contract: Contract, + rename: ResolvedTableRename, +): PostgresTableSchemaNode { + const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, rename.namespaceId); + const table = Object.values(schema.namespaces).find( + (namespace) => namespace.schemaName === ddlSchema, + )?.tables[rename.to]; + assertDefined(table, `a resolved rename names table "${rename.to}" in schema "${ddlSchema}"`); + return table; +} + function partitionPostgresCallsByControlPolicy( calls: readonly TCall[], contract: Contract, @@ -298,25 +312,21 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr if (!applied.ok) { return notOk(applied.failure); } + const previousSchema = postgresContractToSchema( + applied.value.contract, + options.frameworkComponents, + ); + const nextSchema = postgresContractToSchema(options.contract, options.frameworkComponents); const calls = applied.value.renames.flatMap((rename): PostgresOpFactoryCall[] => { const schemaName = emissionSchemaForNamespace(options.contract, rename.namespaceId); - const previous = - options.fromContract?.storage.namespaces[rename.namespaceId]?.entries.table?.[rename.from]; - const next = - options.contract.storage.namespaces[rename.namespaceId]?.entries.table?.[rename.to]; - assertDefined( - previous, - `a resolved rename names table "${rename.from}" of the previous contract`, - ); - assertDefined(next, `a resolved rename names table "${rename.to}" of the next contract`); return [ new RenameTableCall(schemaName, rename.from, rename.to), ...constraintRenamesForTableRename({ schemaName, from: rename.from, to: rename.to, - previous, - next, + previous: renamedTableNode(previousSchema, options.contract, rename), + next: renamedTableNode(nextSchema, options.contract, rename), }), ]; }); @@ -330,11 +340,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr ), ); } - return ok({ - fromContract: applied.value.contract, - previousSchema: postgresContractToSchema(applied.value.contract, options.frameworkComponents), - calls, - }); + return ok({ fromContract: applied.value.contract, previousSchema, calls }); } private planSql(options: SqlMigrationPlannerPlanOptions): PostgresPlanResult { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts index 2de77dba1c16..109e6466e2f4 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts @@ -1,4 +1,5 @@ -import type { StorageTable } from '@internal/sql-contract/types'; +import type { DiffableNode } from '@internal/framework-components/control'; +import type { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; import { defaultForeignKeyName, defaultPrimaryKeyName, @@ -10,57 +11,67 @@ export interface TableRenameConstraintInput { readonly schemaName: string; readonly from: string; readonly to: string; - readonly previous: StorageTable; - readonly next: StorageTable; + /** The renamed table as the previous contract describes it once every stated rename is applied. */ + readonly previous: PostgresTableSchemaNode; + readonly next: PostgresTableSchemaNode; } -function sameColumns(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((column, index) => column === right[index]); +/** The constraint of the next table the diff pairs with `node` and finds unchanged, as the diff compares them. */ +function unchangedIn( + node: TNode, + nextNodes: readonly TNode[], +): TNode | undefined { + return nextNodes.find((next) => next.id === node.id && next.isEqualTo(node)); } /** - * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the previous contract left unnamed carries a name the planner derived from the old table name. It is renamed to the name the next contract gives it explicitly, or otherwise to the name the planner now derives from the new table name. A constraint the previous contract named keeps its name. Indexes and checks are not handled here: their wire names pair by content hash in the ordinary rename passes. + * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the previous contract left unnamed carries a name the planner derived from the old table name. When the next contract keeps the same constraint, it is renamed to the name the next contract gives it explicitly, or otherwise to the name the planner now derives from the new table name. When the constraint changed, it is renamed to the derived name, which is the name the diff's drop of it uses. A constraint the previous contract named keeps its name. Indexes and checks are not handled here: their wire names pair by content hash in the ordinary rename passes. */ export function constraintRenamesForTableRename( input: TableRenameConstraintInput, ): readonly RenameConstraintCall[] { const { schemaName, from, to, previous, next } = input; - const renameIfChanged = ( + const rename = ( kind: 'primaryKey' | 'unique' | 'foreignKey', oldName: string, - newName: string, - ): readonly RenameConstraintCall[] => - oldName === newName ? [] : [new RenameConstraintCall(schemaName, to, kind, oldName, newName)]; + unchanged: { readonly name?: string } | undefined, + derivedName: string, + ): readonly RenameConstraintCall[] => { + const newName = unchanged?.name ?? derivedName; + return oldName === newName + ? [] + : [new RenameConstraintCall(schemaName, to, kind, oldName, newName)]; + }; const primaryKey = previous.primaryKey !== undefined && previous.primaryKey.name === undefined - ? renameIfChanged( + ? rename( 'primaryKey', defaultPrimaryKeyName(from), - next.primaryKey?.name ?? defaultPrimaryKeyName(to), + unchangedIn(previous.primaryKey, next.primaryKey === undefined ? [] : [next.primaryKey]), + defaultPrimaryKeyName(to), ) : []; const uniques = previous.uniques .filter((unique) => unique.name === undefined) .flatMap((unique) => - renameIfChanged( + rename( 'unique', defaultUniqueName(from, unique.columns), - next.uniques.find((candidate) => sameColumns(candidate.columns, unique.columns))?.name ?? - defaultUniqueName(to, unique.columns), + unchangedIn(unique, next.uniques), + defaultUniqueName(to, unique.columns), ), ); const foreignKeys = previous.foreignKeys .filter((fk) => fk.name === undefined) .flatMap((fk) => - renameIfChanged( + rename( 'foreignKey', - defaultForeignKeyName(from, fk.source.columns), - next.foreignKeys.find((candidate) => - sameColumns(candidate.source.columns, fk.source.columns), - )?.name ?? defaultForeignKeyName(to, fk.source.columns), + defaultForeignKeyName(from, fk.columns), + unchangedIn(fk, next.foreignKeys), + defaultForeignKeyName(to, fk.columns), ), ); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts index 1abd631898b0..d9d816b340a7 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts @@ -208,4 +208,50 @@ describe('Postgres planner rename-table intents with table objects', () => { ]); }); }); + + describe('constraints the next contract changes as well as names', () => { + it('renames the foreign key to the new table name, then replaces it when its target changes', async () => { + const memberTable = () => ({ member: postTable('account') }); + expect( + await plannedSql( + contractOf( + 'userProfile', + { + foreignKeys: (tableName) => [ + { + source: reference(tableName, ['accountId']), + target: reference('account', ['id']), + }, + ], + }, + 'from', + memberTable, + ), + contractOf( + 'UserProfile', + { + foreignKeys: (tableName) => [ + { + source: reference(tableName, ['accountId']), + target: reference('member', ['id']), + name: 'profile_member_fk', + }, + ], + }, + 'to', + memberTable, + ), + ), + ).toEqual([ + RENAME_TABLE, + [ + 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_accountId_fkey" TO "UserProfile_accountId_fkey"', + ], + ['ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_accountId_fkey"'], + [ + 'ALTER TABLE "UserProfile"\nADD CONSTRAINT "profile_member_fk"\nFOREIGN KEY ("accountId")\nREFERENCES "member" ("id")', + ], + ]); + }); + }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts index 7e91667b1bfe..6be395df43c1 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts @@ -172,6 +172,26 @@ describe('Postgres scaffold rename-table intents', () => { }, ); + it('renames a primary key whose columns change after the new table name, and leaves the change to the author', async () => { + const from = contractOf('userProfile', { primaryKey: { columns: ['id'] } }, FROM_HASH); + const to = contractOf( + 'UserProfile', + { primaryKey: { columns: ['id', 'email'], name: 'profile_pk' } }, + TO_HASH, + ); + + expect(plan(from, to)).toMatchObject({ + kind: 'failure', + conflicts: [ + { kind: 'indexIncompatible', summary: 'database/public/UserProfile/primary-key' }, + ], + }); + expect(await labelsOf(scaffold({ from, to }))).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + ]); + }); + it('scaffolds an empty body without renames', () => { const empty = createPostgresMigrationPlanner(stubLowerer).emptyMigration( { diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index d731674a6b47..727f22e0b896 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -30,7 +30,7 @@ pnpm test:journeys | `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column, tests `--no-interactive` rejection, `--json` error envelope, and `--json -y` auto-accept. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | -| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard and a stale `--rename` intent fails planning, then `migration plan --rename userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. Journey R2 authors the same rename through `migration new --rename`, whose scaffold carries the same operations. In both, `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies | +| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard and a stale `--rename` intent fails planning, then `migration plan --rename userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. Journey R2 authors the same rename through `migration new --rename`, whose scaffold carries the same operations. In both, `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. Journey R5 renames the table in the same migration that points its foreign key at another table under an explicit name: the foreign key is renamed after the new table, then dropped and created, the rows stay and `db verify --schema-only` is clean | | `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration. A second journey follows the by-hand path of a `db update` project: the case guard gives the two-statement rename through `_prisma_rename_UserProfile`, and after running it `db update` drops and recreates the indexes named after the old table, keeps the rows and verifies clean | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts index d24d1a133db4..43ad78d9ae91 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -8,9 +8,11 @@ * Journey R1 (`migration plan`) also shows that planning without the flag is refused by the case-change guard and that a stale intent fails planning. * * Journey R2 (`migration new`) authors the rename by hand: the flag pre-fills the scaffolded `migration.ts` with the same operations `migration plan` would plan. + * + * Journey R5 renames the table in the same migration that points its foreign key at another table under an explicit name. The foreign key is renamed after the new table, then dropped and created against the new table; the rows stay and `db verify --schema-only` is clean. */ -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; @@ -35,6 +37,44 @@ import { useDevDatabase, } from '../utils/journey-test-helpers'; +const FOREIGN_KEY_FROM_PSL = `// use prisma-8 + +model Account { + id Int @id + profiles UserProfile[] +} + +model Member { + id Int @id +} + +model UserProfile { + id Int @id + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@map("userProfile") +} +`; + +const FOREIGN_KEY_TO_PSL = `// use prisma-8 + +model Account { + id Int @id +} + +model Member { + id Int @id + profiles UserProfile[] +} + +model UserProfile { + id Int @id + accountId Int + member Member @relation(fields: [accountId], references: [id], map: "profile_member_fk") +} +`; + interface PlanDocument { readonly operations: readonly { id: string; label: string; operationClass: string }[]; } @@ -278,4 +318,74 @@ withTempDir(({ createTempDir }) => { timeouts.spinUpPpgDev, ); }); + + describe('Journey R5: rename a table and point its foreign key at another table', () => { + const db = useDevDatabase(); + + it( + 'renames the foreign key after the new table, replaces it, keeps the rows, and verifies clean', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_FROM_PSL); + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `R5.01: emit userProfile: ${emit.stderr}`).toBe(0); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `R5.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `R5.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + await sql( + db.connectionString, + `INSERT INTO "public"."Account" (id) VALUES (1); + INSERT INTO "public"."Member" (id) VALUES (1); + INSERT INTO "public"."userProfile" (id, "accountId") VALUES (1, 1)`, + ); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_TO_PSL); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `R5.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); + const plan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'rename-and-retarget', + '--from', + latestMigrationDirName(ctx), + '--rename', + 'userProfile=UserProfile', + '--json', + ]); + expect(plan.exitCode, `R5.05: plan: ${plan.stderr}`).toBe(0); + expect( + parseJsonOutput(plan) + .operations.map((op) => op.label) + .slice(0, 3), + 'R5.05: the table rename, then its keys renamed after the new table', + ).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + ]); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `R5.06: migrate: ${apply.stderr}`).toBe(0); + const live = await sql( + db.connectionString, + `SELECT + (SELECT array_agg(id) FROM "public"."UserProfile") AS ids, + (SELECT array_agg(conname::text || '->' || confrelid::regclass::text ORDER BY conname) + FROM pg_constraint + WHERE conrelid = '"public"."UserProfile"'::regclass AND contype = 'f') AS foreign_keys`, + ); + expect(live.rows[0], 'R5.07: rows kept, one foreign key to Member').toEqual({ + ids: [1], + foreign_keys: ['profile_member_fk->"Member"'], + }); + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `R5.08: db verify --schema-only: ${verify.stderr}`).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); }); From 68c8291be2276ba32834123ad969fe0d2b00c18c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 15:49:00 +0200 Subject: [PATCH 44/55] fix(migration): SQLite compares identifier case only for ASCII letters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLite target decided that two table names, or two index names, collide by comparing them with toLowerCase(). SQLite folds only ASCII letters: a probe on SQLite 3.50.4 creates tables "Ä" and "ä" side by side, refuses "a" beside "A", and renames "Ö" to "ö" in one statement. So a table rename that changes only a non-ASCII letter took the two-statement path through a temporary name, with a precheck that the temporary name is free, and an index whose name changed only in a non-ASCII letter was paired as a replacement and dropped before its replacement was created. One helper in the SQLite target, sqliteIdentifiersCollide, folds only A to Z. The rename classification (which also decides the temporary-name precheck and the by-hand statements the case guard gives) and the case-only index pairing both use it. Tests: a rename from "Äpfel" to "äpfel" is one statement with no temporary-name precheck, and an index renamed from "Äpfel_..." to "äpfel_..." keeps the usual create-then-drop order. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../sqlite/src/core/migrations/identifier-case.ts | 10 ++++++++++ .../sqlite/src/core/migrations/index-replacements.ts | 5 +++-- .../sqlite/src/core/migrations/operations/tables.ts | 9 +++++---- .../migrations/planner.case-only-index-names.test.ts | 7 +++++++ .../sqlite/test/migrations/rename-table-ops.test.ts | 8 ++++++++ 5 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/identifier-case.ts diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/identifier-case.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/identifier-case.ts new file mode 100644 index 000000000000..bbeaab03c38d --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/identifier-case.ts @@ -0,0 +1,10 @@ +function foldAsciiLetters(identifier: string): string { + return identifier.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} + +/** + * Whether SQLite takes two identifiers for the same name. It compares table and index names without regard to the case of ASCII letters, and compares every other character exactly. + */ +export function sqliteIdentifiersCollide(left: string, right: string): boolean { + return foldAsciiLetters(left) === foldAsciiLetters(right); +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts index 126753f20f3e..64ee96e09a2b 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts @@ -2,6 +2,7 @@ import type { SchemaDiffIssue } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; import { parseWireName } from '@internal/sql-schema-ir/naming'; import { SqlIndexIR } from '@internal/sql-schema-ir/types'; +import { sqliteIdentifiersCollide } from './identifier-case'; import { issueNode } from './issue-planner'; import { CreateIndexCall, DropIndexCall, type SqliteOpFactoryCall } from './op-factory-call'; @@ -49,11 +50,11 @@ export function renamedTableIndex(renamedTables: ReadonlySet): IndexRepl export const indexNameCaseChange: IndexReplacementMatch = (old, replacement) => old.tableName === replacement.tableName && old.index.name !== replacement.index.name && - old.index.name.toLowerCase() === replacement.index.name.toLowerCase() && + sqliteIdentifiersCollide(old.index.name, replacement.index.name) && JSON.stringify(old.index.columns ?? []) === JSON.stringify(replacement.index.columns ?? []); /** - * Pairs each index the plan drops with the index that replaces it, and plans every paired drop before every paired create. SQLite cannot rename an index, and it compares index names without case, so a replacement whose name differs from the old one only in case would otherwise collide with it. The paired issues are returned as consumed so the ordinary diff does not plan them again. + * Pairs each index the plan drops with the index that replaces it, and plans every paired drop before every paired create. SQLite cannot rename an index, and it compares index names without regard to the case of ASCII letters, so a replacement whose name differs from the old one only in that case would otherwise collide with it. The paired issues are returned as consumed so the ordinary diff does not plan them again. */ export function pairIndexReplacements( issues: readonly SchemaDiffIssue[], diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts index c212b9ec384a..f82e7453d7b0 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts @@ -7,6 +7,7 @@ import { blindCast } from '@internal/utils/casts'; import { tableExistsAst } from '../../../contract-free/checks'; import { stripOuterParens } from '../../default-normalizer'; import { escapeLiteral, quoteIdentifier } from '../../sql-utils'; +import { sqliteIdentifiersCollide } from '../identifier-case'; import { buildCreateIndexSql } from '../planner-ddl-builders'; import { buildTargetDetails } from '../planner-target-details'; import { @@ -82,13 +83,13 @@ export function renameTableViaName(toName: string): string { } export function renameChangesOnlyCase(fromName: string, toName: string): boolean { - return fromName.toLowerCase() === toName.toLowerCase(); + return sqliteIdentifiersCollide(fromName, toName); } /** - * SQLite compares table names case-insensitively, so a rename that only - * changes case (`userProfile` to `UserProfile`) is refused as "already - * exists" when done in one statement. It goes through a temporary name. + * SQLite takes names that differ only in the case of ASCII letters for the + * same name, so such a rename (`userProfile` to `UserProfile`) is refused as + * "already exists" when done in one statement. It goes through a temporary name. */ export function renameTableSteps(fromName: string, toName: string): Op['execute'] { const from = quoteIdentifier(fromName); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts index 3f241367389e..860008354bca 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts @@ -38,4 +38,11 @@ describe('SQLite planner index names that differ only in case', () => { `Drop index user_profile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, ]); }); + + it('keeps the usual order for an index whose name changes only in the case of a non-ASCII letter, which SQLite does not fold', async () => { + expect(await plannedLabels('Äpfel', 'äpfel')).toEqual([ + `Create index äpfel_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Drop index Äpfel_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); }); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts index cb0d54794259..082d331dcd66 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts @@ -78,6 +78,14 @@ describe('RenameTableCall (sqlite)', () => { ]); }); + it('renames in one statement when only a non-ASCII letter changes case, since SQLite folds only ASCII letters', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await new RenameTableCall('Äpfel', 'äpfel').toOp(lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual(['ALTER TABLE "Äpfel" RENAME TO "äpfel"']); + expect(received).not.toContainEqual(tableExistsAst('_prisma_rename_äpfel').tableAbsent()); + }); + it('prechecks that the temporary name is free on a case-only rename, saying why it is needed', async () => { const { lowerer, received } = recordingCheckLowerer(); const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); From 943d2d74388e1218044b44a2548aa309fc91b884 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 15:52:19 +0200 Subject: [PATCH 45/55] fix(migration): SQLite drops an index before creating one whose name collides, whatever the columns The SQLite planner pairs an index it drops with one it creates when SQLite takes the two names for the same name, so the drop runs first. The pairing also required the same columns. When a case-only name change came with a column change, the two were left to the ordinary order, create before drop, and SQLite refused the create with "index Idx_Handle already exists". The pairing now matches on the table and the colliding name only. It orders the drop before the create; the create is built from the new index's own node, so a changed definition is kept. What the SQLite target can express in an index, checked with a probe: a SQLite contract refuses an index with where or an expression at construction, and the SQLite create index call renders only the columns, so unique, type and options are not rendered on any path, including a plain create. A pair therefore never creates an expression index with no columns, and it loses nothing a plain create would keep. Test: an index renamed from idx_handle on (handle) to Idx_Handle on (handle, email) plans the drop first, and the planned statements run on SQLite and leave only the new index. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/index-replacements.ts | 5 +- .../planner.case-only-index-names.test.ts | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts index 64ee96e09a2b..7dc91f66e553 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts @@ -45,13 +45,12 @@ export function renamedTableIndex(renamedTables: ReadonlySet): IndexRepl } /** - * An index whose name changes only in case on the same table and columns, as after a table renamed by hand. + * An index on the same table whose new name SQLite takes for the old one, as after a table renamed by hand. Its content is not compared: the old index must be dropped before the new one can be created whatever either defines, and the new one is created from its own definition. */ export const indexNameCaseChange: IndexReplacementMatch = (old, replacement) => old.tableName === replacement.tableName && old.index.name !== replacement.index.name && - sqliteIdentifiersCollide(old.index.name, replacement.index.name) && - JSON.stringify(old.index.columns ?? []) === JSON.stringify(replacement.index.columns ?? []); + sqliteIdentifiersCollide(old.index.name, replacement.index.name); /** * Pairs each index the plan drops with the index that replaces it, and plans every paired drop before every paired create. SQLite cannot rename an index, and it compares index names without regard to the case of ASCII letters, so a replacement whose name differs from the old one only in that case would otherwise collide with it. The paired issues are returned as consumed so the ordinary diff does not plan them again. diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts index 860008354bca..df11d3e90cf2 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts @@ -2,7 +2,9 @@ * After a table is renamed by hand, `db update` finds each index named after the old table under a name that differs only in case from the one the contract wants. SQLite compares index names without case, so the old index must be dropped before the new one is created. */ +import { DatabaseSync } from 'node:sqlite'; import { APP_SPACE_ID } from '@internal/framework-components/control'; +import type { IndexInput } from '@internal/sql-contract/types'; import { describe, expect, it } from 'vitest'; import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; @@ -24,6 +26,17 @@ async function plannedLabels(liveIndexTable: string, contractIndexTable: string) return (await Promise.all(result.plan.operations)).map((op) => op.label); } +function exactIndex(name: string, columns: readonly string[]): IndexInput { + return { + columns, + naming: { kind: 'exact', name }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }; +} + describe('SQLite planner index names that differ only in case', () => { it('drops the old index before creating the one whose name differs only in case', async () => { expect(await plannedLabels('userProfile', 'UserProfile')).toEqual([ @@ -39,6 +52,51 @@ describe('SQLite planner index names that differ only in case', () => { ]); }); + it('drops the old index first when the name changes only in case and the columns change too, and the plan runs on SQLite', async () => { + const live = contractOf( + 'UserProfile', + { indexes: () => [exactIndex('idx_handle', ['handle'])] }, + 'live', + ); + const result = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: contractOf( + 'UserProfile', + { indexes: () => [exactIndex('Idx_Handle', ['handle', 'email'])] }, + 'to', + ), + schema: sqliteContractToSchema(live), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: null, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const statements = (await Promise.all(result.plan.operations)).flatMap((op) => + op.execute.map((step) => step.sql), + ); + + const db = new DatabaseSync(':memory:'); + try { + db.exec('CREATE TABLE "UserProfile" ("id" INTEGER PRIMARY KEY, "email" TEXT, "handle" TEXT)'); + db.exec('CREATE INDEX "idx_handle" ON "UserProfile" ("handle")'); + expect(() => { + for (const statement of statements) db.exec(statement); + }).not.toThrow(); + expect( + db.prepare(`SELECT name, sql FROM sqlite_master WHERE type = 'index' ORDER BY name`).all(), + ).toEqual([ + { + name: 'Idx_Handle', + sql: 'CREATE INDEX "Idx_Handle" ON "UserProfile" ("handle", "email")', + }, + ]); + } finally { + db.close(); + } + }); + it('keeps the usual order for an index whose name changes only in the case of a non-ASCII letter, which SQLite does not fold', async () => { expect(await plannedLabels('Äpfel', 'äpfel')).toEqual([ `Create index äpfel_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, From ededa6855ff3af6cd971866e6e677f31fd82f6e4 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 16:48:42 +0200 Subject: [PATCH 46/55] docs(projects): slice 2 states renames in hand-written migrations; planner hints become a follow-up Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../psl-verbatim-table-names/design-notes.md | 5 ++ projects/psl-verbatim-table-names/plan.md | 8 +-- .../slices/rename-table-operation/spec.md | 52 +++++++------------ 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/projects/psl-verbatim-table-names/design-notes.md b/projects/psl-verbatim-table-names/design-notes.md index cbd9fa232c46..8b08396ecc5e 100644 --- a/projects/psl-verbatim-table-names/design-notes.md +++ b/projects/psl-verbatim-table-names/design-notes.md @@ -22,3 +22,8 @@ The storage name of a model with no `@@map` is the model name. This was already - Operator: the planner intervenes by throwing with mitigation instructions, never by renaming. - Operator: repo fixtures get `@@map` added rather than being regenerated. - Orchestrator, unchallenged: infer keeps re-casing snake_case tables to PascalCase model names with an explicit `@@map`; the guard lives in the planner, not in `db verify`, because verify already prints both names side by side. + +## Decision taken 2026-09-17: renames are stated in hand-written migrations + +Slice 2 first added a `--rename =` flag to `migration plan` and `migration new`. The documented design for stating renames is a planner hint in the contract source (`@hint(was: ...)`, Data Contract and Migration System subsystem docs, ADR 001), so the flag was a second, undocumented mechanism. The operator removed it. Until hints exist, a rename is stated with `this.renameTable(...)` in a migration created by `migration new`, which also computes the companion constraint and index renames from the migration's contracts. + diff --git a/projects/psl-verbatim-table-names/plan.md b/projects/psl-verbatim-table-names/plan.md index 2c8f9469a7e2..5f966bc38d4d 100644 --- a/projects/psl-verbatim-table-names/plan.md +++ b/projects/psl-verbatim-table-names/plan.md @@ -14,10 +14,10 @@ ### Slice 2 — Rename-table migration operation -- **Outcome:** a PSL model whose table name changes plans as one rename-table operation on Postgres and SQLite instead of a drop and a create, and the guard's second remedy points at that path. How the planner learns a rename is intended is settled in this slice's spec before implementation. +- **Outcome:** a user renames a table without losing its rows by writing `this.renameTable(...)` in a migration created with `prisma migration new`; the method also renames the constraints and indexes named after the table. The guard points at this path. Spec: `slices/rename-table-operation/spec.md`. - **Builds on:** slice 1 (guard and error text). -- **Hands to:** the rename op and the intent mechanism. -- **Branch:** to be cut from `main` after slice 1 merges. +- **Hands to:** the rename operation and the companion-rename computation that a future planner-hint feature can reuse. +- **PR:** https://github.com/prisma/orm/pull/30331 ### Slice 3 — TS DSL cross-space relation table fallback @@ -29,6 +29,8 @@ Slice 2 and slice 3 are independent of each other and run in parallel after slic ## Follow-ups filed outside this project +- Planner hints in the contract source, `@hint(was: ...)`, as documented in the Data Contract and Migration System subsystem docs and ADR 001. Not implemented. With hints, `migration plan` and `db update` could plan a rename without a hand-written migration, reusing slice 2's companion-rename code. Open questions: PSL syntax and the TypeScript equivalent; whether `was` names the old model or the old table; how hints reach the offline planner without entering `contract.json`; whether a leftover hint that matches nothing is ignored or an error. + - Extension packages keep migrations directly under `migrations/` (per `.agents/rules/contract-space-package-layout.mdc` and every shipped extension), but `migration plan` reads and writes history under `migrations/app/`. Inside such a package `migration plan` cannot see the history, so `--from` fails and a stated rename cannot be planned. Found while writing the slice 2 extension upgrade note; it predates this project and affects every `migration plan` run in an extension package. - Whether `contract infer` should keep names verbatim like Prisma 7 instead of re-casing to PascalCase. diff --git a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md index 036fb450ed5c..33720b148e5b 100644 --- a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md +++ b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md @@ -1,58 +1,46 @@ # Slice spec — Rename-table migration operation -**Project:** `projects/psl-verbatim-table-names/` · **Slice 2** · **Branch:** `psl-verbatim-rename-table` (from `main`) +**Project:** `projects/psl-verbatim-table-names/` · **Slice 2** · **Branch:** `psl-verbatim-rename-table` · **PR:** https://github.com/prisma/orm/pull/30331 ## At a glance -Today a model whose table name changes plans as `DropTable` plus `CreateTable`, and the rows are gone. After this slice: +Today a model whose table name changes plans as `DropTable` plus `CreateTable`, and the rows are gone. After this slice a user makes the rename its own schema change, runs `prisma migration new`, and writes one line in the generated migration: -```bash -prisma migration plan --rename userProfile=UserProfile +```ts +this.renameTable({ table: 'userProfile', to: 'UserProfile' }) ``` -plans one `renameTable` operation followed by whatever else the diff needs, `migrate` runs `ALTER TABLE "userProfile" RENAME TO "UserProfile"`, and the rows survive. The `MIGRATION.TABLE_NAME_CASE_CHANGED` guard's second remedy points at this flag instead of a by-hand `ALTER TABLE`. - -`migration new` does not run the planner; it scaffolds an empty migration file for hand-authored operations. With the same flag it scaffolds a file whose body already holds one `this.renameTable({ from, to })` call per intent, so both commands accept the same grammar. Amended after the implementer checked the CLI: the first draft assumed `migration new` planned. +`migrate` then renames the table and every constraint and index named after it, and the rows survive. ## Chosen design -**Intent comes from the CLI, never from inference.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The operator states it: `--rename =`, repeatable, on `migration plan` (which feeds the planner) and on `migration new` (which scaffolds the operation into the hand-authored file). Either side may be schema-qualified, `auth.userProfile=UserProfile`; an unqualified name means the target's default namespace. Nothing is added to the schema; there is no attribute and no prompt. - -**The flag name is family-neutral.** The flag is registered by the framework CLI, which serves every family, so it may not carry SQL vocabulary: the repo's framework-vocabulary check fails CI above its threshold, and its rule allows no suppression for a SQL-only concept. `--rename` renames what a model maps to, a table in SQL or a collection in Mongo. Mongo refuses it with `MIGRATION.RENAME_UNSUPPORTED` until it has a rename operation. SQL vocabulary (`renameTable`, `MIGRATION.TABLE_RENAME_*`) stays in the SQL family and target packages. Amended after review: the first draft named the flag `--rename-table`, which added twelve framework lines over the vocabulary threshold. +**A rename is stated in a hand-written migration, never inferred and never stated on the command line.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The documented long-term design is a planner hint in the contract source, `@hint(was: ...)` (Data Contract and Migration System subsystem docs, ADR 001); it is not implemented and is a follow-up outside this project. Until it exists, the way to state a rename is the one the migration system already offers for anything the planner cannot infer: a hand-written migration. -**Every name derived from the table follows it.** Postgres names unnamed primary keys, unique constraints, foreign keys, indexes and check constraints after the table, and `ALTER TABLE ... RENAME TO` renames none of them. The planner therefore emits a companion rename for each such object owned by the renamed table, so a later plan's derived names match the database. Explicitly named objects, and foreign keys on other tables, keep their names. On SQLite, index names derived from the table are dropped and recreated, since SQLite cannot rename an index. `migration new --rename` scaffolds the same operations with the same code. Amended after review: the first draft renamed only the table, so the next migration touching a constraint failed. +**`this.renameTable` emits every rename the table needs.** The migration facade method reads the migration's start and end contracts and emits the table rename followed by a rename for each object on that table whose name is derived from the table name: unnamed primary keys, unique constraints and foreign keys on Postgres; indexes and check constraints whose derived prefix comes from the table name; default-named indexes on SQLite, dropped and recreated because SQLite cannot rename an index. A constraint takes the end contract's explicit name only when it is otherwise unchanged; otherwise it takes the name derived from the new table name. Explicitly named objects and foreign keys on other tables keep their names. On Postgres the method also carries the table's row-level security settings and policies to the new name where the contract refers to them by table name. If the start contract has no such table, or the end contract has no table under the new name, the method refuses with `MIGRATION.TABLE_RENAME_UNMATCHED`. -**Rename intents are applied to the previous schema before the diff runs.** Rather than pairing issues after the fact, the planner takes the previous state (the prior contract's schema IR, or the introspected live schema) and renames the stated tables in it, then runs the ordinary diff against the next contract, then prepends one `RenameTableCall` per intent to the operations. The differ therefore sees the table under its new name and plans any column, index or constraint changes on it normally, and the case-change guard never sees a drop and create pair for it. Existing index and check rename pairing continues to work on the renamed table, since it pairs by wire-name hash. +**The rename is its own schema change.** `migrate` verifies the database against the migration's end contract, so a hand-written migration that renames a table but omits other edits made in the same change fails loudly at `migrate`. The guide tells users to rename first, then make other edits and plan them. -**An intent that does not match is a planning failure**, reported through the planner's existing failure result as a conflict: `from` must exist in the previous state, `to` must not, and `to` must exist in the next contract. Silent acceptance of a stale flag would hide a mistake. +**The operation.** `renameTable` on Postgres and SQLite: prechecks that the old table exists and the new one does not; postchecks that the new table exists and the old one is gone. Postgres qualifies by schema. SQLite folds only ASCII letters when comparing identifiers, so a rename that only changes ASCII case goes through a temporary name. Operation class `widening`. `RenameCheckConstraintCall` is generalised into `RenameConstraintCall` with a kind. -**The operation.** `renameTable` in the Postgres and SQLite operation sets, following the `renameCheckConstraint` precedent (PR #29894) end to end: op builder with precheck (`from` exists, `to` absent), execute (`ALTER TABLE ... RENAME TO ...`, schema-qualified on Postgres), postcheck; `RenameTableCall` in the op-factory call union with `renderTypeScript` and import requirements so a hand-written migration can call it; the migration facade method; the public op-factory export. Operation class `widening`, idempotency class per ADR 038 (effect-idempotent with equivalence check: pre-state compatible if `from` or `to` already holds the table). Under an `additive`-only policy the op is refused the same way other `widening` ops are, not degraded to drop and create. +**The guard's remedies.** `MIGRATION.TABLE_NAME_CASE_CHANGED` offers three ways out: add `@@map` to keep the old table; in a project with migration history, create a migration with `prisma migration new` and call `this.renameTable(...)` in it; in a project managed with `db update`, run the statements the target supplies by hand, then `db update` again. On SQLite `db update` drops an index before creating one whose name collides only in ASCII case. -**Guard remedy.** `MIGRATION.TABLE_NAME_CASE_CHANGED` offers three ways out: add `@@map` to keep the old table; in a project with migration history, plan with `--rename "="`; in a project managed with `db update`, rename the table by hand with the statements the target supplies, then run `db update` again, which renames or recreates the indexes named after the old table. The statements come from the target because SQLite cannot rename a table in one statement when only the case changes; it goes through a temporary name. On SQLite, `db update` drops an index before creating one whose name differs only in case, since SQLite compares index names without regard to case. The error reference and the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` describe the same three paths, plus the by-hand constraint renames on Postgres and the by-hand collection rename on Mongo, which refuses the flag. Amended after review: the first draft kept the by-hand sentence only for Mongo, and the fragments were not updated until review found them stale. +**No CLI flag.** An earlier version of this slice added `--rename =` to `migration plan` and `migration new`. It was removed because it is a second, undocumented way to state a rename that the documented hint design will replace. Amended 2026-09-17 on the operator's decision. ## Scope -**In:** op builder, call, facade, export, TypeScript rendering for Postgres and SQLite; the pre-diff rename application and the unmatched-intent conflict in the shared SQL family planner code, wired in both target planners; CLI flag parsing for both commands, threading into the planner on `migration plan` and into the scaffold on `migration new`; guard remedy text; error reference; fragment text; tests below. +**In:** the operation and its call for Postgres and SQLite; the facade method computing companion renames from the migration's contracts; the SQLite identifier-collision helper, index drop-before-create ordering, and rebuild postchecks; the guard's three remedies with target-supplied by-hand statements; the error reference; the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` and `upgrade-instructions/pending/rename-constraint-call/`; removal of the flag and everything reachable only through it. -**Out:** `db update` and `db init` (no flag; the guard still protects them); Mongo; any rename inference; renaming columns; a schema attribute. +**Out:** planner hints; rename inference; column renames; MongoDB collection renames (users rename by hand). ## Tests, all red before their change -- Op level, both targets: `renameTable` renders the expected SQL; precheck fails when `from` is missing or `to` exists; `renderTypeScript` round-trips through the migration file parser. -- Planner, both targets: with one intent and otherwise identical tables, the plan is exactly one rename op; with an intent and an added column, the plan is the rename op followed by the add-column op on the new name; an intent whose `from` is absent, or whose `to` already exists, yields a failure conflict naming it; the case-change guard does not fire when the pair is covered by an intent and still fires when it is not; an `additive`-only policy refuses the rename as it refuses other widening ops. -- CLI: `--rename` parses repeated and schema-qualified values, rejects a malformed value with a clear error, reaches the planner options on `migration plan`, and on `migration new` produces a migration file that round-trips through the loader and whose operation renders the expected SQL. -- End to end, Postgres (PGlite) and SQLite, under `test/integration/test/cli-journeys/`: create a table with rows under the old name, change the model's table name, run `migration plan --rename` then `migrate`, and prove the rows are present under the new name and `db verify --schema-only` is clean. -- Guard text: the existing guard tests pin the new remedy string. +- Operation, both targets: rendered SQL, prechecks, postchecks, TypeScript rendering round-trip. +- Facade, both targets: `this.renameTable` emits the table rename plus the companion renames for a table with an unnamed primary key, a unique constraint, a foreign key, an index and a check; a constraint that changed keeps the derived name; an explicitly named object is left alone; an unknown table refuses. +- Guard, both targets: the three remedies and the target's by-hand statements. +- Journeys under `test/integration/test/cli-journeys/`: Postgres and SQLite, `migration new` plus a hand-written `this.renameTable` on a table with rows, a unique constraint, a foreign key and an index (and row-level security and a policy on Postgres); after `migrate` the rows and objects are present, a plan with no schema change is empty, `db verify --schema-only` is clean, and a follow-up migration removing those objects applies. The SQLite `db update` by-hand path journey stays. ## Done conditions -- Every test above is green; `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm lint`, `pnpm lint:deps`, `pnpm lint:casts` (delta 0), `pnpm fixtures:check`, `pnpm check:error-reference` pass on the final tree. -- `pnpm check:upgrade-coverage --mode pr --prev origin/main --head HEAD` passes; if it demands a fragment for the app audience (the flag is additive, so probably not), write a truthful one. -- The project DoD line "a model rename in PSL plans as a single rename-table operation on Postgres and SQLite, and the rows survive" is met by the end-to-end test. - -## Dispatch plan - -1. Red tests: op-level, planner, CLI parsing, and the two journey tests, committed alone with their red logs. -2. The operation and its wiring in both targets, plus TypeScript rendering; op-level tests green. -3. Pre-diff rename application, unmatched-intent conflict, guard interaction, CLI flag threading; planner and CLI tests green. -4. Journeys green, guard remedy and docs and fragments updated, full gate, push (`git push -u bot psl-verbatim-rename-table`). The orchestrator opens the PR. +- Every test above is green; all repository checks pass one at a time, including `lint:framework-vocabulary` at or below main's count and the checks only CI runs. +- No `--rename` flag, `CLI.INVALID_RENAME_FLAG`, or rename-intent code reachable only through the flag remains. +- The project DoD line "a model rename in PSL keeps its rows on Postgres and SQLite" is met by the journeys. From ee45f19fc3496d71a74c7a67a251ae2280dd2dae Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:06:53 +0200 Subject: [PATCH 47/55] feat(migration): this.renameTable emits every rename the table needs A rename is now stated in a hand-written migration. On Postgres and SQLite, this.renameTable reads the migration's start and end contracts and returns the table rename followed by a rename of each object whose name was derived from the old table name. The author spreads the result into operations: ...this.renameTable({ table: 'userProfile', to: 'UserProfile' }). Returning an array of operation promises fits how operations are collected today, a list of operations or promises resolved with Promise.all, so the migration base classes do not change. Postgres renames unnamed primary keys, unique constraints and foreign keys, and wire-named indexes and checks named after the table. A constraint takes the end contract's explicit name only when the diff finds it unchanged; otherwise it takes the name derived from the new table name. Row-level security markers and policies follow the table in the renamed contract, and ALTER TABLE carries them in the database. SQLite drops and recreates each wire-named index named after the table. Explicitly named objects and foreign keys on other tables keep their names. The companions come from the code the planner used for the --rename flag, moved out of the planners so the facade can call it without importing them (the Postgres planner module imports the migration base through its produced migration): applyTableRename in the SQL family, table-rename-constraint-renames, the index and check pairing passes, and pairIndexReplacements. A table missing from the start contract, a new name missing from the end contract, or a migration without a start contract is refused with MIGRATION.TABLE_RENAME_UNMATCHED. Like the other facade methods, renameTable emits what the author wrote and does not consult control policy. The planners no longer apply rename intents, and the planner rename tests become facade tests. RenameTableCall renders as ...this.renameTable({...}). Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/2-sql/9-family/src/core/errors.ts | 1 - .../src/core/migrations/apply-table-rename.ts | 268 +++++++++++ .../core/migrations/table-rename-intents.ts | 377 --------------- .../2-sql/9-family/src/exports/control.ts | 23 +- .../9-family/test/apply-table-rename.test.ts | 327 +++++++++++++ .../test/table-rename-intents.test.ts | 430 ------------------ .../migrations/index-and-check-renames.ts | 233 ++++++++++ .../src/core/migrations/op-factory-call.ts | 2 +- .../postgres/src/core/migrations/planner.ts | 413 +---------------- .../src/core/migrations/postgres-migration.ts | 30 +- .../src/core/migrations/table-rename-calls.ts | 92 ++++ .../migrations/rename-table-facade.test.ts | 351 ++++++++++++++ .../test/migrations/rename-table-fixtures.ts | 4 +- .../test/migrations/rename-table-ops.test.ts | 6 +- .../rename-table-planner.rls.test.ts | 97 ---- ...rename-table-planner.table-objects.test.ts | 257 ----------- .../migrations/rename-table-planner.test.ts | 287 ------------ .../migrations/rename-table-scaffold.test.ts | 208 --------- .../src/core/migrations/op-factory-call.ts | 2 +- .../sqlite/src/core/migrations/planner.ts | 158 +------ .../src/core/migrations/sqlite-migration.ts | 31 +- .../src/core/migrations/table-rename-calls.ts | 39 ++ .../migrations/rename-table-facade.test.ts | 157 +++++++ .../test/migrations/rename-table-ops.test.ts | 4 +- ...rename-table-planner.table-objects.test.ts | 58 --- .../migrations/rename-table-planner.test.ts | 258 ----------- .../migrations/rename-table-scaffold.test.ts | 144 ------ skills/prisma-8/references/migrations.md | 2 +- 28 files changed, 1561 insertions(+), 2698 deletions(-) create mode 100644 packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts delete mode 100644 packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts create mode 100644 packages/2-sql/9-family/test/apply-table-rename.test.ts delete mode 100644 packages/2-sql/9-family/test/table-rename-intents.test.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts delete mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts delete mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts delete mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts delete mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts delete mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts delete mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts delete mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts diff --git a/packages/2-sql/9-family/src/core/errors.ts b/packages/2-sql/9-family/src/core/errors.ts index b1822190dfe8..ba3a3e1a3460 100644 --- a/packages/2-sql/9-family/src/core/errors.ts +++ b/packages/2-sql/9-family/src/core/errors.ts @@ -9,7 +9,6 @@ type SqlFamilyErrorCode = | 'CONTRACT.TABLE_AMBIGUOUS' | 'CONTRACT.TYPE_UNKNOWN' | 'MIGRATION.MARKER_CAS_FAILURE' - | 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT' | 'MIGRATION.TABLE_RENAME_UNMATCHED'; export function sqlFamilyError( diff --git a/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts new file mode 100644 index 000000000000..4f6b0a4ce4b1 --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts @@ -0,0 +1,268 @@ +import type { Contract } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + ForeignKey, + type ForeignKeyReference, + type ForeignKeyReferenceInput, + isMaterializedSqlNamespace, + type SqlNamespace, + type SqlNamespaceBase, + type SqlNamespaceEntries, + SqlStorage, + StorageTable, +} from '@internal/sql-contract/types'; +import { InternalError } from '@internal/utils/internal-error'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import type { StructuredError } from '@internal/utils/structured-error'; +import { sqlFamilyError } from '../errors'; + +export const TABLE_RENAME_UNMATCHED_CODE = 'MIGRATION.TABLE_RENAME_UNMATCHED'; + +/** A table a migration renames: `namespaceId` is `undefined` when the migration leaves the namespace to the contracts. */ +export interface TableRename { + readonly namespaceId: string | undefined; + readonly from: string; + readonly to: string; +} + +/** A rename after its table was found in the start contract. */ +export interface ResolvedTableRename { + readonly namespaceId: string; + readonly from: string; + readonly to: string; +} + +/** + * Renames a target's own references to a renamed table inside one namespace's entries: entity kinds the family does not know, such as a target's row-level-security markers, that name a table. + */ +export type RenameTableReferences = ( + entries: SqlNamespaceEntries, + rename: ResolvedTableRename, +) => SqlNamespaceEntries; + +export interface ApplyTableRenameInput { + readonly startContract: Contract | null; + readonly endContract: Contract; + readonly rename: TableRename; + readonly renameTableReferences: RenameTableReferences | undefined; +} + +export interface AppliedTableRename { + /** The start contract with the table under its new name. */ + readonly contract: Contract; + readonly rename: ResolvedTableRename; +} + +function tableLabel(namespaceId: string | undefined, tableName: string): string { + return namespaceId === undefined || namespaceId === UNBOUND_NAMESPACE_ID + ? tableName + : `${namespaceId}.${tableName}`; +} + +function namespacesDeclaring(contract: Contract, tableName: string): string[] { + return Object.values(contract.storage.namespaces) + .filter((namespace) => Object.hasOwn(namespace.entries.table ?? {}, tableName)) + .map((namespace) => namespace.id); +} + +function declares(contract: Contract, namespaceId: string, tableName: string): boolean { + return Object.hasOwn(contract.storage.namespaces[namespaceId]?.entries.table ?? {}, tableName); +} + +function unmatched(rename: TableRename, reason: string): StructuredError { + return sqlFamilyError( + TABLE_RENAME_UNMATCHED_CODE, + `renameTable "${tableLabel(rename.namespaceId, rename.from)}" to "${rename.to}" does not match the migration's contracts: ${reason}.`, + { + why: "renameTable must name a table of the migration's start contract and a new name that the end contract has and the start contract does not. Make the rename its own schema change, so the migration's start contract is the schema before the rename and its end contract the schema after it, and check the spelling and the namespace.", + meta: { from: rename.from, to: rename.to }, + }, + ); +} + +function resolveTableRename( + rename: TableRename, + startContract: Contract, + endContract: Contract, +): Result { + const namespaceIds = + rename.namespaceId === undefined + ? namespacesDeclaring(startContract, rename.from) + : declares(startContract, rename.namespaceId, rename.from) + ? [rename.namespaceId] + : []; + const [namespaceId, ...others] = namespaceIds; + if (namespaceId === undefined) { + return notOk( + unmatched( + rename, + `table "${tableLabel(rename.namespaceId, rename.from)}" does not exist in the start contract`, + ), + ); + } + if (others.length > 0) { + return notOk( + unmatched( + rename, + `table "${rename.from}" is declared in more than one namespace (${namespaceIds.join(', ')}); name its namespace`, + ), + ); + } + if (declares(startContract, namespaceId, rename.to)) { + return notOk( + unmatched( + rename, + `table "${tableLabel(namespaceId, rename.to)}" already exists in the start contract`, + ), + ); + } + if (!declares(endContract, namespaceId, rename.to)) { + return notOk( + unmatched( + rename, + `table "${tableLabel(namespaceId, rename.to)}" does not exist in the end contract`, + ), + ); + } + return ok({ namespaceId, from: rename.from, to: rename.to }); +} + +function renamedReference( + reference: ForeignKeyReference, + rename: ResolvedTableRename, +): ForeignKeyReference | ForeignKeyReferenceInput { + const local = reference.spaceId === undefined; + if ( + !local || + reference.namespaceId !== rename.namespaceId || + reference.tableName !== rename.from + ) { + return reference; + } + return { ...reference, tableName: rename.to }; +} + +function renameForeignKeys(table: StorageTable, rename: ResolvedTableRename): StorageTable { + const touched = table.foreignKeys.some( + (fk) => + renamedReference(fk.source, rename) !== fk.source || + renamedReference(fk.target, rename) !== fk.target, + ); + if (!touched) return table; + return new StorageTable({ + ...table, + foreignKeys: table.foreignKeys.map( + (fk) => + new ForeignKey({ + ...fk, + source: renamedReference(fk.source, rename), + target: renamedReference(fk.target, rename), + }), + ), + }); +} + +/** + * The same namespace instance with only `entries` replaced: every own + * property, including the non-enumerable `kind`, is carried over onto the + * same prototype, so the target's namespace class (its `qualifyTable`, + * `ddlSchemaName`, entity getters) keeps working on the copy. This is the + * one place a frozen contract node is rebuilt without its constructor: the + * constructors re-hydrate `entries` from raw input, which the already + * hydrated entities here do not need. + */ +function withEntries(namespace: SqlNamespaceBase, entries: SqlNamespaceEntries): SqlNamespaceBase { + const copy: SqlNamespaceBase = Object.create(Object.getPrototypeOf(namespace), { + ...Object.getOwnPropertyDescriptors(namespace), + entries: { + value: Object.freeze(entries), + enumerable: true, + writable: false, + configurable: false, + }, + }); + return Object.freeze(copy); +} + +function renameTablesInNamespace( + namespace: SqlNamespace, + renames: readonly ResolvedTableRename[], + renameTableReferences: RenameTableReferences | undefined, +): SqlNamespace { + const own = renames.filter((rename) => rename.namespaceId === namespace.id); + const tables = Object.entries(namespace.entries.table ?? {}).map(([name, table]) => { + const renamedTo = own.find((rename) => rename.from === name)?.to; + const withForeignKeys = renames.reduce(renameForeignKeys, table); + return [renamedTo ?? name, withForeignKeys] as const; + }); + const untouched = tables.every(([name, table]) => namespace.entries.table?.[name] === table); + if (untouched) return namespace; + if (!isMaterializedSqlNamespace(namespace)) { + throw new InternalError( + `applyTableRenameIntents: namespace "${namespace.id}" is not a materialized SQL namespace`, + ); + } + const renamedTables: SqlNamespaceEntries = { + ...namespace.entries, + table: Object.fromEntries(tables), + }; + return withEntries( + namespace, + renameTableReferences === undefined + ? renamedTables + : own.reduce(renameTableReferences, renamedTables), + ); +} + +function renameTablesInContract( + contract: Contract, + renames: readonly ResolvedTableRename[], + renameTableReferences: RenameTableReferences | undefined, +): Contract { + const namespaces = Object.fromEntries( + Object.entries(contract.storage.namespaces).map(([id, namespace]) => [ + id, + renameTablesInNamespace(namespace, renames, renameTableReferences), + ]), + ); + const materialized: Record = {}; + for (const [id, namespace] of Object.entries(namespaces)) { + if (!isMaterializedSqlNamespace(namespace)) { + throw new InternalError( + `applyTableRenameIntents: namespace "${id}" is not a materialized SQL namespace`, + ); + } + materialized[id] = namespace; + } + return { + ...contract, + storage: new SqlStorage({ + storageHash: contract.storage.storageHash, + ...(contract.storage.types === undefined ? {} : { types: contract.storage.types }), + namespaces: materialized, + }), + }; +} + +/** + * Resolves the table a migration renames against its start and end contracts and returns the start contract with that table under its new name, so a diff against the end contract sees the table under one name. The table must exist in the start contract (in exactly one namespace when the namespace is not given), and the new name must exist in the end contract and not in the start contract; otherwise the rename is refused with `MIGRATION.TABLE_RENAME_UNMATCHED`. + * + * Foreign keys that name the renamed table on either side are retargeted. Index, unique, check and primary-key names are carried unchanged. Target entity kinds that name the table are renamed by the target's `renameTableReferences`. + */ +export function applyTableRename( + input: ApplyTableRenameInput, +): Result { + if (input.startContract === null) { + return notOk(unmatched(input.rename, 'the migration has no start contract')); + } + const resolved = resolveTableRename(input.rename, input.startContract, input.endContract); + if (!resolved.ok) return resolved; + return ok({ + contract: renameTablesInContract( + input.startContract, + [resolved.value], + input.renameTableReferences, + ), + rename: resolved.value, + }); +} diff --git a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts b/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts deleted file mode 100644 index b827686e2451..000000000000 --- a/packages/2-sql/9-family/src/core/migrations/table-rename-intents.ts +++ /dev/null @@ -1,377 +0,0 @@ -import type { Contract } from '@internal/contract/types'; -import type { StorageEntityRename } from '@internal/framework-components/control'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { - ForeignKey, - type ForeignKeyReference, - type ForeignKeyReferenceInput, - isMaterializedSqlNamespace, - type SqlNamespace, - type SqlNamespaceBase, - type SqlNamespaceEntries, - SqlStorage, - StorageTable, -} from '@internal/sql-contract/types'; -import { ifDefined } from '@internal/utils/defined'; -import { InternalError } from '@internal/utils/internal-error'; -import { notOk, ok, type Result } from '@internal/utils/result'; -import type { StructuredError } from '@internal/utils/structured-error'; -import { sqlFamilyError } from '../errors'; -import type { SqlPlannerConflict } from './types'; - -export const TABLE_RENAME_UNMATCHED_CODE = 'MIGRATION.TABLE_RENAME_UNMATCHED'; -export const TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE = 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT'; - -/** - * The error `migration new` raises for rename intents a planner refused. It carries the code the refusals carry as conflicts of `migration plan`. - */ -export function tableRenameScaffoldError( - conflicts: readonly SqlPlannerConflict[], -): StructuredError { - const noPreviousContract = conflicts.every( - (conflict) => conflict.meta?.['code'] === TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE, - ); - const code = noPreviousContract - ? TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE - : TABLE_RENAME_UNMATCHED_CODE; - return sqlFamilyError( - code, - conflicts.map(summaryWithoutCode).join('\n'), - ifDefined('why', conflicts[0]?.why), - ); -} - -function summaryWithoutCode(conflict: SqlPlannerConflict): string { - const prefix = `${String(conflict.meta?.['code'])}: `; - return conflict.summary.startsWith(prefix) - ? conflict.summary.slice(prefix.length) - : conflict.summary; -} - -/** A `--rename =` intent as the CLI parsed it. */ -export type TableRenameIntent = StorageEntityRename; - -/** An intent after both sides were matched against the contracts. */ -export interface ResolvedTableRename { - readonly namespaceId: string; - readonly from: string; - readonly to: string; -} - -/** - * Renames a target's own references to a renamed table inside one namespace's entries: entity kinds the family does not know, such as a target's row-level-security markers, that name a table. - */ -export type RenameTableReferences = ( - entries: SqlNamespaceEntries, - rename: ResolvedTableRename, -) => SqlNamespaceEntries; - -export interface ApplyTableRenameIntentsInput { - readonly fromContract: Contract | null; - readonly toContract: Contract; - readonly intents: readonly TableRenameIntent[]; - readonly renameTableReferences: RenameTableReferences | undefined; -} - -export interface AppliedTableRenames { - /** The previous contract with every intent's table under its new name. */ - readonly contract: Contract; - readonly renames: readonly ResolvedTableRename[]; -} - -type TableLookup = - | { readonly kind: 'found'; readonly namespaceId: string } - | { readonly kind: 'missing' } - | { readonly kind: 'ambiguous'; readonly namespaceIds: readonly string[] }; - -function coordinateLabel(coordinate: StorageEntityRename['from']): string { - return coordinate.namespaceId === undefined - ? coordinate.name - : `${coordinate.namespaceId}.${coordinate.name}`; -} - -function tableRenameIntentLabel(intent: TableRenameIntent): string { - return `${coordinateLabel(intent.from)}=${coordinateLabel(intent.to)}`; -} - -function namespacesDeclaring(contract: Contract, tableName: string): string[] { - return Object.values(contract.storage.namespaces) - .filter((namespace) => Object.hasOwn(namespace.entries.table ?? {}, tableName)) - .map((namespace) => namespace.id); -} - -function lookupTable( - contract: Contract, - coordinate: StorageEntityRename['from'], -): TableLookup { - if (coordinate.namespaceId !== undefined) { - const namespace = contract.storage.namespaces[coordinate.namespaceId]; - return namespace !== undefined && Object.hasOwn(namespace.entries.table ?? {}, coordinate.name) - ? { kind: 'found', namespaceId: coordinate.namespaceId } - : { kind: 'missing' }; - } - const namespaceIds = namespacesDeclaring(contract, coordinate.name); - const [only] = namespaceIds; - if (only !== undefined && namespaceIds.length === 1) return { kind: 'found', namespaceId: only }; - return namespaceIds.length === 0 ? { kind: 'missing' } : { kind: 'ambiguous', namespaceIds }; -} - -function qualifiedTableKey(namespaceId: string, tableName: string): string { - return JSON.stringify([namespaceId, tableName]); -} - -function resolvedTableLabel(namespaceId: string, tableName: string): string { - return namespaceId === UNBOUND_NAMESPACE_ID ? tableName : `${namespaceId}.${tableName}`; -} - -function unmatched(intent: TableRenameIntent, reason: string): SqlPlannerConflict { - return { - kind: 'tableRenameUnmatched', - summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename "${tableRenameIntentLabel(intent)}" does not match the contracts: ${reason}.`, - why: 'A rename intent must name a table of the previous contract as its old name and a table of the next contract as its new name, and the new name must not already exist in the previous contract. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed.', - meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: intent.from.name, to: intent.to.name }, - }; -} - -function ambiguousReason( - coordinate: StorageEntityRename['from'], - namespaceIds: readonly string[], -): string { - return `table "${coordinate.name}" is declared in more than one namespace (${namespaceIds.join(', ')}); qualify it as .${coordinate.name}`; -} - -function resolveIntent( - intent: TableRenameIntent, - fromContract: Contract, - toContract: Contract, -): Result { - const from = lookupTable(fromContract, intent.from); - if (from.kind === 'ambiguous') { - return notOk(unmatched(intent, ambiguousReason(intent.from, from.namespaceIds))); - } - if (from.kind === 'missing') { - return notOk( - unmatched( - intent, - `table "${coordinateLabel(intent.from)}" does not exist in the previous contract`, - ), - ); - } - const namespaceId = from.namespaceId; - if (intent.to.namespaceId !== undefined && intent.to.namespaceId !== namespaceId) { - return notOk( - unmatched( - intent, - `a rename cannot move table "${intent.from.name}" from namespace "${namespaceId}" to namespace "${intent.to.namespaceId}"`, - ), - ); - } - const previousNamespace = fromContract.storage.namespaces[namespaceId]; - if (Object.hasOwn(previousNamespace?.entries.table ?? {}, intent.to.name)) { - return notOk( - unmatched(intent, `table "${intent.to.name}" already exists in the previous contract`), - ); - } - const nextNamespace = toContract.storage.namespaces[namespaceId]; - if (!Object.hasOwn(nextNamespace?.entries.table ?? {}, intent.to.name)) { - return notOk( - unmatched(intent, `table "${intent.to.name}" does not exist in the next contract`), - ); - } - return ok({ namespaceId, from: intent.from.name, to: intent.to.name }); -} - -function renamedReference( - reference: ForeignKeyReference, - rename: ResolvedTableRename, -): ForeignKeyReference | ForeignKeyReferenceInput { - const local = reference.spaceId === undefined; - if ( - !local || - reference.namespaceId !== rename.namespaceId || - reference.tableName !== rename.from - ) { - return reference; - } - return { ...reference, tableName: rename.to }; -} - -function renameForeignKeys(table: StorageTable, rename: ResolvedTableRename): StorageTable { - const touched = table.foreignKeys.some( - (fk) => - renamedReference(fk.source, rename) !== fk.source || - renamedReference(fk.target, rename) !== fk.target, - ); - if (!touched) return table; - return new StorageTable({ - ...table, - foreignKeys: table.foreignKeys.map( - (fk) => - new ForeignKey({ - ...fk, - source: renamedReference(fk.source, rename), - target: renamedReference(fk.target, rename), - }), - ), - }); -} - -/** - * The same namespace instance with only `entries` replaced: every own - * property, including the non-enumerable `kind`, is carried over onto the - * same prototype, so the target's namespace class (its `qualifyTable`, - * `ddlSchemaName`, entity getters) keeps working on the copy. This is the - * one place a frozen contract node is rebuilt without its constructor: the - * constructors re-hydrate `entries` from raw input, which the already - * hydrated entities here do not need. - */ -function withEntries(namespace: SqlNamespaceBase, entries: SqlNamespaceEntries): SqlNamespaceBase { - const copy: SqlNamespaceBase = Object.create(Object.getPrototypeOf(namespace), { - ...Object.getOwnPropertyDescriptors(namespace), - entries: { - value: Object.freeze(entries), - enumerable: true, - writable: false, - configurable: false, - }, - }); - return Object.freeze(copy); -} - -function renameTablesInNamespace( - namespace: SqlNamespace, - renames: readonly ResolvedTableRename[], - renameTableReferences: RenameTableReferences | undefined, -): SqlNamespace { - const own = renames.filter((rename) => rename.namespaceId === namespace.id); - const tables = Object.entries(namespace.entries.table ?? {}).map(([name, table]) => { - const renamedTo = own.find((rename) => rename.from === name)?.to; - const withForeignKeys = renames.reduce(renameForeignKeys, table); - return [renamedTo ?? name, withForeignKeys] as const; - }); - const untouched = tables.every(([name, table]) => namespace.entries.table?.[name] === table); - if (untouched) return namespace; - if (!isMaterializedSqlNamespace(namespace)) { - throw new InternalError( - `applyTableRenameIntents: namespace "${namespace.id}" is not a materialized SQL namespace`, - ); - } - const renamedTables: SqlNamespaceEntries = { - ...namespace.entries, - table: Object.fromEntries(tables), - }; - return withEntries( - namespace, - renameTableReferences === undefined - ? renamedTables - : own.reduce(renameTableReferences, renamedTables), - ); -} - -function renameTablesInContract( - contract: Contract, - renames: readonly ResolvedTableRename[], - renameTableReferences: RenameTableReferences | undefined, -): Contract { - const namespaces = Object.fromEntries( - Object.entries(contract.storage.namespaces).map(([id, namespace]) => [ - id, - renameTablesInNamespace(namespace, renames, renameTableReferences), - ]), - ); - const materialized: Record = {}; - for (const [id, namespace] of Object.entries(namespaces)) { - if (!isMaterializedSqlNamespace(namespace)) { - throw new InternalError( - `applyTableRenameIntents: namespace "${id}" is not a materialized SQL namespace`, - ); - } - materialized[id] = namespace; - } - return { - ...contract, - storage: new SqlStorage({ - storageHash: contract.storage.storageHash, - ...(contract.storage.types === undefined ? {} : { types: contract.storage.types }), - namespaces: materialized, - }), - }; -} - -/** - * Applies operator-stated rename intents to the previous contract so the - * ordinary diff sees each renamed table under its new name. Every intent is - * matched first: its old name must exist in the previous contract (in - * exactly one namespace when unqualified), its new name must not, and its - * new name must exist in the next contract. Two intents may not resolve to - * the same old table or the same new table. One conflict per unmatched - * intent; nothing is applied unless every intent matches. - * - * Foreign keys that name a renamed table on either side are retargeted so - * the FK still pairs with the next contract's. Index, unique, check and - * primary-key names are carried unchanged: the differ pairs those by hash or - * by columns, and their renames are planned by the existing rename passes. - * Target entity kinds that name a table are renamed by the target's - * `renameTableReferences`, once per rename in their namespace. - */ -export function applyTableRenameIntents( - input: ApplyTableRenameIntentsInput, -): Result { - if (input.intents.length === 0) { - if (input.fromContract === null) { - throw new InternalError('applyTableRenameIntents: nothing to apply and no previous contract'); - } - return ok({ contract: input.fromContract, renames: [] }); - } - if (input.fromContract === null) { - return notOk( - input.intents.map((intent) => ({ - kind: 'unsupportedOperation', - summary: `${TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE}: --rename "${tableRenameIntentLabel(intent)}" needs a previous contract to apply the rename to, and this plan starts from an empty database.`, - why: 'A rename intent only makes sense between two contracts. Plan from the migration that created the table. A database managed with db update has no migration history: rename the table there by hand with ALTER TABLE ... RENAME TO ..., and drop the flag.', - meta: { code: TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE }, - })), - ); - } - const fromContract = input.fromContract; - const conflicts: SqlPlannerConflict[] = []; - const renames: ResolvedTableRename[] = []; - const oldNamesTaken = new Map(); - const newNamesTaken = new Map(); - for (const intent of input.intents) { - const resolved = resolveIntent(intent, fromContract, input.toContract); - if (!resolved.ok) { - conflicts.push(resolved.failure); - continue; - } - const { namespaceId, from, to } = resolved.value; - const oldName = qualifiedTableKey(namespaceId, from); - const newName = qualifiedTableKey(namespaceId, to); - const earlierOld = oldNamesTaken.get(oldName); - const earlierNew = newNamesTaken.get(newName); - if (earlierOld !== undefined) { - conflicts.push( - unmatched( - intent, - `table "${resolvedTableLabel(namespaceId, from)}" is already renamed by --rename "${tableRenameIntentLabel(earlierOld)}"`, - ), - ); - } else if (earlierNew !== undefined) { - conflicts.push( - unmatched( - intent, - `table "${resolvedTableLabel(namespaceId, to)}" is already the new name in --rename "${tableRenameIntentLabel(earlierNew)}"`, - ), - ); - } else { - oldNamesTaken.set(oldName, intent); - newNamesTaken.set(newName, intent); - renames.push(resolved.value); - } - } - if (conflicts.length > 0) return notOk(conflicts); - return ok({ - contract: renameTablesInContract(fromContract, renames, input.renameTableReferences), - renames, - }); -} diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 0841e3babd13..063679ad7921 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -19,6 +19,17 @@ export type { SqlControlTargetDescriptor, SqlDescribedContractSpace, } from '../core/control-target-descriptor'; +export type { + AppliedTableRename, + ApplyTableRenameInput, + RenameTableReferences, + ResolvedTableRename, + TableRename, +} from '../core/migrations/apply-table-rename'; +export { + applyTableRename, + TABLE_RENAME_UNMATCHED_CODE, +} from '../core/migrations/apply-table-rename'; export type { ContractToSchemaIROptions, DefaultRenderer, @@ -57,22 +68,12 @@ export type { export type { TableNameCaseGuardTable, TableRenameByHand, + TableRenameInMigration, } from '../core/migrations/table-name-case-guard'; export { detectTableNameCaseChanges, TABLE_NAME_CASE_CHANGED_CODE, } from '../core/migrations/table-name-case-guard'; -export type { - AppliedTableRenames, - ApplyTableRenameIntentsInput, - ResolvedTableRename, - TableRenameIntent, -} from '../core/migrations/table-rename-intents'; -export { - applyTableRenameIntents, - TABLE_RENAME_UNMATCHED_CODE, - tableRenameScaffoldError, -} from '../core/migrations/table-rename-intents'; export type { CodecControlHooks, CreateSqlMigrationPlanOptions, diff --git a/packages/2-sql/9-family/test/apply-table-rename.test.ts b/packages/2-sql/9-family/test/apply-table-rename.test.ts new file mode 100644 index 000000000000..fb1d94642c35 --- /dev/null +++ b/packages/2-sql/9-family/test/apply-table-rename.test.ts @@ -0,0 +1,327 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + isMaterializedSqlNamespace, + SqlStorage, + StorageTable, + type StorageTableInput, + toStorageTypeInstance, +} from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; +import { + applyTableRename, + TABLE_RENAME_UNMATCHED_CODE, + type TableRename, +} from '../src/core/migrations/apply-table-rename'; + +const idColumn = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; + +function table(extra: Partial = {}): StorageTable { + return new StorageTable({ + columns: { id: idColumn }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + ...extra, + }); +} + +function contractOf( + namespaces: Readonly>>>, + hashSeed = 'seed', +): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + types: { Money: toStorageTypeInstance({ codecId: 'pg/numeric@1', nativeType: 'numeric' }) }, + namespaces: Object.fromEntries( + Object.entries(namespaces).map(([id, tables]) => [ + id, + createTestSqlNamespace({ id, entries: { table: tables } }), + ]), + ), + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +const rename = (from: string, to: string, namespaceId?: string): TableRename => ({ + namespaceId, + from, + to, +}); + +function tablesOf(contract: Contract, namespaceId: string): readonly string[] { + return Object.keys(contract.storage.namespaces[namespaceId]?.entries.table ?? {}); +} + +describe('applyTableRename', () => { + it('re-keys the table under its new name and retargets every foreign key that named it', () => { + const startContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { + userProfile: table({ + columns: { id: idColumn, parentId: { ...idColumn, nullable: true } }, + foreignKeys: [ + { + source: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['parentId'], + }, + target: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['id'], + }, + name: 'userProfile_parent_fkey', + }, + ], + }), + post: table({ + columns: { id: idColumn, authorId: idColumn }, + foreignKeys: [ + { + source: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'post', + columns: ['authorId'], + }, + target: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['id'], + }, + }, + ], + }), + }, + }); + const endContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), post: table() }, + }); + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rename).toEqual({ + namespaceId: UNBOUND_NAMESPACE_ID, + from: 'userProfile', + to: 'UserProfile', + }); + const renamed = result.value.contract; + expect(tablesOf(renamed, UNBOUND_NAMESPACE_ID)).toEqual(['UserProfile', 'post']); + const renamedTable = + renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['UserProfile']; + expect(renamedTable).toBeInstanceOf(StorageTable); + expect(Object.keys(renamedTable?.columns ?? {})).toEqual(['id', 'parentId']); + expect( + renamedTable?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName, fk.name]), + ).toEqual([['UserProfile', 'UserProfile', 'userProfile_parent_fkey']]); + const post = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['post']; + expect(post?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName])).toEqual([ + ['post', 'UserProfile'], + ]); + }); + + it('keeps the namespace class, the storage hash and types, and leaves the input untouched', () => { + const startContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { userProfile: table() }, + audit: { log: table() }, + }); + const endContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table() }, + audit: { log: table() }, + }); + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const renamed = result.value.contract; + const namespace = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]; + expect(isMaterializedSqlNamespace(namespace)).toBe(true); + expect(Object.getPrototypeOf(namespace)).toBe( + Object.getPrototypeOf(startContract.storage.namespaces[UNBOUND_NAMESPACE_ID]), + ); + expect(namespace?.id).toBe(UNBOUND_NAMESPACE_ID); + expect(renamed.storage.namespaces['audit']).toBe(startContract.storage.namespaces['audit']); + expect(renamed.storage.storageHash).toBe(startContract.storage.storageHash); + expect(renamed.storage.types).toEqual(startContract.storage.types); + expect(renamed.profileHash).toBe(startContract.profileHash); + expect(tablesOf(startContract, UNBOUND_NAMESPACE_ID)).toEqual(['userProfile']); + }); + + it('finds the table in whichever namespace declares it when no namespace is given', () => { + const startContract = contractOf({ auth: { userProfile: table() }, app: { post: table() } }); + const endContract = contractOf({ auth: { UserProfile: table() }, app: { post: table() } }); + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rename).toEqual({ + namespaceId: 'auth', + from: 'userProfile', + to: 'UserProfile', + }); + expect(tablesOf(result.value.contract, 'auth')).toEqual(['UserProfile']); + }); + + it('renames only in the namespace given', () => { + const startContract = contractOf({ + auth: { userProfile: table() }, + app: { userProfile: table() }, + }); + const endContract = contractOf({ + auth: { UserProfile: table() }, + app: { userProfile: table() }, + }); + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile', 'auth'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rename).toEqual({ + namespaceId: 'auth', + from: 'userProfile', + to: 'UserProfile', + }); + expect(tablesOf(result.value.contract, 'app')).toEqual(['userProfile']); + }); + + it("lets the target rename its own references to the table in the table's namespace", () => { + const startContract = contractOf({ + auth: { userProfile: table(), account: table() }, + app: { userProfile: table() }, + }); + const endContract = contractOf({ + auth: { UserProfile: table(), account: table() }, + app: { userProfile: table() }, + }); + const calls: string[] = []; + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile', 'auth'), + renameTableReferences: (entries, applied) => { + calls.push(`${applied.namespaceId}:${applied.from}>${applied.to}`); + return { ...entries, marker: { [applied.to]: { tableName: applied.to } } }; + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(calls).toEqual(['auth:userProfile>UserProfile']); + expect(result.value.contract.storage.namespaces['auth']?.entries['marker']).toEqual({ + UserProfile: { tableName: 'UserProfile' }, + }); + expect(result.value.contract.storage.namespaces['app']?.entries['marker']).toBeUndefined(); + }); + + describe('refusals', () => { + const startContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { userProfile: table(), Account: table() }, + }); + const endContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), Account: table() }, + }); + + function refusalFor( + tableRename: TableRename, + start: Contract | null = startContract, + ) { + const result = applyTableRename({ + startContract: start, + endContract, + rename: tableRename, + renameTableReferences: undefined, + }); + expect(result.ok).toBe(false); + return result.ok ? undefined : result.failure; + } + + it('refuses a table the start contract does not have', () => { + expect(refusalFor(rename('ghost', 'UserProfile'))).toMatchObject({ + code: TABLE_RENAME_UNMATCHED_CODE, + message: + 'renameTable "ghost" to "UserProfile" does not match the migration\'s contracts: table "ghost" does not exist in the start contract.', + meta: { from: 'ghost', to: 'UserProfile' }, + }); + }); + + it('refuses a new name the start contract already has', () => { + expect(refusalFor(rename('userProfile', 'Account'))?.message).toContain( + 'table "Account" already exists in the start contract', + ); + }); + + it('refuses a new name the end contract does not have', () => { + expect(refusalFor(rename('userProfile', 'Profile'))?.message).toContain( + 'table "Profile" does not exist in the end contract', + ); + }); + + it('refuses a namespace the start contract does not have', () => { + expect(refusalFor(rename('userProfile', 'UserProfile', 'auth'))?.message).toContain( + 'table "auth.userProfile" does not exist in the start contract', + ); + }); + + it('refuses a migration without a start contract', () => { + expect(refusalFor(rename('userProfile', 'UserProfile'), null)).toMatchObject({ + code: TABLE_RENAME_UNMATCHED_CODE, + message: expect.stringContaining('the migration has no start contract'), + }); + }); + + it('refuses a table name declared in more than one namespace when no namespace is given', () => { + const result = applyTableRename({ + startContract: contractOf({ + auth: { userProfile: table() }, + app: { userProfile: table() }, + }), + endContract: contractOf({ auth: { UserProfile: table() }, app: { userProfile: table() } }), + rename: rename('userProfile', 'UserProfile'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.message).toContain( + 'table "userProfile" is declared in more than one namespace (auth, app); name its namespace', + ); + }); + }); +}); diff --git a/packages/2-sql/9-family/test/table-rename-intents.test.ts b/packages/2-sql/9-family/test/table-rename-intents.test.ts deleted file mode 100644 index 650a8f2b1b76..000000000000 --- a/packages/2-sql/9-family/test/table-rename-intents.test.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; -import type { StorageEntityRename } from '@internal/framework-components/control'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { - isMaterializedSqlNamespace, - SqlStorage, - StorageTable, - type StorageTableInput, - toStorageTypeInstance, -} from '@internal/sql-contract/types'; -import { applicationDomainOf } from '@repo/test-utils'; -import { describe, expect, it } from 'vitest'; -import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; -import { - applyTableRenameIntents, - TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE, - TABLE_RENAME_UNMATCHED_CODE, -} from '../src/core/migrations/table-rename-intents'; - -const idColumn = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; - -function table(extra: Partial = {}): StorageTable { - return new StorageTable({ - columns: { id: idColumn }, - primaryKey: { columns: ['id'] }, - uniques: [], - indexes: [], - foreignKeys: [], - ...extra, - }); -} - -function contractOf( - namespaces: Readonly>>>, - hashSeed = 'seed', -): Contract { - return { - target: 'postgres', - targetFamily: 'sql', - profileHash: profileHash(hashSeed), - storage: new SqlStorage({ - storageHash: coreHash(hashSeed), - types: { Money: toStorageTypeInstance({ codecId: 'pg/numeric@1', nativeType: 'numeric' }) }, - namespaces: Object.fromEntries( - Object.entries(namespaces).map(([id, tables]) => [ - id, - createTestSqlNamespace({ id, entries: { table: tables } }), - ]), - ), - }), - roots: {}, - domain: applicationDomainOf({ models: {} }), - capabilities: {}, - extensions: {}, - meta: {}, - }; -} - -const rename = (from: string, to: string): StorageEntityRename => ({ - from: { name: from }, - to: { name: to }, -}); - -function tablesOf(contract: Contract, namespaceId: string): readonly string[] { - return Object.keys(contract.storage.namespaces[namespaceId]?.entries.table ?? {}); -} - -describe('applyTableRenameIntents', () => { - it('re-keys the table under its new name and retargets every foreign key that named it', () => { - const fromContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { - userProfile: table({ - columns: { id: idColumn, parentId: { ...idColumn, nullable: true } }, - foreignKeys: [ - { - source: { - namespaceId: UNBOUND_NAMESPACE_ID, - tableName: 'userProfile', - columns: ['parentId'], - }, - target: { - namespaceId: UNBOUND_NAMESPACE_ID, - tableName: 'userProfile', - columns: ['id'], - }, - name: 'userProfile_parent_fkey', - }, - ], - }), - post: table({ - columns: { id: idColumn, authorId: idColumn }, - foreignKeys: [ - { - source: { - namespaceId: UNBOUND_NAMESPACE_ID, - tableName: 'post', - columns: ['authorId'], - }, - target: { - namespaceId: UNBOUND_NAMESPACE_ID, - tableName: 'userProfile', - columns: ['id'], - }, - }, - ], - }), - }, - }); - const toContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), post: table() }, - }); - - const result = applyTableRenameIntents({ - renameTableReferences: undefined, - fromContract, - toContract, - intents: [rename('userProfile', 'UserProfile')], - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.renames).toEqual([ - { namespaceId: UNBOUND_NAMESPACE_ID, from: 'userProfile', to: 'UserProfile' }, - ]); - const renamed = result.value.contract; - expect(tablesOf(renamed, UNBOUND_NAMESPACE_ID)).toEqual(['UserProfile', 'post']); - const renamedTable = - renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['UserProfile']; - expect(renamedTable).toBeInstanceOf(StorageTable); - expect(Object.keys(renamedTable?.columns ?? {})).toEqual(['id', 'parentId']); - expect( - renamedTable?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName, fk.name]), - ).toEqual([['UserProfile', 'UserProfile', 'userProfile_parent_fkey']]); - const post = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['post']; - expect(post?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName])).toEqual([ - ['post', 'UserProfile'], - ]); - }); - - it('keeps the namespace class, the storage hash and types, and leaves the input untouched', () => { - const fromContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { userProfile: table() }, - audit: { log: table() }, - }); - const toContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { UserProfile: table() }, - audit: { log: table() }, - }); - - const result = applyTableRenameIntents({ - renameTableReferences: undefined, - fromContract, - toContract, - intents: [rename('userProfile', 'UserProfile')], - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - const renamed = result.value.contract; - const namespace = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]; - expect(isMaterializedSqlNamespace(namespace)).toBe(true); - expect(Object.getPrototypeOf(namespace)).toBe( - Object.getPrototypeOf(fromContract.storage.namespaces[UNBOUND_NAMESPACE_ID]), - ); - expect(namespace?.id).toBe(UNBOUND_NAMESPACE_ID); - expect(renamed.storage.namespaces['audit']).toBe(fromContract.storage.namespaces['audit']); - expect(renamed.storage.storageHash).toBe(fromContract.storage.storageHash); - expect(renamed.storage.types).toEqual(fromContract.storage.types); - expect(renamed.profileHash).toBe(fromContract.profileHash); - expect(tablesOf(fromContract, UNBOUND_NAMESPACE_ID)).toEqual(['userProfile']); - }); - - it('resolves an unqualified name in whichever namespace declares it', () => { - const fromContract = contractOf({ auth: { userProfile: table() }, app: { post: table() } }); - const toContract = contractOf({ auth: { UserProfile: table() }, app: { post: table() } }); - - const result = applyTableRenameIntents({ - renameTableReferences: undefined, - fromContract, - toContract, - intents: [rename('userProfile', 'UserProfile')], - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.renames).toEqual([ - { namespaceId: 'auth', from: 'userProfile', to: 'UserProfile' }, - ]); - expect(tablesOf(result.value.contract, 'auth')).toEqual(['UserProfile']); - }); - - it('honours a namespace qualifier on either side', () => { - const fromContract = contractOf({ - auth: { userProfile: table() }, - app: { userProfile: table() }, - }); - const toContract = contractOf({ - auth: { UserProfile: table() }, - app: { userProfile: table() }, - }); - - const result = applyTableRenameIntents({ - renameTableReferences: undefined, - fromContract, - toContract, - intents: [ - { - from: { namespaceId: 'auth', name: 'userProfile' }, - to: { namespaceId: 'auth', name: 'UserProfile' }, - }, - ], - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.renames).toEqual([ - { namespaceId: 'auth', from: 'userProfile', to: 'UserProfile' }, - ]); - expect(tablesOf(result.value.contract, 'app')).toEqual(['userProfile']); - }); - - it('lets the target rename its own references to each renamed table in that table namespace', () => { - const fromContract = contractOf({ - auth: { userProfile: table(), account: table() }, - app: { userProfile: table() }, - }); - const toContract = contractOf({ - auth: { UserProfile: table(), account: table() }, - app: { userProfile: table() }, - }); - const calls: string[] = []; - - const result = applyTableRenameIntents({ - fromContract, - toContract, - intents: [ - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - ], - renameTableReferences: (entries, applied) => { - calls.push(`${applied.namespaceId}:${applied.from}>${applied.to}`); - return { ...entries, marker: { [applied.to]: { tableName: applied.to } } }; - }, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(calls).toEqual(['auth:userProfile>UserProfile']); - expect(result.value.contract.storage.namespaces['auth']?.entries['marker']).toEqual({ - UserProfile: { tableName: 'UserProfile' }, - }); - expect(result.value.contract.storage.namespaces['app']?.entries['marker']).toBeUndefined(); - }); - - it('returns nothing to do when there are no intents', () => { - const fromContract = contractOf({ [UNBOUND_NAMESPACE_ID]: { userProfile: table() } }); - - const result = applyTableRenameIntents({ - fromContract, - toContract: fromContract, - intents: [], - renameTableReferences: undefined, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.contract).toBe(fromContract); - expect(result.value.renames).toEqual([]); - }); - - describe('unmatched intents', () => { - const fromContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { userProfile: table(), Account: table() }, - }); - const toContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), Account: table() }, - }); - - function conflictsFor(intents: readonly StorageEntityRename[]) { - const result = applyTableRenameIntents({ - fromContract, - toContract, - intents, - renameTableReferences: undefined, - }); - expect(result.ok).toBe(false); - return result.ok ? [] : result.failure; - } - - it('reports a previous table that does not exist', () => { - expect(conflictsFor([rename('ghost', 'UserProfile')])).toEqual([ - expect.objectContaining({ - kind: 'tableRenameUnmatched', - summary: expect.stringContaining('table "ghost" does not exist in the previous contract'), - meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: 'ghost', to: 'UserProfile' }, - }), - ]); - }); - - it('reports a new name that already exists in the previous state', () => { - expect(conflictsFor([rename('userProfile', 'Account')])).toEqual([ - expect.objectContaining({ - kind: 'tableRenameUnmatched', - summary: expect.stringContaining( - 'table "Account" already exists in the previous contract', - ), - }), - ]); - }); - - it('reports a new name that is absent from the next contract', () => { - expect(conflictsFor([rename('userProfile', 'Profile')])).toEqual([ - expect.objectContaining({ - kind: 'tableRenameUnmatched', - summary: expect.stringContaining('table "Profile" does not exist in the next contract'), - }), - ]); - }); - - it('reports a name whose namespace qualifier the contract does not have', () => { - expect( - conflictsFor([ - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - ]), - ).toEqual([ - expect.objectContaining({ - kind: 'tableRenameUnmatched', - summary: expect.stringContaining('"auth.userProfile"'), - }), - ]); - }); - - it('reports every unmatched intent, one conflict each, and applies none', () => { - const conflicts = conflictsFor([ - rename('userProfile', 'UserProfile'), - rename('ghost', 'UserProfile'), - rename('userProfile', 'Profile'), - ]); - expect(conflicts.map((conflict) => conflict.meta)).toEqual([ - { code: TABLE_RENAME_UNMATCHED_CODE, from: 'ghost', to: 'UserProfile' }, - { code: TABLE_RENAME_UNMATCHED_CODE, from: 'userProfile', to: 'Profile' }, - ]); - }); - - it('reports an unqualified name declared in more than one namespace', () => { - const ambiguousFrom = contractOf({ - auth: { userProfile: table() }, - app: { userProfile: table() }, - }); - const ambiguousTo = contractOf({ - auth: { UserProfile: table() }, - app: { userProfile: table() }, - }); - const result = applyTableRenameIntents({ - renameTableReferences: undefined, - fromContract: ambiguousFrom, - toContract: ambiguousTo, - intents: [rename('userProfile', 'UserProfile')], - }); - - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.failure[0]?.summary).toContain('more than one namespace'); - }); - - it('rejects intents without a prior contract to apply them to, with its own code', () => { - const result = applyTableRenameIntents({ - renameTableReferences: undefined, - fromContract: null, - toContract, - intents: [rename('userProfile', 'UserProfile')], - }); - - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.failure).toEqual([ - { - kind: 'unsupportedOperation', - summary: `${TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE}: --rename "userProfile=UserProfile" needs a previous contract to apply the rename to, and this plan starts from an empty database.`, - why: 'A rename intent only makes sense between two contracts. Plan from the migration that created the table. A database managed with db update has no migration history: rename the table there by hand with ALTER TABLE ... RENAME TO ..., and drop the flag.', - meta: { code: TABLE_RENAME_NO_PREVIOUS_CONTRACT_CODE }, - }, - ]); - }); - - describe('two intents that resolve to the same table', () => { - const authFrom = contractOf({ auth: { userProfile: table(), account: table() } }); - const authTo = contractOf({ auth: { Profile: table(), Member: table(), account: table() } }); - - function conflictsBetween(intents: readonly StorageEntityRename[]) { - const result = applyTableRenameIntents({ - renameTableReferences: undefined, - fromContract: authFrom, - toContract: authTo, - intents, - }); - expect(result.ok).toBe(false); - return result.ok ? [] : result.failure; - } - - it('reports the second intent that renames the same old table', () => { - expect( - conflictsBetween([ - rename('userProfile', 'Profile'), - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'Member' } }, - ]), - ).toEqual([ - expect.objectContaining({ - kind: 'tableRenameUnmatched', - summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename "auth.userProfile=Member" does not match the contracts: table "auth.userProfile" is already renamed by --rename "userProfile=Profile".`, - meta: { code: TABLE_RENAME_UNMATCHED_CODE, from: 'userProfile', to: 'Member' }, - }), - ]); - }); - - it('reports the second intent that renames to the same new table', () => { - expect( - conflictsBetween([ - rename('userProfile', 'Profile'), - { from: { name: 'account' }, to: { namespaceId: 'auth', name: 'Profile' } }, - ]), - ).toEqual([ - expect.objectContaining({ - kind: 'tableRenameUnmatched', - summary: `${TABLE_RENAME_UNMATCHED_CODE}: --rename "account=auth.Profile" does not match the contracts: table "auth.Profile" is already the new name in --rename "userProfile=Profile".`, - }), - ]); - }); - }); - }); -}); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts new file mode 100644 index 000000000000..e1c0d6722e67 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts @@ -0,0 +1,233 @@ +import type { SqlMigrationPlannerPlanOptions } from '@internal/family-sql/control'; +import type { SchemaDiffIssue } from '@internal/framework-components/control'; +import { issueOutcome } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { parseWireName } from '@internal/sql-schema-ir/naming'; +import { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types'; +import type { SqlSchemaDiffNode } from '../schema-ir/schema-node-kinds'; +import { resolveNamespaceIdForDdlSchema } from './control-policy'; +import { issueNode } from './issue-planner'; +import { RenameConstraintCall, RenameIndexCall } from './op-factory-call'; + +/** + * Rename post-pass for indexes, per `(schema, table)`, widening-only, + * deterministic by sorted names — the same structure as the policy pass + * below (which stays untouched: policies pair by hash only). + * + * Hash pairing (prefix-only renames): extras whose live names parse as + * wire names, grouped by `(schema, table, hash)`; missing nodes iterated + * in sorted-name order consume the sorted-name-first candidate. + * + * Content pairing (exact→wire convergence), after hash pairing has + * consumed its matches: the remaining wire-named-missing nodes + * (`prefix` defined) against the remaining extras of any name shape, + * paired iff content-equal (columns ordered-strict both-defined-or- + * both-undefined, `unique`/`type` strict, `options` loose, bodies + * byte-equal). + * + * Leftovers proceed as create/drop exactly as before; without the + * widening allowance the pass is skipped and pairing degrades to the + * additive half, like the policy pass. + */ +export function pairIndexRenames( + options: Pick, + issues: readonly SchemaDiffIssue[], +): { + readonly calls: readonly RenameIndexCall[]; + readonly consumed: ReadonlySet>; +} { + const consumed = new Set>(); + const calls: RenameIndexCall[] = []; + if (!options.policy.allowedOperationClasses.includes('widening')) { + return { calls, consumed }; + } + + interface IndexFinding { + readonly issue: SchemaDiffIssue; + readonly node: SqlIndexIR; + readonly ddlSchema: string; + readonly tableName: string; + } + const missing: IndexFinding[] = []; + const extra: IndexFinding[] = []; + for (const issue of issues) { + const node = issueNode(issue); + if (node === undefined || !SqlIndexIR.is(node)) continue; + const ddlSchema = issue.path[1]; + const tableName = issue.path[2]; + if (ddlSchema === undefined || tableName === undefined) continue; + if (issueOutcome(issue) === 'not-found') { + missing.push({ issue, node, ddlSchema, tableName }); + } else if (issueOutcome(issue) === 'not-expected') { + extra.push({ issue, node, ddlSchema, tableName }); + } + } + if (missing.length === 0 || extra.length === 0) { + return { calls, consumed }; + } + + const byName = (a: IndexFinding, b: IndexFinding): number => + a.node.name < b.node.name ? -1 : a.node.name > b.node.name ? 1 : 0; + // DDL emission must stay unqualified for the unbound namespace, exactly + // like the per-issue mapping's `emissionSchemaName`. + const emissionSchema = (ddlSchema: string): string => + resolveNamespaceIdForDdlSchema(options.contract, ddlSchema) === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : ddlSchema; + const pairingKey = (finding: IndexFinding, hash: string): string => + JSON.stringify([finding.ddlSchema, finding.tableName, hash]); + const rename = (missingFinding: IndexFinding, candidate: IndexFinding): void => { + consumed.add(missingFinding.issue); + consumed.add(candidate.issue); + calls.push( + new RenameIndexCall( + emissionSchema(missingFinding.ddlSchema), + missingFinding.tableName, + candidate.node.name, + missingFinding.node.name, + ), + ); + }; + + const sortedMissing = [...missing].sort(byName); + + const extrasByHash = new Map(); + for (const finding of extra) { + const parsed = parseWireName(finding.node.name); + if (parsed === undefined) continue; + const key = pairingKey(finding, parsed.hash); + const group = extrasByHash.get(key) ?? []; + group.push(finding); + extrasByHash.set(key, group); + } + for (const group of extrasByHash.values()) { + group.sort(byName); + } + for (const missingFinding of sortedMissing) { + const parsed = parseWireName(missingFinding.node.name); + if (parsed === undefined) continue; + const candidate = extrasByHash.get(pairingKey(missingFinding, parsed.hash))?.shift(); + if (candidate === undefined) continue; + rename(missingFinding, candidate); + } + + const sortedExtras = [...extra].sort(byName); + for (const missingFinding of sortedMissing) { + if (consumed.has(missingFinding.issue)) continue; + if (missingFinding.node.prefix === undefined) continue; + const candidate = sortedExtras.find( + (extraFinding) => + !consumed.has(extraFinding.issue) && + extraFinding.ddlSchema === missingFinding.ddlSchema && + extraFinding.tableName === missingFinding.tableName && + missingFinding.node.contentEquals(extraFinding.node, { + columnPresence: 'matching', + bodies: 'verbatim', + }), + ); + if (candidate === undefined) continue; + rename(missingFinding, candidate); + } + + return { calls, consumed }; +} + +/** + * Check-constraint rename post-pass: a `not-found` and a `not-expected` + * check on the same table whose wire-name content hashes match but whose + * prefixes differ is a prefix-only rename, and collapses into one + * `ALTER TABLE … RENAME CONSTRAINT`. + * + * This is the index pass's hash-pairing phase and nothing else. There is + * deliberately no content-pairing phase: a live check body is whatever + * Postgres reprinted, so it never byte-matches the authored text, and + * pairing an exact-named live check by content would bless whatever + * predicate is actually live. Adoption of an old exact-named check stays + * drop + add. + * + * Runs only when the policy allows `widening` (rename's class). Without it + * the pass no-ops and the pair degrades to the slice-1 behavior: an add, + * plus a drop when `destructive` is allowed too. + */ +export function pairCheckRenames( + options: Pick, + issues: readonly SchemaDiffIssue[], +): { + readonly calls: readonly RenameConstraintCall[]; + readonly consumed: ReadonlySet>; +} { + const consumed = new Set>(); + const calls: RenameConstraintCall[] = []; + if (!options.policy.allowedOperationClasses.includes('widening')) { + return { calls, consumed }; + } + + interface CheckFinding { + readonly issue: SchemaDiffIssue; + readonly node: SqlCheckConstraintIR; + readonly ddlSchema: string; + readonly tableName: string; + } + const missing: CheckFinding[] = []; + const extra: CheckFinding[] = []; + for (const issue of issues) { + const node = issueNode(issue); + if (node === undefined || !SqlCheckConstraintIR.is(node)) continue; + const ddlSchema = issue.path[1]; + const tableName = issue.path[2]; + if (ddlSchema === undefined || tableName === undefined) continue; + if (issueOutcome(issue) === 'not-found') { + missing.push({ issue, node, ddlSchema, tableName }); + } else if (issueOutcome(issue) === 'not-expected') { + extra.push({ issue, node, ddlSchema, tableName }); + } + } + if (missing.length === 0 || extra.length === 0) { + return { calls, consumed }; + } + + const byName = (a: CheckFinding, b: CheckFinding): number => + a.node.name < b.node.name ? -1 : a.node.name > b.node.name ? 1 : 0; + // DDL emission must stay unqualified for the unbound namespace, exactly + // like the per-issue mapping's `emissionSchemaName`. + const emissionSchema = (ddlSchema: string): string => + resolveNamespaceIdForDdlSchema(options.contract, ddlSchema) === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : ddlSchema; + const pairingKey = (finding: CheckFinding, hash: string): string => + JSON.stringify([finding.ddlSchema, finding.tableName, hash]); + + const sortedMissing = [...missing].sort(byName); + + const extrasByHash = new Map(); + for (const finding of extra) { + const parsed = parseWireName(finding.node.name); + if (parsed === undefined) continue; + const key = pairingKey(finding, parsed.hash); + const group = extrasByHash.get(key) ?? []; + group.push(finding); + extrasByHash.set(key, group); + } + for (const group of extrasByHash.values()) { + group.sort(byName); + } + for (const missingFinding of sortedMissing) { + const parsed = parseWireName(missingFinding.node.name); + if (parsed === undefined) continue; + const candidate = extrasByHash.get(pairingKey(missingFinding, parsed.hash))?.shift(); + if (candidate === undefined) continue; + consumed.add(missingFinding.issue); + consumed.add(candidate.issue); + calls.push( + new RenameConstraintCall( + emissionSchema(missingFinding.ddlSchema), + missingFinding.tableName, + 'checkConstraint', + candidate.node.name, + missingFinding.node.name, + ), + ); + } + + return { calls, consumed }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 08f253eab492..4032e082a993 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -405,7 +405,7 @@ export class RenameTableCall extends PostgresOpFactoryCallNode { } opts.push(`table: ${jsonToTsSource(this.oldTableName)}`); opts.push(`to: ${jsonToTsSource(this.tableName)}`); - return `this.renameTable({ ${opts.join(', ')} })`; + return `...this.renameTable({ ${opts.join(', ')} })`; } override importRequirements(): readonly ImportRequirement[] { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 1259f55ef7ac..afd08d122533 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -1,14 +1,12 @@ import type { Contract } from '@internal/contract/types'; import type { MigrationOperationPolicy, - ResolvedTableRename, SqlMigrationPlannerPlanOptions, SqlPlannerConflict, SqlPlannerFailureResult, SuppressionRecord, } from '@internal/family-sql/control'; import { - applyTableRenameIntents, controlPolicyForCall, detectTableNameCaseChanges, extractCodecControlHooks, @@ -16,7 +14,6 @@ import { partitionIssuesByControlPolicy, planFieldEventOperations, plannerFailure, - tableRenameScaffoldError, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; @@ -25,21 +22,16 @@ import type { MigrationPlanner, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, - MigrationScaffoldRenames, SchemaDiffIssue, SchemaOwnership, - StorageEntityRename, } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { SqlStorage } from '@internal/sql-contract/types'; import { namingOf, parseWireName } from '@internal/sql-schema-ir/naming'; import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; -import { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types'; -import { assertDefined } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; -import { notOk, ok, type Result } from '@internal/utils/result'; import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; import { postgresNodeStorageCoordinate } from '../schema-ir/node-storage-coordinate'; @@ -55,6 +47,7 @@ import { resolvePostgresNodeIssueCreationFactoryName, } from './control-policy'; import { buildPostgresPlanDiff } from './diff-database-schema'; +import { pairCheckRenames, pairIndexRenames } from './index-and-check-renames'; import { coalesceSubtreeIssues, conflictForDisallowedCall, @@ -66,18 +59,14 @@ import type { PostgresOpFactoryCall } from './op-factory-call'; import { CreatePostgresRlsPolicyCall, DropPostgresRlsPolicyCall, - RenameConstraintCall, - RenameIndexCall, RenamePostgresRlsPolicyCall, RenameTableCall, } from './op-factory-call'; import { renameTableStatement } from './operations/tables'; import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration'; import { postgresPlannerStrategies } from './planner-strategies'; -import { postgresContractToSchema } from './postgres-contract-to-schema'; -import { renameRlsReferences } from './rename-rls-references'; import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; -import { constraintRenamesForTableRename } from './table-rename-constraint-renames'; +import { emissionSchemaForNamespace } from './table-rename-calls'; import { verifyPostgresNamespacePresence } from './verify-postgres-namespaces'; type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends { @@ -90,31 +79,6 @@ type PlannerOptionsWithComponents = SqlMigrationPlannerPlanOptions & { readonly frameworkComponents: PlannerFrameworkComponents; }; -interface PlannedTableRenames { - readonly fromContract: Contract | null; - readonly previousSchema: SqlMigrationPlannerPlanOptions['schema']; - readonly calls: readonly PostgresOpFactoryCall[]; -} - -function emissionSchemaForNamespace(contract: Contract, namespaceId: string): string { - return namespaceId === UNBOUND_NAMESPACE_ID - ? UNBOUND_NAMESPACE_ID - : resolveDdlSchemaForNamespaceStorage(contract.storage, namespaceId); -} - -function renamedTableNode( - schema: PostgresDatabaseSchemaNode, - contract: Contract, - rename: ResolvedTableRename, -): PostgresTableSchemaNode { - const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, rename.namespaceId); - const table = Object.values(schema.namespaces).find( - (namespace) => namespace.schemaName === ddlSchema, - )?.tables[rename.to]; - assertDefined(table, `a resolved rename names table "${rename.to}" in schema "${ddlSchema}"`); - return table; -} - function partitionPostgresCallsByControlPolicy( calls: readonly TCall[], contract: Contract, @@ -127,10 +91,6 @@ function partitionPostgresCallsByControlPolicy, + context: MigrationScaffoldContext, spaceId: string, ): MigrationPlanWithAuthoringSurface { return new TypeScriptRenderablePostgresMigration( - context.renames === undefined - ? [] - : this.scaffoldTableRenames(context, context.renames, spaceId), + [], { from: context.fromHash, to: context.toHash, @@ -239,110 +195,6 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr ); } - /** - * The rename operations `planSql` plans for the stated renames, and nothing else: the table and constraint renames, then the index and check renames on the renamed tables, less those the tables' control policies suppress. Every other difference between the two contracts is left to the author. - */ - private scaffoldTableRenames( - context: MigrationScaffoldContext<'sql', 'postgres'>, - renames: MigrationScaffoldRenames<'sql', 'postgres'>, - spaceId: string, - ): readonly PostgresOpFactoryCall[] { - const contract = blindCast< - Contract, - 'the family resolver only binds the Postgres planner for a SQL contract' - >(renames.toContract); - const fromContract = blindCast< - Contract | null, - 'the family resolver only binds the Postgres planner for a SQL contract' - >(renames.fromContract); - const options: SqlMigrationPlannerPlanOptions = { - contract, - fromContract, - schema: postgresContractToSchema(fromContract, renames.frameworkComponents), - policy: SCAFFOLD_POLICY, - frameworkComponents: renames.frameworkComponents, - spaceId, - snapshotsImportPath: context.snapshotsImportPath, - renames: renames.intents, - }; - const planned = this.planTableRenames(options); - if (!planned.ok) { - throw tableRenameScaffoldError(planned.failure); - } - PostgresDatabaseSchemaNode.assert(planned.value.previousSchema); - const { issues } = buildPostgresPlanDiff({ - contract, - actualSchema: planned.value.previousSchema, - frameworkComponents: renames.frameworkComponents, - }); - const relationalIssues = issues.filter((issue) => !isPolicyDiffIssue(issue)); - const renamedTables = planned.value.calls.filter( - (call): call is RenameTableCall => call.factoryName === 'renameTable', - ); - const onRenamedTable = (call: { readonly schemaName: string; readonly tableName: string }) => - renamedTables.some( - (renamed) => renamed.schemaName === call.schemaName && renamed.tableName === call.tableName, - ); - return partitionPostgresCallsByControlPolicy( - [ - ...planned.value.calls, - ...this.pairIndexRenames(options, relationalIssues).calls.filter(onRenamedTable), - ...this.pairCheckRenames(options, relationalIssues).calls.filter(onRenamedTable), - ], - contract, - ).kept; - } - - /** - * Applies the stated renames to the previous contract and plans the table and constraint renames they need. Without renames it hands back the previous contract and schema unchanged and no calls. - */ - private planTableRenames( - options: SqlMigrationPlannerPlanOptions, - ): Result { - const intents = options.renames ?? []; - if (intents.length === 0) { - return ok({ fromContract: options.fromContract, previousSchema: options.schema, calls: [] }); - } - const applied = applyTableRenameIntents({ - fromContract: options.fromContract, - toContract: options.contract, - intents, - renameTableReferences: renameRlsReferences, - }); - if (!applied.ok) { - return notOk(applied.failure); - } - const previousSchema = postgresContractToSchema( - applied.value.contract, - options.frameworkComponents, - ); - const nextSchema = postgresContractToSchema(options.contract, options.frameworkComponents); - const calls = applied.value.renames.flatMap((rename): PostgresOpFactoryCall[] => { - const schemaName = emissionSchemaForNamespace(options.contract, rename.namespaceId); - return [ - new RenameTableCall(schemaName, rename.from, rename.to), - ...constraintRenamesForTableRename({ - schemaName, - from: rename.from, - to: rename.to, - previous: renamedTableNode(previousSchema, options.contract, rename), - next: renamedTableNode(nextSchema, options.contract, rename), - }), - ]; - }); - const disallowed = calls.filter( - (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), - ); - if (disallowed.length > 0) { - return notOk( - disallowed.map((call) => - conflictForDisallowedCall(call, options.policy.allowedOperationClasses), - ), - ); - } - return ok({ fromContract: applied.value.contract, previousSchema, calls }); - } - private planSql(options: SqlMigrationPlannerPlanOptions): PostgresPlanResult { const schemaName = options.schemaName ?? @@ -353,19 +205,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return policyResult; } - // Operator-stated renames are applied to the previous contract before - // the diff, and the "from" tree is re-derived from that renamed contract, - // so the differ sees each renamed table under its new name and plans its - // column, index and constraint changes normally. The table and - // constraint renames become the first operations of the plan. An intent - // that matches neither side, or a rename the policy's classes do not - // admit, fails the plan here rather than degrading to a drop and a create. - const tableRenames = this.planTableRenames(options); - if (!tableRenames.ok) { - return plannerFailure(tableRenames.failure); - } - const { fromContract, previousSchema } = tableRenames.value; - const renameTableCalls = tableRenames.value.calls; + const fromContract = options.fromContract; + const previousSchema = options.schema; // The one combined tree diff drives the whole plan: relational findings // become structural DDL via `planIssues`, policy findings become RLS ops @@ -432,8 +273,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // mapping turns them into create + drop. Consumed issues never reach // `planIssues`; the rename calls go through the same call-side // control-policy partition the policy ops use. - const indexRenames = this.pairIndexRenames(options, schemaIssues); - const checkRenames = this.pairCheckRenames(options, schemaIssues); + const indexRenames = pairIndexRenames(options, schemaIssues); + const checkRenames = pairCheckRenames(options, schemaIssues); const renameConsumed = new Set([...indexRenames.consumed, ...checkRenames.consumed]); const plannableIssues = renameConsumed.size === 0 @@ -482,6 +323,12 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr rename.to, ), ], + renameTableCall: (rename) => + new RenameTableCall( + rename.namespaceId ?? UNBOUND_NAMESPACE_ID, + rename.from, + rename.to, + ).renderTypeScript(), contract: options.contract, defaultNamespaceId: DEFAULT_NAMESPACE_ID, }); @@ -514,11 +361,6 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return plannerFailure([...(result.ok ? [] : result.failure), ...schemaDiff.conflicts]); } - const renameTablePartition = partitionPostgresCallsByControlPolicy( - renameTableCalls, - options.contract, - ); - const indexRenamePartition = partitionPostgresCallsByControlPolicy( [...indexRenames.calls, ...checkRenames.calls], options.contract, @@ -549,10 +391,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr fieldEventPostgresCalls, options.contract, ); - // The table renames run first: every later operation addresses the - // renamed table by its new name. const calls = [ - ...renameTablePartition.kept, ...result.value.calls, ...indexRenamePartition.kept, ...schemaDiffPartition.kept, @@ -565,7 +404,6 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr const seenWarnings = new Set(); const warnings: SqlPlannerConflict[] = [ ...issuePartition.suppressions, - ...renameTablePartition.suppressions, ...indexRenamePartition.suppressions, ...schemaDiff.suppressions, ...schemaDiffPartition.suppressions, @@ -595,229 +433,6 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr }); } - /** - * Rename post-pass for indexes, per `(schema, table)`, widening-only, - * deterministic by sorted names — the same structure as the policy pass - * below (which stays untouched: policies pair by hash only). - * - * Hash pairing (prefix-only renames): extras whose live names parse as - * wire names, grouped by `(schema, table, hash)`; missing nodes iterated - * in sorted-name order consume the sorted-name-first candidate. - * - * Content pairing (exact→wire convergence), after hash pairing has - * consumed its matches: the remaining wire-named-missing nodes - * (`prefix` defined) against the remaining extras of any name shape, - * paired iff content-equal (columns ordered-strict both-defined-or- - * both-undefined, `unique`/`type` strict, `options` loose, bodies - * byte-equal). - * - * Leftovers proceed as create/drop exactly as before; without the - * widening allowance the pass is skipped and pairing degrades to the - * additive half, like the policy pass. - */ - private pairIndexRenames( - options: Pick, - issues: readonly SchemaDiffIssue[], - ): { - readonly calls: readonly RenameIndexCall[]; - readonly consumed: ReadonlySet>; - } { - const consumed = new Set>(); - const calls: RenameIndexCall[] = []; - if (!options.policy.allowedOperationClasses.includes('widening')) { - return { calls, consumed }; - } - - interface IndexFinding { - readonly issue: SchemaDiffIssue; - readonly node: SqlIndexIR; - readonly ddlSchema: string; - readonly tableName: string; - } - const missing: IndexFinding[] = []; - const extra: IndexFinding[] = []; - for (const issue of issues) { - const node = issueNode(issue); - if (node === undefined || !SqlIndexIR.is(node)) continue; - const ddlSchema = issue.path[1]; - const tableName = issue.path[2]; - if (ddlSchema === undefined || tableName === undefined) continue; - if (issueOutcome(issue) === 'not-found') { - missing.push({ issue, node, ddlSchema, tableName }); - } else if (issueOutcome(issue) === 'not-expected') { - extra.push({ issue, node, ddlSchema, tableName }); - } - } - if (missing.length === 0 || extra.length === 0) { - return { calls, consumed }; - } - - const byName = (a: IndexFinding, b: IndexFinding): number => - a.node.name < b.node.name ? -1 : a.node.name > b.node.name ? 1 : 0; - // DDL emission must stay unqualified for the unbound namespace, exactly - // like the per-issue mapping's `emissionSchemaName`. - const emissionSchema = (ddlSchema: string): string => - resolveNamespaceIdForDdlSchema(options.contract, ddlSchema) === UNBOUND_NAMESPACE_ID - ? UNBOUND_NAMESPACE_ID - : ddlSchema; - const pairingKey = (finding: IndexFinding, hash: string): string => - JSON.stringify([finding.ddlSchema, finding.tableName, hash]); - const rename = (missingFinding: IndexFinding, candidate: IndexFinding): void => { - consumed.add(missingFinding.issue); - consumed.add(candidate.issue); - calls.push( - new RenameIndexCall( - emissionSchema(missingFinding.ddlSchema), - missingFinding.tableName, - candidate.node.name, - missingFinding.node.name, - ), - ); - }; - - const sortedMissing = [...missing].sort(byName); - - const extrasByHash = new Map(); - for (const finding of extra) { - const parsed = parseWireName(finding.node.name); - if (parsed === undefined) continue; - const key = pairingKey(finding, parsed.hash); - const group = extrasByHash.get(key) ?? []; - group.push(finding); - extrasByHash.set(key, group); - } - for (const group of extrasByHash.values()) { - group.sort(byName); - } - for (const missingFinding of sortedMissing) { - const parsed = parseWireName(missingFinding.node.name); - if (parsed === undefined) continue; - const candidate = extrasByHash.get(pairingKey(missingFinding, parsed.hash))?.shift(); - if (candidate === undefined) continue; - rename(missingFinding, candidate); - } - - const sortedExtras = [...extra].sort(byName); - for (const missingFinding of sortedMissing) { - if (consumed.has(missingFinding.issue)) continue; - if (missingFinding.node.prefix === undefined) continue; - const candidate = sortedExtras.find( - (extraFinding) => - !consumed.has(extraFinding.issue) && - extraFinding.ddlSchema === missingFinding.ddlSchema && - extraFinding.tableName === missingFinding.tableName && - missingFinding.node.contentEquals(extraFinding.node, { - columnPresence: 'matching', - bodies: 'verbatim', - }), - ); - if (candidate === undefined) continue; - rename(missingFinding, candidate); - } - - return { calls, consumed }; - } - - /** - * Check-constraint rename post-pass: a `not-found` and a `not-expected` - * check on the same table whose wire-name content hashes match but whose - * prefixes differ is a prefix-only rename, and collapses into one - * `ALTER TABLE … RENAME CONSTRAINT`. - * - * This is the index pass's hash-pairing phase and nothing else. There is - * deliberately no content-pairing phase: a live check body is whatever - * Postgres reprinted, so it never byte-matches the authored text, and - * pairing an exact-named live check by content would bless whatever - * predicate is actually live. Adoption of an old exact-named check stays - * drop + add. - * - * Runs only when the policy allows `widening` (rename's class). Without it - * the pass no-ops and the pair degrades to the slice-1 behavior: an add, - * plus a drop when `destructive` is allowed too. - */ - private pairCheckRenames( - options: Pick, - issues: readonly SchemaDiffIssue[], - ): { - readonly calls: readonly RenameConstraintCall[]; - readonly consumed: ReadonlySet>; - } { - const consumed = new Set>(); - const calls: RenameConstraintCall[] = []; - if (!options.policy.allowedOperationClasses.includes('widening')) { - return { calls, consumed }; - } - - interface CheckFinding { - readonly issue: SchemaDiffIssue; - readonly node: SqlCheckConstraintIR; - readonly ddlSchema: string; - readonly tableName: string; - } - const missing: CheckFinding[] = []; - const extra: CheckFinding[] = []; - for (const issue of issues) { - const node = issueNode(issue); - if (node === undefined || !SqlCheckConstraintIR.is(node)) continue; - const ddlSchema = issue.path[1]; - const tableName = issue.path[2]; - if (ddlSchema === undefined || tableName === undefined) continue; - if (issueOutcome(issue) === 'not-found') { - missing.push({ issue, node, ddlSchema, tableName }); - } else if (issueOutcome(issue) === 'not-expected') { - extra.push({ issue, node, ddlSchema, tableName }); - } - } - if (missing.length === 0 || extra.length === 0) { - return { calls, consumed }; - } - - const byName = (a: CheckFinding, b: CheckFinding): number => - a.node.name < b.node.name ? -1 : a.node.name > b.node.name ? 1 : 0; - // DDL emission must stay unqualified for the unbound namespace, exactly - // like the per-issue mapping's `emissionSchemaName`. - const emissionSchema = (ddlSchema: string): string => - resolveNamespaceIdForDdlSchema(options.contract, ddlSchema) === UNBOUND_NAMESPACE_ID - ? UNBOUND_NAMESPACE_ID - : ddlSchema; - const pairingKey = (finding: CheckFinding, hash: string): string => - JSON.stringify([finding.ddlSchema, finding.tableName, hash]); - - const sortedMissing = [...missing].sort(byName); - - const extrasByHash = new Map(); - for (const finding of extra) { - const parsed = parseWireName(finding.node.name); - if (parsed === undefined) continue; - const key = pairingKey(finding, parsed.hash); - const group = extrasByHash.get(key) ?? []; - group.push(finding); - extrasByHash.set(key, group); - } - for (const group of extrasByHash.values()) { - group.sort(byName); - } - for (const missingFinding of sortedMissing) { - const parsed = parseWireName(missingFinding.node.name); - if (parsed === undefined) continue; - const candidate = extrasByHash.get(pairingKey(missingFinding, parsed.hash))?.shift(); - if (candidate === undefined) continue; - consumed.add(missingFinding.issue); - consumed.add(candidate.issue); - calls.push( - new RenameConstraintCall( - emissionSchema(missingFinding.ddlSchema), - missingFinding.tableName, - 'checkConstraint', - candidate.node.name, - missingFinding.node.name, - ), - ); - } - - return { calls, consumed }; - } - /** * Maps the RLS policy findings of the one combined tree diff * (`buildPostgresPlanDiff`, already ownership-filtered) into diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 25fdcc3209f1..78e7d7140542 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts @@ -2,6 +2,7 @@ import type { Contract } from '@internal/contract/types'; import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import { Migration as SqlMigration } from '@internal/family-sql/migration'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { ControlStack } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { MigrationContractViews } from '@internal/migration-tools/migration'; @@ -38,7 +39,6 @@ import { RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, - RenameTableCall, SetDefaultCall, SetNotNullCall, } from './op-factory-call'; @@ -48,6 +48,7 @@ import { installExtension } from './operations/dependencies'; import type { CreateIndexExtras } from './operations/indexes'; import type { ForeignKeySpec } from './operations/shared'; import type { PostgresPlanTargetDetails } from './planner-target-details'; +import { postgresTableRenameCalls } from './table-rename-calls'; /** * Target-owned base class for Postgres migrations. @@ -111,6 +112,16 @@ export abstract class PostgresMigration< : undefined; } + private frameworkComponents(): ReadonlyArray> { + const stack = this.stack; + if (stack === undefined) return []; + return [ + stack.target, + ...(stack.adapter === undefined ? [] : [stack.adapter]), + ...stack.extensions, + ]; + } + /** * Returns the materialized control adapter, or throws a MIGRATION.POSTGRES_CONTROL_STACK_MISSING naming * `operation` when the migration was constructed without a `ControlStack`. @@ -341,16 +352,21 @@ export abstract class PostgresMigration< ).toOp(this.controlAdapterFor('dropConstraint')); } + /** + * Emit the operations that rename a table: the table rename, then a rename of each primary key, unique constraint, foreign key, index and check whose name was derived from the old table name, read from this migration's start and end contracts. Spread the result into `operations`: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. `schema` names the table's namespace when more than one declares the table. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + */ protected renameTable(options: { readonly schema?: string; readonly table: string; readonly to: string; - }): Promise> { - return new RenameTableCall( - options.schema ?? UNBOUND_NAMESPACE_ID, - options.table, - options.to, - ).toOp(this.controlAdapterFor('renameTable')); + }): readonly Promise>[] { + const adapter = this.controlAdapterFor('renameTable'); + return postgresTableRenameCalls({ + startContract: this.startContract, + endContract: this.endContract, + rename: { namespaceId: options.schema, from: options.table, to: options.to }, + frameworkComponents: this.frameworkComponents(), + }).map(async (call) => call.toOp(adapter)); } protected dropTable(options: { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts new file mode 100644 index 000000000000..ca9aaf04f5a9 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts @@ -0,0 +1,92 @@ +import type { Contract } from '@internal/contract/types'; +import { + applyTableRename, + type MigrationOperationPolicy, + type ResolvedTableRename, + type TableRename, +} from '@internal/family-sql/control'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { assertDefined } from '@internal/utils/assertions'; +import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import type { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; +import { buildPostgresPlanDiff } from './diff-database-schema'; +import { pairCheckRenames, pairIndexRenames } from './index-and-check-renames'; +import { type PostgresOpFactoryCall, RenameTableCall } from './op-factory-call'; +import { postgresContractToSchema } from './postgres-contract-to-schema'; +import { renameRlsReferences } from './rename-rls-references'; +import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; +import { constraintRenamesForTableRename } from './table-rename-constraint-renames'; + +const RENAME_POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['widening'] }; + +export function emissionSchemaForNamespace( + contract: Contract, + namespaceId: string, +): string { + return namespaceId === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : resolveDdlSchemaForNamespaceStorage(contract.storage, namespaceId); +} + +function renamedTableNode( + schema: PostgresDatabaseSchemaNode, + contract: Contract, + rename: ResolvedTableRename, +): PostgresTableSchemaNode { + const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, rename.namespaceId); + const table = Object.values(schema.namespaces).find( + (namespace) => namespace.schemaName === ddlSchema, + )?.tables[rename.to]; + assertDefined(table, `a resolved rename names table "${rename.to}" in schema "${ddlSchema}"`); + return table; +} + +/** + * The calls a migration's `renameTable` emits: the table rename, then a rename of each primary key, unique constraint and foreign key the start contract left unnamed, and of each wire-named index and check whose prefix derives from the table name. A constraint takes the end contract's explicit name only when the end contract leaves it otherwise unchanged. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + */ +export function postgresTableRenameCalls(input: { + readonly startContract: Contract | null; + readonly endContract: Contract; + readonly rename: TableRename; + readonly frameworkComponents: ReadonlyArray>; +}): readonly PostgresOpFactoryCall[] { + const applied = applyTableRename({ + startContract: input.startContract, + endContract: input.endContract, + rename: input.rename, + renameTableReferences: renameRlsReferences, + }); + if (!applied.ok) { + throw applied.failure; + } + const { rename } = applied.value; + const contract = input.endContract; + const schemaName = emissionSchemaForNamespace(contract, rename.namespaceId); + const previousSchema = postgresContractToSchema( + applied.value.contract, + input.frameworkComponents, + ); + const nextSchema = postgresContractToSchema(contract, input.frameworkComponents); + const { issues } = buildPostgresPlanDiff({ + contract, + actualSchema: previousSchema, + frameworkComponents: input.frameworkComponents, + }); + const onRenamedTable = (call: { readonly schemaName: string; readonly tableName: string }) => + call.schemaName === schemaName && call.tableName === rename.to; + const pairing = { contract, policy: RENAME_POLICY }; + return [ + new RenameTableCall(schemaName, rename.from, rename.to), + ...constraintRenamesForTableRename({ + schemaName, + from: rename.from, + to: rename.to, + previous: renamedTableNode(previousSchema, contract, rename), + next: renamedTableNode(nextSchema, contract, rename), + }), + ...pairIndexRenames(pairing, issues).calls.filter(onRenamedTable), + ...pairCheckRenames(pairing, issues).calls.filter(onRenamedTable), + ]; +} diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts new file mode 100644 index 000000000000..695ba735f070 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts @@ -0,0 +1,351 @@ +/** + * `this.renameTable` in a hand-written Postgres migration. It reads the migration's start and end contracts and emits the table rename, then a rename of each object on the table whose name the planner derived from the old table name: unnamed primary keys, unique constraints and foreign keys, and wire-named indexes and checks. A constraint the end contract changes takes the name derived from the new table name. Explicitly named objects keep their names. A table missing from either contract is refused. + */ + +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; +import type { ControlStack } from '@internal/framework-components/control'; +import { + type CheckConstraintInput, + type ForeignKeyInput, + type IndexInput, + SqlStorage, + StorageTable, +} from '@internal/sql-contract/types'; +import { computeCheckContentHash, computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import type { PostgresPlanTargetDetails } from '../../src/core/migrations/planner-target-details'; +import { PostgresMigration } from '../../src/core/migrations/postgres-migration'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { PostgresRlsEnablement } from '../../src/core/postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; +import { PostgresSchema } from '../../src/core/postgres-schema'; +import { + contractOf, + NICKNAME_CHECK, + type ProfileSpec, + postTable, + reference, + stubLowerer, +} from './rename-table-fixtures'; + +type Op = SqlMigrationPlanOperation; +type ContractJson = { readonly storage: { readonly storageHash: string } }; + +const stack = { + adapter: { create: () => stubLowerer as unknown as SqlControlAdapter<'postgres'> }, + target: { kind: 'target', familyId: 'sql', targetId: 'postgres' }, + extensions: [], +} as unknown as ControlStack<'sql', 'postgres'>; + +function jsonOf(contract: Contract): ContractJson { + return new PostgresContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +function renameMigration( + start: Contract | null, + end: Contract, + rename: { readonly schema?: string; readonly table: string; readonly to: string }, +): { readonly operations: readonly Promise[] } { + const endJson = jsonOf(end); + class WithoutStart extends PostgresMigration { + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return [...this.renameTable(rename)]; + } + } + if (start === null) return new WithoutStart(stack); + const startJson = jsonOf(start); + class WithStart extends WithoutStart { + override readonly startContractJson = startJson; + } + return new WithStart(stack); +} + +const RENAME = { table: 'userProfile', to: 'UserProfile' } as const; + +async function renameOps( + start: Contract | null, + end: Contract, + rename: { readonly schema?: string; readonly table: string; readonly to: string } = RENAME, +): Promise { + return Promise.all(renameMigration(start, end, rename).operations); +} + +async function renameLabels(spec: ProfileSpec, nextSpec: ProfileSpec = spec) { + const ops = await renameOps( + contractOf('userProfile', spec, 'from'), + contractOf('UserProfile', nextSpec, 'to'), + ); + return ops.map((op) => op.label); +} + +const HANDLE_HASH = computeIndexContentHash({ columns: ['handle'], unique: false }); +const NICKNAME_HASH = computeCheckContentHash(NICKNAME_CHECK); + +const handleIndex = (tableName: string): IndexInput => ({ + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash: HANDLE_HASH }, + where: undefined, + unique: false, + type: undefined, + options: undefined, +}); + +const nicknameCheck = (tableName: string): CheckConstraintInput => ({ + naming: { kind: 'wire', prefix: `${tableName}_nickname_check`, hash: NICKNAME_HASH }, + expression: NICKNAME_CHECK, +}); + +const accountForeignKey = (tableName: string): ForeignKeyInput => ({ + source: reference(tableName, ['accountId']), + target: reference('account', ['id']), +}); + +const withObjects: ProfileSpec = { + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [accountForeignKey(tableName)], + indexes: (tableName) => [handleIndex(tableName)], + checks: (tableName) => [nicknameCheck(tableName)], +}; + +function rlsContract(tableName: string, hashSeed: string): Contract { + const policy = new PostgresRlsPolicy({ + naming: { kind: 'wire', prefix: 'tenant_read', hash: 'f8d5e783' }, + tableName, + namespaceId: 'public', + operation: 'select', + roles: ['app_user'], + using: '(tenant_id = 1)', + withCheck: undefined, + permissive: true, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + public: new PostgresSchema({ + id: 'public', + entries: { + table: { + [tableName]: new StorageTable({ + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + tenant_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'], name: 'profile_pk' }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + }, + policy: { [policy.name]: policy }, + rls: { [tableName]: new PostgresRlsEnablement({ tableName, namespaceId: 'public' }) }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +describe('PostgresMigration.renameTable', () => { + it('emits the table rename, then a rename of each object named after the old table', async () => { + expect(await renameLabels(withObjects)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + `Rename index "userProfile_handle_idx_${HANDLE_HASH}" to "UserProfile_handle_idx_${HANDLE_HASH}" on "UserProfile"`, + `Rename check constraint "userProfile_nickname_check_${NICKNAME_HASH}" to "UserProfile_nickname_check_${NICKNAME_HASH}" on "UserProfile"`, + ]); + }); + + it('renders the rename statements', async () => { + const ops = await renameOps( + contractOf('userProfile', { primaryKey: { columns: ['id'] } }, 'from'), + contractOf('UserProfile', { primaryKey: { columns: ['id'] } }, 'to'), + ); + + expect(ops.map((op) => op.execute.map((step) => step.sql))).toEqual([ + ['ALTER TABLE "userProfile" RENAME TO "UserProfile"'], + ['ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"'], + ]); + }); + + it('renames an unnamed constraint to the explicit name the end contract gives it', async () => { + expect( + await renameLabels( + { uniques: [{ columns: ['email'] }] }, + { uniques: [{ columns: ['email'], name: 'profile_email_unique' }] }, + ), + ).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "profile_email_unique" on "UserProfile"', + ]); + }); + + it('renames a constraint the end contract changes to the name derived from the new table', async () => { + const memberTable = () => ({ + member: new StorageTable({ + columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'], name: 'member_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + }); + const ops = await renameOps( + contractOf( + 'userProfile', + { foreignKeys: (tableName) => [accountForeignKey(tableName)] }, + 'from', + memberTable, + ), + contractOf( + 'UserProfile', + { + foreignKeys: (tableName) => [ + { + source: reference(tableName, ['accountId']), + target: reference('member', ['id']), + name: 'profile_member_fk', + }, + ], + }, + 'to', + memberTable, + ), + ); + + expect(ops.map((op) => op.label)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + ]); + }); + + it('renames a primary key whose columns the end contract changes to the name derived from the new table', async () => { + expect( + await renameLabels( + { primaryKey: { columns: ['id'] } }, + { primaryKey: { columns: ['id', 'email'], name: 'profile_pk' } }, + ), + ).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + ]); + }); + + it('leaves explicitly named objects alone', async () => { + const spec: ProfileSpec = { + primaryKey: { columns: ['id'], name: 'profile_pk' }, + uniques: [{ columns: ['email'], name: 'profile_email_unique' }], + foreignKeys: (tableName) => [{ ...accountForeignKey(tableName), name: 'profile_account_fk' }], + indexes: (tableName) => [ + { ...handleIndex(tableName), naming: { kind: 'exact', name: 'profile_handle' } }, + ], + }; + + expect(await renameLabels(spec)).toEqual(['Rename table "userProfile" to "UserProfile"']); + }); + + it('leaves a foreign key on another table that references the renamed table alone', async () => { + const ops = await renameOps( + contractOf('userProfile', {}, 'from', (tableName) => ({ post: postTable(tableName) })), + contractOf('UserProfile', {}, 'to', (tableName) => ({ post: postTable(tableName) })), + ); + + expect(ops.map((op) => op.label)).toEqual(['Rename table "userProfile" to "UserProfile"']); + }); + + it('emits the rename alone for a table with row-level security and a policy, which the rename carries', async () => { + const ops = await renameOps( + rlsContract('userProfile', 'from'), + rlsContract('UserProfile', 'to'), + ); + + expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); + }); + + it('renames in the schema that declares the table', async () => { + const ops = await renameOps( + contractOf('userProfile', {}, 'from', () => ({}), 'auth'), + contractOf('UserProfile', {}, 'to', () => ({}), 'auth'), + { schema: 'auth', ...RENAME }, + ); + + expect(ops.map((op) => op.execute.map((step) => step.sql))).toEqual([ + ['ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"'], + ]); + }); + + it('refuses a table the start contract does not have', async () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { table: 'ghost', to: 'UserProfile' }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "ghost" does not exist in the start contract'), + }), + ); + }); + + it('refuses a new name the end contract does not have', async () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { table: 'userProfile', to: 'Profile' }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "Profile" does not exist in the end contract'), + }), + ); + }); + + it('refuses a new name the start contract already has', async () => { + const withBoth = contractOf('userProfile', {}, 'from', () => ({ + UserProfile: new StorageTable({ + columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'], name: 'other_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + })); + expect( + () => renameMigration(withBoth, contractOf('UserProfile', {}, 'to'), RENAME).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining( + 'table "UserProfile" already exists in the start contract', + ), + }), + ); + }); + + it('refuses in a migration without a start contract', async () => { + expect( + () => renameMigration(null, contractOf('UserProfile', {}, 'to'), RENAME).operations, + ).toThrow(expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' })); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts index ac9a252a7e82..6cfc5222ed2b 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts @@ -1,4 +1,4 @@ -import { type Contract, type ControlPolicy, coreHash, profileHash } from '@internal/contract/types'; +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { @@ -28,7 +28,6 @@ export interface ProfileSpec { readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; readonly indexes?: (tableName: string) => readonly IndexInput[]; readonly checks?: (tableName: string) => readonly CheckConstraintInput[]; - readonly control?: ControlPolicy; } function profileTable(tableName: string, spec: ProfileSpec): StorageTable { @@ -39,7 +38,6 @@ function profileTable(tableName: string, spec: ProfileSpec): StorageTable { indexes: spec.indexes?.(tableName) ?? [], foreignKeys: spec.foreignKeys?.(tableName) ?? [], checks: spec.checks?.(tableName) ?? [], - ...(spec.control === undefined ? {} : { control: spec.control }), }); } diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts index 52f4b413ea22..3755072900b6 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts @@ -111,13 +111,13 @@ describe('RenameTableCall (postgres)', () => { }); }); - it('renderTypeScript() emits the facade call, schema-qualified only when bound', () => { + it('renderTypeScript() spreads the facade call, which returns every rename, schema-qualified only when bound', () => { expect(new RenameTableCall('auth', 'userProfile', 'UserProfile').renderTypeScript()).toBe( - 'this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', + '...this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', ); expect( new RenameTableCall(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile').renderTypeScript(), - ).toBe('this.renameTable({ table: "userProfile", to: "UserProfile" })'); + ).toBe('...this.renameTable({ table: "userProfile", to: "UserProfile" })'); }); it('needs no facade import because the call is a method on the migration', () => { diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts deleted file mode 100644 index b8974f222327..000000000000 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.rls.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * `--rename` on a row-level-security table: the Postgres `rls` marker and every policy name the table they attach to, so a rename moves them with the table. `ALTER TABLE ... RENAME TO` keeps RLS enabled and the policies attached, and policy names do not derive from the table name, so the plan is the rename alone. - */ - -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import type { StorageEntityRename } from '@internal/framework-components/control'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; -import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; -import { applicationDomainOf } from '@repo/test-utils'; -import { describe, expect, it } from 'vitest'; -import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; -import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; -import { PostgresRlsEnablement } from '../../src/core/postgres-rls-enablement'; -import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; -import { PostgresSchema } from '../../src/core/postgres-schema'; - -const stubLowerer: ExecuteRequestLowerer = { - lower: () => ({ sql: 'stub', params: [] }), - lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), -}; - -const RENAME: StorageEntityRename = { - from: { name: 'userProfile' }, - to: { name: 'UserProfile' }, -}; - -function rlsContract(tableName: string, hashSeed: string): Contract { - const policy = new PostgresRlsPolicy({ - naming: { kind: 'wire', prefix: 'tenant_read', hash: 'f8d5e783' }, - tableName, - namespaceId: 'public', - operation: 'select', - roles: ['app_user'], - using: '(tenant_id = 1)', - withCheck: undefined, - permissive: true, - }); - return { - target: 'postgres', - targetFamily: 'sql', - profileHash: profileHash(hashSeed), - storage: new SqlStorage({ - storageHash: coreHash(hashSeed), - namespaces: { - public: new PostgresSchema({ - id: 'public', - entries: { - table: { - [tableName]: new StorageTable({ - columns: { - id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, - tenant_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, - }, - primaryKey: { columns: ['id'], name: `${tableName}_pkey` }, - foreignKeys: [], - uniques: [], - indexes: [], - }), - }, - policy: { [policy.name]: policy }, - rls: { [tableName]: new PostgresRlsEnablement({ tableName, namespaceId: 'public' }) }, - }, - }), - }, - }), - roots: {}, - domain: applicationDomainOf({ models: {} }), - capabilities: {}, - extensions: {}, - meta: {}, - }; -} - -function plan(from: Contract, to: Contract) { - return createPostgresMigrationPlanner(stubLowerer).plan({ - contract: to, - schema: postgresContractToSchema(from, []), - policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, - fromContract: from, - frameworkComponents: [], - spaceId: APP_SPACE_ID, - snapshotsImportPath: '../../snapshots', - renames: [RENAME], - }); -} - -describe('Postgres planner rename-table intents on an RLS table', () => { - it('plans the rename alone for a table with RLS enabled and a policy', async () => { - const result = plan(rlsContract('userProfile', 'from'), rlsContract('UserProfile', 'to')); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - const ops = await Promise.all(result.plan.operations); - expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); - }); -}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts deleted file mode 100644 index d9d816b340a7..000000000000 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.table-objects.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * `--rename` on a Postgres table that carries indexes, constraints and foreign keys. - * - * Wire-named indexes and checks whose prefix derives from the table name pair by content hash and plan as renames. Primary keys, uniques and foreign keys the contract leaves unnamed were named by the planner from the old table name, so each gets a companion constraint rename to the name the planner now derives from the new table name. Explicitly named constraints keep their names, and foreign keys on other tables keep theirs. - */ - -import type { Contract } from '@internal/contract/types'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; -import type { - CheckConstraintInput, - ForeignKeyInput, - IndexInput, - SqlStorage, -} from '@internal/sql-contract/types'; -import { computeCheckContentHash, computeIndexContentHash } from '@internal/sql-schema-ir/naming'; -import { describe, expect, it } from 'vitest'; -import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; -import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; -import { - contractOf, - NICKNAME_CHECK, - type ProfileSpec, - postTable, - reference, - stubLowerer, -} from './rename-table-fixtures'; - -async function plannedOps(from: Contract, to: Contract) { - const result = createPostgresMigrationPlanner(stubLowerer).plan({ - contract: to, - schema: postgresContractToSchema(from, []), - policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, - fromContract: from, - frameworkComponents: [], - spaceId: APP_SPACE_ID, - snapshotsImportPath: '../../snapshots', - renames: [{ from: { name: 'userProfile' }, to: { name: 'UserProfile' } }], - }); - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return []; - return Promise.all(result.plan.operations); -} - -async function plannedSql( - from: Contract, - to: Contract, -): Promise { - return (await plannedOps(from, to)).map((op) => op.execute.map((step) => step.sql)); -} - -async function planRename(spec: ProfileSpec, nextSpec: ProfileSpec = spec) { - return plannedSql( - contractOf('userProfile', spec, 'from'), - contractOf('UserProfile', nextSpec, 'to'), - ); -} - -const RENAME_TABLE = ['ALTER TABLE "userProfile" RENAME TO "UserProfile"']; - -describe('Postgres planner rename-table intents with table objects', () => { - describe('wire-named indexes and checks', () => { - it('renames an index whose prefix derives from the table name', async () => { - const hash = computeIndexContentHash({ columns: ['handle'], unique: false }); - const handleIndex = (tableName: string): IndexInput => ({ - columns: ['handle'], - naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash }, - where: undefined, - unique: false, - type: undefined, - options: undefined, - }); - - const ops = await plannedOps( - contractOf('userProfile', { indexes: (t) => [handleIndex(t)] }, 'from'), - contractOf('UserProfile', { indexes: (t) => [handleIndex(t)] }, 'to'), - ); - - expect(ops.map((op) => op.label)).toEqual([ - 'Rename table "userProfile" to "UserProfile"', - `Rename index "userProfile_handle_idx_${hash}" to "UserProfile_handle_idx_${hash}" on "UserProfile"`, - ]); - }); - - it('renames a check constraint whose prefix derives from the table name', async () => { - const hash = computeCheckContentHash(NICKNAME_CHECK); - const nicknameCheck = (tableName: string): CheckConstraintInput => ({ - naming: { kind: 'wire', prefix: `${tableName}_nickname_check`, hash }, - expression: NICKNAME_CHECK, - }); - - expect(await planRename({ checks: (t) => [nicknameCheck(t)] })).toEqual([ - RENAME_TABLE, - [ - `ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_nickname_check_${hash}" TO "UserProfile_nickname_check_${hash}"`, - ], - ]); - }); - - it('plans nothing for a foreign key on another table that references the renamed table', async () => { - expect( - await plannedSql( - contractOf('userProfile', {}, 'from', (t) => ({ post: postTable(t) })), - contractOf('UserProfile', {}, 'to', (t) => ({ post: postTable(t) })), - ), - ).toEqual([RENAME_TABLE]); - }); - }); - - describe('constraints the contract leaves unnamed', () => { - it('renames the primary key the planner named after the old table', async () => { - expect(await planRename({ primaryKey: { columns: ['id'] } })).toEqual([ - RENAME_TABLE, - ['ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"'], - ]); - }); - - it('renames an unnamed unique constraint', async () => { - expect(await planRename({ uniques: [{ columns: ['email'] }] })).toEqual([ - RENAME_TABLE, - [ - 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_email_key" TO "UserProfile_email_key"', - ], - ]); - }); - - it('renames an unnamed foreign key the renamed table owns', async () => { - const accountFk = (tableName: string): ForeignKeyInput => ({ - source: reference(tableName, ['accountId']), - target: reference('account', ['id']), - }); - - expect(await planRename({ foreignKeys: (t) => [accountFk(t)] })).toEqual([ - RENAME_TABLE, - [ - 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_accountId_fkey" TO "UserProfile_accountId_fkey"', - ], - ]); - }); - - it('renames the unique first, so dropping it in the same plan uses the new name', async () => { - expect(await planRename({ uniques: [{ columns: ['email'] }] }, {})).toEqual([ - RENAME_TABLE, - [ - 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_email_key" TO "UserProfile_email_key"', - ], - ['ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_email_key"'], - ]); - }); - }); - - describe('constraints with explicit names', () => { - it('keeps explicitly named primary keys, uniques and foreign keys', async () => { - const spec: ProfileSpec = { - primaryKey: { columns: ['id'], name: 'profile_pk' }, - uniques: [{ columns: ['email'], name: 'profile_email_unique' }], - foreignKeys: (tableName) => [ - { - source: reference(tableName, ['accountId']), - target: reference('account', ['id']), - name: 'profile_account_fk', - }, - ], - }; - - expect(await planRename(spec)).toEqual([RENAME_TABLE]); - }); - - it('plans nothing more for an unnamed unique the next contract names with its current name', async () => { - expect( - await planRename( - { uniques: [{ columns: ['email'] }] }, - { uniques: [{ columns: ['email'], name: 'userProfile_email_key' }] }, - ), - ).toEqual([RENAME_TABLE]); - }); - - it('renames unnamed constraints to the explicit names the next contract gives them', async () => { - expect( - await planRename( - { - primaryKey: { columns: ['id'] }, - uniques: [{ columns: ['email'] }], - foreignKeys: (tableName) => [ - { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, - ], - }, - { - primaryKey: { columns: ['id'], name: 'profile_pk' }, - uniques: [{ columns: ['email'], name: 'profile_email_unique' }], - foreignKeys: (tableName) => [ - { - source: reference(tableName, ['accountId']), - target: reference('account', ['id']), - name: 'profile_account_fk', - }, - ], - }, - ), - ).toEqual([ - RENAME_TABLE, - ['ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "profile_pk"'], - [ - 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_email_key" TO "profile_email_unique"', - ], - [ - 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_accountId_fkey" TO "profile_account_fk"', - ], - ]); - }); - }); - - describe('constraints the next contract changes as well as names', () => { - it('renames the foreign key to the new table name, then replaces it when its target changes', async () => { - const memberTable = () => ({ member: postTable('account') }); - expect( - await plannedSql( - contractOf( - 'userProfile', - { - foreignKeys: (tableName) => [ - { - source: reference(tableName, ['accountId']), - target: reference('account', ['id']), - }, - ], - }, - 'from', - memberTable, - ), - contractOf( - 'UserProfile', - { - foreignKeys: (tableName) => [ - { - source: reference(tableName, ['accountId']), - target: reference('member', ['id']), - name: 'profile_member_fk', - }, - ], - }, - 'to', - memberTable, - ), - ), - ).toEqual([ - RENAME_TABLE, - [ - 'ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_accountId_fkey" TO "UserProfile_accountId_fkey"', - ], - ['ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_accountId_fkey"'], - [ - 'ALTER TABLE "UserProfile"\nADD CONSTRAINT "profile_member_fk"\nFOREIGN KEY ("accountId")\nREFERENCES "member" ("id")', - ], - ]); - }); - }); -}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts deleted file mode 100644 index 34a22c763ab0..000000000000 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-planner.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * `--rename` intents on the Postgres planner: the stated tables are - * renamed in the previous contract before the diff, one `renameTable` op is - * prepended per intent, and an intent that matches neither side is a - * planning failure rather than a warning. - */ - -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import type { - MigrationOperationClass, - StorageEntityRename, -} from '@internal/framework-components/control'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; -import { keepInternalSpecifiers } from '@internal/framework-components/emission'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; -import { applicationDomainOf } from '@repo/test-utils'; -import { describe, expect, it } from 'vitest'; -import { postgresResolveDefault } from '../../src/core/default-normalizer'; -import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; -import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; -import { type PostgresContract, postgresCreateNamespace } from '../../src/core/postgres-schema'; -import { postgresRenderDefault } from '../../src/exports/control'; - -const stubLowerer: ExecuteRequestLowerer = { - lower: () => ({ sql: 'stub', params: [] }), - lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), -}; - -const ALL_CLASSES = ['additive', 'widening', 'destructive'] as const; -const ADDITIVE_ONLY = ['additive'] as const; -const FROM_HASH = 'a'.repeat(64); -const TO_HASH = 'b'.repeat(64); - -interface TableSpec { - readonly extraColumn?: string; -} - -function storageTable(spec: TableSpec, tableName: string): StorageTable { - return new StorageTable({ - columns: { - id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, - email: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, - ...(spec.extraColumn === undefined - ? {} - : { [spec.extraColumn]: { nativeType: 'text', codecId: 'pg/text@1', nullable: true } }), - }, - primaryKey: { columns: ['id'], name: `${tableName}_pkey` }, - foreignKeys: [], - uniques: [], - indexes: [], - }); -} - -function contractOf( - tables: Readonly>, - hashSeed: string, - namespaceId: string = UNBOUND_NAMESPACE_ID, -): PostgresContract { - return { - target: 'postgres', - targetFamily: 'sql', - profileHash: profileHash(hashSeed), - storage: new SqlStorage({ - storageHash: coreHash(hashSeed), - namespaces: { - [namespaceId]: postgresCreateNamespace({ - id: namespaceId, - entries: { - table: Object.fromEntries( - Object.entries(tables).map(([name, spec]) => [name, storageTable(spec, name)]), - ), - policy: {}, - }, - }), - }, - }), - roots: {}, - domain: applicationDomainOf({ models: {} }), - capabilities: {}, - extensions: {}, - meta: {}, - }; -} - -function schemaOf(contract: Contract | null) { - return contractToPostgresDatabaseSchemaNode( - contract === null ? null : { ...contract, target: 'postgres' as const }, - { - annotationNamespace: 'pg', - renderDefault: postgresRenderDefault, - resolveDefault: postgresResolveDefault, - }, - ); -} - -function plan(input: { - readonly from: PostgresContract | null; - readonly to: PostgresContract; - readonly renames?: readonly StorageEntityRename[]; - readonly policy?: readonly MigrationOperationClass[]; -}) { - const planner = createPostgresMigrationPlanner(stubLowerer); - return planner.plan({ - contract: input.to, - schema: schemaOf(input.from), - policy: { allowedOperationClasses: [...(input.policy ?? ALL_CLASSES)] }, - fromContract: input.from, - frameworkComponents: [], - spaceId: APP_SPACE_ID, - snapshotsImportPath: '../../snapshots', - ...(input.renames === undefined ? {} : { renames: input.renames }), - }); -} - -const rename = (from: string, to: string): StorageEntityRename => ({ - from: { name: from }, - to: { name: to }, -}); - -describe('Postgres planner rename-table intents', () => { - it('plans exactly one rename op when the tables are otherwise identical', async () => { - const from = contractOf({ userProfile: {} }, FROM_HASH); - const to = contractOf({ UserProfile: {} }, TO_HASH); - - const result = plan({ from, to, renames: [rename('userProfile', 'UserProfile')] }); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - const ops = await Promise.all(result.plan.operations); - expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); - expect(ops[0]?.execute.map((step) => step.sql)).toEqual([ - 'ALTER TABLE "userProfile" RENAME TO "UserProfile"', - ]); - expect(ops[0]?.operationClass).toBe('widening'); - expect(result.plan.describe()).toEqual({ - from: from.storage.storageHash, - to: to.storage.storageHash, - }); - }); - - it('renders the rename as a facade call in the migration file', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - expect(result.plan.renderTypeScript(keepInternalSpecifiers)).toContain( - 'this.renameTable({ table: "userProfile", to: "UserProfile" })', - ); - }); - - it('plans the rename first, then the added column on the new name', async () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: { extraColumn: 'nickname' } }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - const ids = (await Promise.all(result.plan.operations)).map((op) => op.id); - expect(ids).toHaveLength(2); - expect(ids[0]).toBe('renameTable.userProfile'); - expect(ids[1]).toMatch(/^column\..*UserProfile\.nickname$/); - }); - - it('qualifies the statement with the schema when the intent names a bound namespace', async () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH, 'auth'), - to: contractOf({ UserProfile: {} }, TO_HASH, 'auth'), - renames: [ - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - ], - }); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - const ops = await Promise.all(result.plan.operations); - expect(ops.map((op) => op.execute.map((step) => step.sql))).toEqual([ - ['ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"'], - ]); - }); - - it('applies several intents, one rename op each, in the order given', async () => { - const result = plan({ - from: contractOf({ userProfile: {}, orderLine: {} }, FROM_HASH), - to: contractOf({ UserProfile: {}, OrderLine: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile'), rename('orderLine', 'OrderLine')], - }); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - const ids = (await Promise.all(result.plan.operations)).map((op) => op.id); - expect(ids).toEqual(['renameTable.userProfile', 'renameTable.orderLine']); - }); - - it('fails when the intent names a previous table that does not exist', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('ghost', 'UserProfile')], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts).toEqual([ - expect.objectContaining({ - kind: 'tableRenameUnmatched', - summary: expect.stringContaining('"ghost"'), - meta: expect.objectContaining({ - code: 'MIGRATION.TABLE_RENAME_UNMATCHED', - from: 'ghost', - to: 'UserProfile', - }), - }), - ]); - }); - - it('fails when the new name already exists in the previous state', () => { - const result = plan({ - from: contractOf({ userProfile: {}, UserProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); - expect(result.conflicts[0]?.summary).toContain('already exists'); - }); - - it('fails when the new name is absent from the next contract', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ Account: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); - expect(result.conflicts[0]?.summary).toContain('"UserProfile"'); - }); - - it('fails when intents are given without a prior contract to apply them to', () => { - const result = plan({ - from: null, - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['unsupportedOperation']); - }); - - it('still fires the case-change guard when the pair is not covered by an intent', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableNameCaseChanged']); - }); - - it('refuses the rename under an additive-only policy like any other widening op', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - policy: ADDITIVE_ONLY, - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.summary)).toEqual([ - expect.stringContaining('requires class "widening"'), - ]); - }); -}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts deleted file mode 100644 index 6be395df43c1..000000000000 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-scaffold.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * `migration new --rename` on Postgres: the scaffold carries the same rename operations `migration plan` plans for the stated renames, and refuses the intents `migration plan` refuses. - */ - -import type { Contract } from '@internal/contract/types'; -import type { - MigrationPlanWithAuthoringSurface, - StorageEntityRename, -} from '@internal/framework-components/control'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; -import { keepInternalSpecifiers } from '@internal/framework-components/emission'; -import type { IndexInput, SqlStorage } from '@internal/sql-contract/types'; -import { computeIndexContentHash } from '@internal/sql-schema-ir/naming'; -import { describe, expect, it } from 'vitest'; -import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; -import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; -import { contractOf, type ProfileSpec, reference, stubLowerer } from './rename-table-fixtures'; - -const FROM_HASH = 'a'.repeat(64); -const TO_HASH = 'b'.repeat(64); -const RENAME: StorageEntityRename = { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }; -const HANDLE_HASH = computeIndexContentHash({ columns: ['handle'], unique: false }); - -const withObjects: ProfileSpec = { - primaryKey: { columns: ['id'] }, - uniques: [{ columns: ['email'] }], - foreignKeys: (tableName) => [ - { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, - ], - indexes: (tableName): readonly IndexInput[] => [ - { - columns: ['handle'], - naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash: HANDLE_HASH }, - where: undefined, - unique: false, - type: undefined, - options: undefined, - }, - ], -}; - -function scaffold(input: { - readonly from: Contract | null; - readonly to: Contract; - readonly intents?: readonly StorageEntityRename[]; -}) { - return createPostgresMigrationPlanner(stubLowerer).emptyMigration( - { - packageDir: '/tmp/migration-pkg', - fromHash: input.from?.storage.storageHash ?? null, - toHash: input.to.storage.storageHash, - snapshotsImportPath: '../../snapshots', - renames: { - intents: input.intents ?? [RENAME], - fromContract: input.from, - toContract: input.to, - frameworkComponents: [], - }, - }, - APP_SPACE_ID, - ); -} - -function plan(from: Contract, to: Contract) { - return createPostgresMigrationPlanner(stubLowerer).plan({ - contract: to, - schema: postgresContractToSchema(from, []), - policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, - fromContract: from, - frameworkComponents: [], - spaceId: APP_SPACE_ID, - snapshotsImportPath: '../../snapshots', - renames: [RENAME], - }); -} - -async function labelsOf(plan: MigrationPlanWithAuthoringSurface): Promise { - return (await Promise.all(plan.operations)).map((op) => op.label); -} - -const RENAME_LABELS = [ - 'Rename table "userProfile" to "UserProfile"', - 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', - 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', - 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', - `Rename index "userProfile_handle_idx_${HANDLE_HASH}" to "UserProfile_handle_idx_${HANDLE_HASH}" on "UserProfile"`, -]; - -describe('Postgres scaffold rename-table intents', () => { - it('carries the same operations migration plan plans for the rename', async () => { - const from = contractOf('userProfile', withObjects, FROM_HASH); - const to = contractOf('UserProfile', withObjects, TO_HASH); - const planned = plan(from, to); - expect(planned.kind).toBe('success'); - if (planned.kind !== 'success') return; - - expect(await labelsOf(scaffold({ from, to }))).toEqual(RENAME_LABELS); - expect(await labelsOf(planned.plan)).toEqual(RENAME_LABELS); - }); - - it('carries only the renames, not the other changes between the contracts', async () => { - const from = contractOf('userProfile', withObjects, FROM_HASH); - const to = contractOf( - 'UserProfile', - { ...withObjects, uniques: [{ columns: ['email'] }, { columns: ['handle'] }] }, - TO_HASH, - ); - - expect(await labelsOf(scaffold({ from, to }))).toEqual(RENAME_LABELS); - }); - - it('renders every rename as a facade call', () => { - const source = scaffold({ - from: contractOf('userProfile', withObjects, FROM_HASH), - to: contractOf('UserProfile', withObjects, TO_HASH), - }).renderTypeScript(keepInternalSpecifiers); - - expect(source).toContain('this.renameTable({ table: "userProfile", to: "UserProfile" })'); - expect(source).toContain( - 'this.renameConstraint({ table: "UserProfile", kind: "unique", from: "userProfile_email_key", to: "UserProfile_email_key" })', - ); - }); - - it('renames in the schema that declares the table', () => { - const source = scaffold({ - from: contractOf('userProfile', {}, FROM_HASH, () => ({}), 'auth'), - to: contractOf('UserProfile', {}, TO_HASH, () => ({}), 'auth'), - }).renderTypeScript(keepInternalSpecifiers); - - expect(source).toContain( - 'this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', - ); - }); - - it('refuses with MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT when there is no previous contract', () => { - expect(() => scaffold({ from: null, to: contractOf('UserProfile', {}, TO_HASH) })).toThrow( - expect.objectContaining({ - code: 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT', - message: - '--rename "userProfile=UserProfile" needs a previous contract to apply the rename to, and this plan starts from an empty database.', - }), - ); - }); - - it('refuses an intent that matches neither contract with MIGRATION.TABLE_RENAME_UNMATCHED', () => { - expect(() => - scaffold({ - from: contractOf('userProfile', {}, FROM_HASH), - to: contractOf('UserProfile', {}, TO_HASH), - intents: [{ from: { name: 'ghost' }, to: { name: 'UserProfile' } }], - }), - ).toThrow( - expect.objectContaining({ - code: 'MIGRATION.TABLE_RENAME_UNMATCHED', - message: - '--rename "ghost=UserProfile" does not match the contracts: table "ghost" does not exist in the previous contract.', - }), - ); - }); - - it.each(['external', 'observed', 'tolerated'] as const)( - 'carries no rename for a %s table, as migration plan plans none', - async (control) => { - const from = contractOf('userProfile', { ...withObjects, control }, FROM_HASH); - const to = contractOf('UserProfile', { ...withObjects, control }, TO_HASH); - const planned = plan(from, to); - expect(planned.kind).toBe('success'); - if (planned.kind !== 'success') return; - - expect(await labelsOf(planned.plan)).toEqual([]); - expect(await labelsOf(scaffold({ from, to }))).toEqual([]); - }, - ); - - it('renames a primary key whose columns change after the new table name, and leaves the change to the author', async () => { - const from = contractOf('userProfile', { primaryKey: { columns: ['id'] } }, FROM_HASH); - const to = contractOf( - 'UserProfile', - { primaryKey: { columns: ['id', 'email'], name: 'profile_pk' } }, - TO_HASH, - ); - - expect(plan(from, to)).toMatchObject({ - kind: 'failure', - conflicts: [ - { kind: 'indexIncompatible', summary: 'database/public/UserProfile/primary-key' }, - ], - }); - expect(await labelsOf(scaffold({ from, to }))).toEqual([ - 'Rename table "userProfile" to "UserProfile"', - 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', - ]); - }); - - it('scaffolds an empty body without renames', () => { - const empty = createPostgresMigrationPlanner(stubLowerer).emptyMigration( - { - packageDir: '/tmp/migration-pkg', - fromHash: null, - toHash: TO_HASH, - snapshotsImportPath: '../../snapshots', - }, - APP_SPACE_ID, - ); - - expect(empty.operations).toEqual([]); - }); -}); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index 4277f5daeeff..569e43fe547e 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -339,7 +339,7 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { } renderTypeScript(): string { - return `this.renameTable({ table: ${jsonToTsSource(this.oldTableName)}, to: ${jsonToTsSource(this.tableName)} })`; + return `...this.renameTable({ table: ${jsonToTsSource(this.oldTableName)}, to: ${jsonToTsSource(this.tableName)} })`; } override importRequirements(): readonly ImportRequirement[] { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 2319296001d7..f5a604fd896d 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -3,50 +3,38 @@ import type { MigrationOperationPolicy, SqlMigrationPlanner, SqlMigrationPlannerPlanOptions, - SqlPlannerConflict, SqlPlannerFailureResult, } from '@internal/family-sql/control'; import { - applyTableRenameIntents, detectTableNameCaseChanges, extractCodecControlHooks, planFieldEventOperations, plannerFailure, - tableRenameScaffoldError, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { MigrationPlanner, MigrationScaffoldContext, - MigrationScaffoldRenames, SchemaDiffIssue, SchemaOwnership, - StorageEntityRename, } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import type { SqlStorage } from '@internal/sql-contract/types'; import { RelationalSchemaNodeKind, type SqlSchemaIR, SqlTableIR, } from '@internal/sql-schema-ir/types'; -import { blindCast } from '@internal/utils/casts'; -import { notOk, ok, type Result } from '@internal/utils/result'; -import { buildSqlitePlanDiff, sqliteContractToSchema } from './diff-database-schema'; -import { - indexNameCaseChange, - pairIndexReplacements, - renamedTableIndex, -} from './index-replacements'; +import { buildSqlitePlanDiff } from './diff-database-schema'; +import { indexNameCaseChange, pairIndexReplacements } from './index-replacements'; import { coalesceSubtreeIssues, conflictForDisallowedCall, issueNode, planIssues, } from './issue-planner'; -import { RenameTableCall, type SqliteOpFactoryCall } from './op-factory-call'; +import { RenameTableCall } from './op-factory-call'; import { renameTableSteps } from './operations/tables'; import { type SqliteMigrationDestinationInfo, @@ -55,20 +43,6 @@ import { import { sqlitePlannerStrategies } from './planner-strategies'; import type { SqlitePlanTargetDetails } from './planner-target-details'; -interface PlannedTableRenames { - readonly fromContract: Contract | null; - readonly previousSchema: SqlMigrationPlannerPlanOptions['schema']; - readonly calls: readonly RenameTableCall[]; -} - -const SCAFFOLD_POLICY: MigrationOperationPolicy = { - allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'], -}; - -function renamedTableNames(calls: readonly RenameTableCall[]): ReadonlySet { - return new Set(calls.map((call) => call.tableName)); -} - export function createSqliteMigrationPlanner( lowerer: ExecuteRequestLowerer, ): SqliteMigrationPlanner { @@ -137,22 +111,16 @@ export class SqliteMigrationPlanner * {@link SqlMigrationPlannerPlanOptions.snapshotsImportPath}. */ readonly snapshotsImportPath: string; - /** See {@link SqlMigrationPlannerPlanOptions.renames}. */ - readonly renames?: readonly StorageEntityRename[]; }): SqlitePlanResult { return this.planSql(options as SqlMigrationPlannerPlanOptions); } emptyMigration( - context: MigrationScaffoldContext<'sql', 'sqlite'>, + context: MigrationScaffoldContext, spaceId: string, ): TypeScriptRenderableSqliteMigration { - const renameCalls = - context.renames === undefined - ? [] - : this.scaffoldTableRenames(context, context.renames, spaceId); return new TypeScriptRenderableSqliteMigration( - renameCalls, + [], { from: context.fromHash, to: context.toHash, @@ -164,116 +132,14 @@ export class SqliteMigrationPlanner ); } - /** - * The rename operations `planSql` plans for the stated renames, and nothing else: the table renames, then the index drops and creates on the renamed tables. Every other difference between the two contracts is left to the author. - */ - private scaffoldTableRenames( - context: MigrationScaffoldContext<'sql', 'sqlite'>, - renames: MigrationScaffoldRenames<'sql', 'sqlite'>, - spaceId: string, - ): readonly SqliteOpFactoryCall[] { - const fromContract = blindCast< - Contract | null, - 'the family resolver only binds the SQLite planner for a SQL contract' - >(renames.fromContract); - const options: SqlMigrationPlannerPlanOptions = { - contract: blindCast< - Contract, - 'the family resolver only binds the SQLite planner for a SQL contract' - >(renames.toContract), - fromContract, - schema: sqliteContractToSchema(fromContract), - policy: SCAFFOLD_POLICY, - frameworkComponents: renames.frameworkComponents, - spaceId, - snapshotsImportPath: context.snapshotsImportPath, - renames: renames.intents, - }; - const planned = this.planTableRenames(options); - if (!planned.ok) { - throw tableRenameScaffoldError(planned.failure); - } - const { issues } = this.collectSchemaIssues({ - ...options, - schema: planned.value.previousSchema, - }); - return [ - ...planned.value.calls, - ...pairIndexReplacements(issues, renamedTableIndex(renamedTableNames(planned.value.calls))) - .calls, - ]; - } - - /** - * Applies the stated renames to the previous contract and plans the table renames. Without renames it hands back the previous contract and schema unchanged and no calls. - */ - private planTableRenames( - options: SqlMigrationPlannerPlanOptions, - ): Result { - const intents = options.renames ?? []; - if (intents.length === 0) { - return ok({ fromContract: options.fromContract, previousSchema: options.schema, calls: [] }); - } - const applied = applyTableRenameIntents({ - fromContract: options.fromContract, - toContract: options.contract, - intents, - renameTableReferences: undefined, - }); - if (!applied.ok) { - return notOk(applied.failure); - } - const calls = applied.value.renames.map( - (rename) => new RenameTableCall(rename.from, rename.to), - ); - const disallowed = calls.filter( - (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), - ); - if (disallowed.length > 0) { - return notOk( - disallowed.map((call) => - conflictForDisallowedCall(call, options.policy.allowedOperationClasses), - ), - ); - } - return ok({ - fromContract: applied.value.contract, - previousSchema: sqliteContractToSchema(applied.value.contract), - calls, - }); - } - private planSql(options: SqlMigrationPlannerPlanOptions): SqlitePlanResult { const policyResult = this.ensureAdditivePolicy(options.policy); if (policyResult) return policyResult; - // Operator-stated renames are applied to the previous contract before - // the diff, and the "from" tree is re-derived from that renamed contract, - // so the differ sees each renamed table under its new name. The renames - // themselves become the first operations of the plan; an intent that - // matches neither side, or a rename the policy's classes do not admit, - // fails the plan rather than degrading to a drop and a create. - const tableRenames = this.planTableRenames(options); - if (!tableRenames.ok) { - return plannerFailure(tableRenames.failure); - } - const { fromContract, previousSchema } = tableRenames.value; - const renameTableCalls = tableRenames.value.calls; + const fromContract = options.fromContract; - const { - expected, - actual, - issues: diffIssues, - } = this.collectSchemaIssues({ - ...options, - schema: previousSchema, - }); - const onRenamedTable = renamedTableIndex(renamedTableNames(renameTableCalls)); - const replacedIndexes = pairIndexReplacements( - diffIssues, - (old, replacement) => - onRenamedTable(old, replacement) || indexNameCaseChange(old, replacement), - ); + const { expected, actual, issues: diffIssues } = this.collectSchemaIssues(options); + const replacedIndexes = pairIndexReplacements(diffIssues, indexNameCaseChange); const disallowedIndexCalls = replacedIndexes.calls.filter( (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), ); @@ -294,6 +160,7 @@ export class SqliteMigrationPlanner namespaceIdOf: () => UNBOUND_NAMESPACE_ID, renameByHandStatements: (rename) => renameTableSteps(rename.from, rename.to).map((renameStep) => renameStep.sql), + renameTableCall: (rename) => new RenameTableCall(rename.from, rename.to).renderTypeScript(), contract: options.contract, defaultNamespaceId: UNBOUND_NAMESPACE_ID, }); @@ -330,12 +197,7 @@ export class SqliteMigrationPlanner // toOp + importRequirements ride directly through the same emit path // as structural ops, no `RawSqlCall` wrap. The table renames run first: // every later operation addresses the renamed table by its new name. - const calls = [ - ...renameTableCalls, - ...replacedIndexes.calls, - ...result.value.calls, - ...fieldEventOps, - ]; + const calls = [...replacedIndexes.calls, ...result.value.calls, ...fieldEventOps]; const destination: SqliteMigrationDestinationInfo = { storageHash: options.contract.storage.storageHash, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts index 5105c14c0996..c3a21ab73ffe 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts @@ -5,6 +5,7 @@ import type { } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import { Migration as SqlMigration } from '@internal/family-sql/migration'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { ControlStack } from '@internal/framework-components/control'; import { MigrationContractViews } from '@internal/migration-tools/migration'; import type { SqlStorage } from '@internal/sql-contract/types'; @@ -20,10 +21,10 @@ import { DropIndexCall, DropTableCall, RecreateTableCall, - RenameTableCall, } from './op-factory-call'; import type { SqliteColumnSpec, SqliteIndexSpec, SqliteTableSpec } from './operations/shared'; import type { SqlitePlanTargetDetails } from './planner-target-details'; +import { sqliteTableRenameCalls } from './table-rename-calls'; type Op = SqlMigrationPlanOperation; @@ -82,6 +83,16 @@ export abstract class SqliteMigration< : undefined; } + private frameworkComponents(): ReadonlyArray> { + const stack = this.stack; + if (stack === undefined) return []; + return [ + stack.target, + ...(stack.adapter === undefined ? [] : [stack.adapter]), + ...stack.extensions, + ]; + } + /** * Returns the materialized control adapter, or throws a MIGRATION.SQLITE_CONTROL_STACK_MISSING naming * `operation` when the migration was constructed without a `ControlStack`. @@ -126,10 +137,20 @@ export abstract class SqliteMigration< return new DropTableCall(options.table).toOp(this.controlAdapterFor('dropTable')); } - protected renameTable(options: { readonly table: string; readonly to: string }): Promise { - return new RenameTableCall(options.table, options.to).toOp( - this.controlAdapterFor('renameTable'), - ); + /** + * Emit the operations that rename a table: the table rename, then a drop and a create under the new name of each index whose name was derived from the old table name, read from this migration's start and end contracts. Spread the result into `operations`: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + */ + protected renameTable(options: { + readonly table: string; + readonly to: string; + }): readonly Promise[] { + const adapter = this.controlAdapterFor('renameTable'); + return sqliteTableRenameCalls({ + startContract: this.startContract, + endContract: this.endContract, + rename: { namespaceId: undefined, from: options.table, to: options.to }, + frameworkComponents: this.frameworkComponents(), + }).map(async (call) => call.toOp(adapter)); } protected addColumn(options: { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts new file mode 100644 index 000000000000..a420a123a5cf --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts @@ -0,0 +1,39 @@ +import type { Contract } from '@internal/contract/types'; +import { applyTableRename, type TableRename } from '@internal/family-sql/control'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { buildSqlitePlanDiff, sqliteContractToSchema } from './diff-database-schema'; +import { pairIndexReplacements, renamedTableIndex } from './index-replacements'; +import { coalesceSubtreeIssues } from './issue-planner'; +import { RenameTableCall, type SqliteOpFactoryCall } from './op-factory-call'; + +/** + * The calls a migration's `renameTable` emits: the table rename, then a drop of each wire-named index whose prefix derives from the old table name and a create of it under the new name, since SQLite cannot rename an index. SQLite names no primary key, unique constraint or foreign key the contract leaves unnamed, so those need nothing. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + */ +export function sqliteTableRenameCalls(input: { + readonly startContract: Contract | null; + readonly endContract: Contract; + readonly rename: TableRename; + readonly frameworkComponents: ReadonlyArray>; +}): readonly SqliteOpFactoryCall[] { + const applied = applyTableRename({ + startContract: input.startContract, + endContract: input.endContract, + rename: input.rename, + renameTableReferences: undefined, + }); + if (!applied.ok) { + throw applied.failure; + } + const { rename } = applied.value; + const { issues } = buildSqlitePlanDiff({ + contract: input.endContract, + actualSchema: sqliteContractToSchema(applied.value.contract), + frameworkComponents: input.frameworkComponents, + }); + return [ + new RenameTableCall(rename.from, rename.to), + ...pairIndexReplacements(coalesceSubtreeIssues(issues), renamedTableIndex(new Set([rename.to]))) + .calls, + ]; +} diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts new file mode 100644 index 000000000000..23bb64a2756c --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts @@ -0,0 +1,157 @@ +/** + * `this.renameTable` in a hand-written SQLite migration. It reads the migration's start and end contracts and emits the table rename, then drops each index whose wire name derives from the old table name and creates it under the new name, because SQLite cannot rename an index. SQLite names no primary key, unique constraint or foreign key the contract leaves unnamed, so those need nothing. A table missing from either contract is refused. + */ + +import type { Contract } from '@internal/contract/types'; +import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; +import type { ControlStack } from '@internal/framework-components/control'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import type { SqlitePlanTargetDetails } from '../../src/core/migrations/planner-target-details'; +import { SqliteMigration } from '../../src/core/migrations/sqlite-migration'; +import { SqliteContractSerializer } from '../../src/core/sqlite-contract-serializer'; +import { + contractOf, + HANDLE_INDEX_HASH, + handleIndex, + type ProfileSpec, + reference, + stubLowerer, +} from './rename-table-fixtures'; + +type Op = SqlMigrationPlanOperation; +type ContractJson = { readonly storage: { readonly storageHash: string } }; + +const stack = { + adapter: { create: () => stubLowerer as unknown as SqlControlAdapter<'sqlite'> }, + target: { kind: 'target', familyId: 'sql', targetId: 'sqlite' }, + extensions: [], +} as unknown as ControlStack<'sql', 'sqlite'>; + +function jsonOf(contract: Contract): ContractJson { + return new SqliteContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +function renameMigration( + start: Contract | null, + end: Contract, + rename: { readonly table: string; readonly to: string }, +): { readonly operations: readonly Promise[] } { + const endJson = jsonOf(end); + class WithoutStart extends SqliteMigration { + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return [...this.renameTable(rename)]; + } + } + if (start === null) return new WithoutStart(stack); + const startJson = jsonOf(start); + class WithStart extends WithoutStart { + override readonly startContractJson = startJson; + } + return new WithStart(stack); +} + +const RENAME = { table: 'userProfile', to: 'UserProfile' } as const; + +async function renameLabels(spec: ProfileSpec): Promise { + const ops = await Promise.all( + renameMigration( + contractOf('userProfile', spec, 'from'), + contractOf('UserProfile', spec, 'to'), + RENAME, + ).operations, + ); + return ops.map((op) => op.label); +} + +describe('SqliteMigration.renameTable', () => { + it('drops an index whose prefix derives from the table name, then creates it under the new name', async () => { + expect(await renameLabels({ indexes: (tableName) => [handleIndex(tableName)] })).toEqual([ + 'Rename table userProfile to UserProfile', + `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); + + it('emits the rename alone for an unnamed unique, an owned foreign key and a referencing foreign key', async () => { + expect( + await renameLabels({ + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [ + { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, + ], + }), + ).toEqual(['Rename table userProfile to UserProfile']); + }); + + it('leaves an explicitly named index alone', async () => { + expect( + await renameLabels({ + indexes: (tableName) => [ + { ...handleIndex(tableName), naming: { kind: 'exact', name: 'profile_handle' } }, + ], + }), + ).toEqual(['Rename table userProfile to UserProfile']); + }); + + it('renames through a temporary name when only the case changes', async () => { + const [rename] = await Promise.all( + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + RENAME, + ).operations, + ); + + expect(rename?.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"', + 'ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"', + ]); + }); + + it('refuses a table the start contract does not have', () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { + table: 'ghost', + to: 'UserProfile', + }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "ghost" does not exist in the start contract'), + }), + ); + }); + + it('refuses a new name the end contract does not have', () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { + table: 'userProfile', + to: 'Profile', + }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "Profile" does not exist in the end contract'), + }), + ); + }); + + it('refuses in a migration without a start contract', () => { + expect( + () => renameMigration(null, contractOf('UserProfile', {}, 'to'), RENAME).operations, + ).toThrow(expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' })); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts index 082d331dcd66..42eddfe8ddd8 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts @@ -121,9 +121,9 @@ describe('RenameTableCall (sqlite)', () => { }); }); - it('renderTypeScript() emits the facade call', () => { + it('renderTypeScript() spreads the facade call, which returns every rename', () => { expect(new RenameTableCall('userProfile', 'UserProfile').renderTypeScript()).toBe( - 'this.renameTable({ table: "userProfile", to: "UserProfile" })', + '...this.renameTable({ table: "userProfile", to: "UserProfile" })', ); }); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts deleted file mode 100644 index 8e669fca25b4..000000000000 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.table-objects.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * `--rename` on a SQLite table that carries indexes, constraints and foreign keys. - * - * SQLite renames a table's automatic indexes and rewrites foreign keys that reference it, and it names no primary key, unique or foreign key the contract leaves unnamed, so those need nothing beyond the rename. A wire-named index whose prefix derives from the table name keeps its old name through `ALTER TABLE ... RENAME TO`, and SQLite cannot rename an index, so it is dropped and created under the new name; an index holds no rows, so nothing is lost. - */ - -import { APP_SPACE_ID } from '@internal/framework-components/control'; -import { describe, expect, it } from 'vitest'; -import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; -import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; -import { - contractOf, - HANDLE_INDEX_HASH, - handleIndex, - type ProfileSpec, - reference, - stubLowerer, -} from './rename-table-fixtures'; - -async function plannedLabels(spec: ProfileSpec): Promise { - const from = contractOf('userProfile', spec, 'from'); - const result = createSqliteMigrationPlanner(stubLowerer).plan({ - contract: contractOf('UserProfile', spec, 'to'), - schema: sqliteContractToSchema(from), - policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, - fromContract: from, - frameworkComponents: [], - spaceId: APP_SPACE_ID, - snapshotsImportPath: '../../snapshots', - renames: [{ from: { name: 'userProfile' }, to: { name: 'UserProfile' } }], - }); - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return []; - return (await Promise.all(result.plan.operations)).map((op) => op.label); -} - -const RENAME_TABLE = 'Rename table userProfile to UserProfile'; - -describe('SQLite planner rename-table intents with table objects', () => { - it('drops an index whose prefix derives from the table name before creating it under the new name', async () => { - expect(await plannedLabels({ indexes: (t) => [handleIndex(t)] })).toEqual([ - RENAME_TABLE, - `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, - `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, - ]); - }); - - it('plans the rename alone for an unnamed unique, an owned foreign key and a referencing foreign key', async () => { - expect( - await plannedLabels({ - uniques: [{ columns: ['email'] }], - foreignKeys: (tableName) => [ - { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, - ], - }), - ).toEqual([RENAME_TABLE]); - }); -}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts deleted file mode 100644 index 9fce44990f0b..000000000000 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-planner.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * `--rename` intents on the SQLite planner: the stated tables are - * renamed in the previous contract before the diff, one `renameTable` op is - * prepended per intent, and an intent that matches neither side is a - * planning failure rather than a warning. - */ - -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import type { - MigrationOperationClass, - StorageEntityRename, -} from '@internal/framework-components/control'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; -import { keepInternalSpecifiers } from '@internal/framework-components/emission'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; -import { applicationDomainOf } from '@repo/test-utils'; -import { describe, expect, it } from 'vitest'; -import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; -import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; -import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; - -const stubLowerer: ExecuteRequestLowerer = { - lower: () => { - throw new Error('lower() called on stubLowerer — planner must use lowerToExecuteRequest()'); - }, - lowerToExecuteRequest: async () => ({ sql: '', params: [] }), -}; - -const ALL_CLASSES = ['additive', 'widening', 'destructive'] as const; -const ADDITIVE_ONLY = ['additive'] as const; -const FROM_HASH = 'a'.repeat(64); -const TO_HASH = 'b'.repeat(64); - -interface TableSpec { - readonly extraColumn?: string; -} - -function storageTable(spec: TableSpec, tableName: string): StorageTable { - return new StorageTable({ - columns: { - id: { nativeType: 'integer', codecId: 'sqlite/integer@1', nullable: false }, - email: { nativeType: 'text', codecId: 'sqlite/text@1', nullable: false }, - ...(spec.extraColumn === undefined - ? {} - : { - [spec.extraColumn]: { nativeType: 'text', codecId: 'sqlite/text@1', nullable: true }, - }), - }, - primaryKey: { columns: ['id'], name: `${tableName}_pkey` }, - foreignKeys: [], - uniques: [], - indexes: [], - }); -} - -function contractOf( - tables: Readonly>, - hashSeed: string, -): Contract { - return { - target: 'sqlite', - targetFamily: 'sql', - profileHash: profileHash(hashSeed), - storage: new SqlStorage({ - storageHash: coreHash(hashSeed), - namespaces: { - [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ - id: UNBOUND_NAMESPACE_ID, - entries: { - table: Object.fromEntries( - Object.entries(tables).map(([name, spec]) => [name, storageTable(spec, name)]), - ), - }, - }), - }, - }), - roots: {}, - domain: applicationDomainOf({ models: {} }), - capabilities: {}, - extensions: {}, - meta: {}, - }; -} - -function plan(input: { - readonly from: Contract | null; - readonly to: Contract; - readonly renames?: readonly StorageEntityRename[]; - readonly policy?: readonly MigrationOperationClass[]; -}) { - const planner = createSqliteMigrationPlanner(stubLowerer); - return planner.plan({ - contract: input.to, - schema: sqliteContractToSchema(input.from), - policy: { allowedOperationClasses: [...(input.policy ?? ALL_CLASSES)] }, - fromContract: input.from, - frameworkComponents: [], - spaceId: APP_SPACE_ID, - snapshotsImportPath: '../../snapshots', - ...(input.renames === undefined ? {} : { renames: input.renames }), - }); -} - -const rename = (from: string, to: string): StorageEntityRename => ({ - from: { name: from }, - to: { name: to }, -}); - -describe('SQLite planner rename-table intents', () => { - it('plans exactly one rename op when the tables are otherwise identical', async () => { - const from = contractOf({ userProfile: {} }, FROM_HASH); - const to = contractOf({ UserProfile: {} }, TO_HASH); - - const result = plan({ from, to, renames: [rename('userProfile', 'UserProfile')] }); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - const ops = await Promise.all(result.plan.operations); - expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); - expect(ops[0]?.execute.map((step) => step.sql)).toEqual([ - 'ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"', - 'ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"', - ]); - expect(ops[0]?.operationClass).toBe('widening'); - expect(result.plan.describe()).toEqual({ - from: from.storage.storageHash, - to: to.storage.storageHash, - }); - }); - - it('renders the rename as a facade call in the migration file', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - expect(result.plan.renderTypeScript(keepInternalSpecifiers)).toContain( - 'this.renameTable({ table: "userProfile", to: "UserProfile" })', - ); - }); - - it('plans the rename first, then the added column on the new name', async () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: { extraColumn: 'nickname' } }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('success'); - if (result.kind !== 'success') return; - const ids = (await Promise.all(result.plan.operations)).map((op) => op.id); - expect(ids).toEqual(['renameTable.userProfile', 'column.UserProfile.nickname']); - }); - - it('fails when the intent names a previous table that does not exist', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('ghost', 'UserProfile')], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts).toEqual([ - expect.objectContaining({ - kind: 'tableRenameUnmatched', - summary: expect.stringContaining('"ghost"'), - meta: expect.objectContaining({ - code: 'MIGRATION.TABLE_RENAME_UNMATCHED', - from: 'ghost', - to: 'UserProfile', - }), - }), - ]); - }); - - it('fails when the new name already exists in the previous state', () => { - const result = plan({ - from: contractOf({ userProfile: {}, UserProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); - expect(result.conflicts[0]?.summary).toContain('already exists'); - }); - - it('fails when the new name is absent from the next contract', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ Account: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); - }); - - it('fails when the intent is qualified with a namespace SQLite does not have', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [ - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - ], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableRenameUnmatched']); - }); - - it('fails when intents are given without a prior contract to apply them to', () => { - const result = plan({ - from: null, - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['unsupportedOperation']); - }); - - it('still fires the case-change guard when the pair is not covered by an intent', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.kind)).toEqual(['tableNameCaseChanged']); - }); - - it('refuses the rename under an additive-only policy like any other widening op', () => { - const result = plan({ - from: contractOf({ userProfile: {} }, FROM_HASH), - to: contractOf({ UserProfile: {} }, TO_HASH), - renames: [rename('userProfile', 'UserProfile')], - policy: ADDITIVE_ONLY, - }); - - expect(result.kind).toBe('failure'); - if (result.kind !== 'failure') return; - expect(result.conflicts.map((conflict) => conflict.summary)).toEqual([ - expect.stringContaining('requires class "widening"'), - ]); - }); -}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts deleted file mode 100644 index 0aeeaea8c255..000000000000 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-scaffold.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * `migration new --rename` on SQLite: the scaffold carries the same rename operations `migration plan` plans for the stated renames, and refuses the intents `migration plan` refuses, including a namespace qualifier SQLite does not have. - */ - -import type { Contract } from '@internal/contract/types'; -import type { - MigrationPlanWithAuthoringSurface, - StorageEntityRename, -} from '@internal/framework-components/control'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; -import { keepInternalSpecifiers } from '@internal/framework-components/emission'; -import type { SqlStorage } from '@internal/sql-contract/types'; -import { describe, expect, it } from 'vitest'; -import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; -import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; -import { - contractOf, - HANDLE_INDEX_HASH, - handleIndex, - type ProfileSpec, - stubLowerer, -} from './rename-table-fixtures'; - -const FROM_HASH = 'a'.repeat(64); -const TO_HASH = 'b'.repeat(64); -const RENAME: StorageEntityRename = { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }; -const withIndex: ProfileSpec = { - uniques: [{ columns: ['email'] }], - indexes: (t) => [handleIndex(t)], -}; - -function scaffold(input: { - readonly from: Contract | null; - readonly to: Contract; - readonly intents?: readonly StorageEntityRename[]; -}) { - return createSqliteMigrationPlanner(stubLowerer).emptyMigration( - { - packageDir: '/tmp/migration-pkg', - fromHash: input.from?.storage.storageHash ?? null, - toHash: input.to.storage.storageHash, - snapshotsImportPath: '../../snapshots', - renames: { - intents: input.intents ?? [RENAME], - fromContract: input.from, - toContract: input.to, - frameworkComponents: [], - }, - }, - APP_SPACE_ID, - ); -} - -async function labelsOf(plan: MigrationPlanWithAuthoringSurface): Promise { - return (await Promise.all(plan.operations)).map((op) => op.label); -} - -const RENAME_LABELS = [ - 'Rename table userProfile to UserProfile', - `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, - `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, -]; - -describe('SQLite scaffold rename-table intents', () => { - it('carries the same operations migration plan plans for the rename', async () => { - const from = contractOf('userProfile', withIndex, FROM_HASH); - const to = contractOf('UserProfile', withIndex, TO_HASH); - const planned = createSqliteMigrationPlanner(stubLowerer).plan({ - contract: to, - schema: sqliteContractToSchema(from), - policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, - fromContract: from, - frameworkComponents: [], - spaceId: APP_SPACE_ID, - snapshotsImportPath: '../../snapshots', - renames: [RENAME], - }); - expect(planned.kind).toBe('success'); - if (planned.kind !== 'success') return; - - expect(await labelsOf(scaffold({ from, to }))).toEqual(RENAME_LABELS); - expect(await labelsOf(planned.plan)).toEqual(RENAME_LABELS); - }); - - it('carries only the renames, not the other changes between the contracts', async () => { - const from = contractOf('userProfile', withIndex, FROM_HASH); - const to = contractOf('UserProfile', { ...withIndex, uniques: [] }, TO_HASH); - - expect(await labelsOf(scaffold({ from, to }))).toEqual(RENAME_LABELS); - }); - - it('renders every rename as a facade call', () => { - const source = scaffold({ - from: contractOf('userProfile', withIndex, FROM_HASH), - to: contractOf('UserProfile', withIndex, TO_HASH), - }).renderTypeScript(keepInternalSpecifiers); - - expect(source).toContain('this.renameTable({ table: "userProfile", to: "UserProfile" })'); - expect(source).toContain(`userProfile_handle_idx_${HANDLE_INDEX_HASH}`); - expect(source).toContain(`UserProfile_handle_idx_${HANDLE_INDEX_HASH}`); - }); - - it('refuses with MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT when there is no previous contract', () => { - expect(() => scaffold({ from: null, to: contractOf('UserProfile', {}, TO_HASH) })).toThrow( - expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT' }), - ); - }); - - it.each([ - [ - 'the old name', - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - ], - [ - 'the new name', - { from: { name: 'userProfile' }, to: { namespaceId: 'auth', name: 'UserProfile' } }, - ], - ])( - 'refuses a namespace qualifier on %s with MIGRATION.TABLE_RENAME_UNMATCHED', - (_side, intent) => { - expect(() => - scaffold({ - from: contractOf('userProfile', {}, FROM_HASH), - to: contractOf('UserProfile', {}, TO_HASH), - intents: [intent], - }), - ).toThrow(expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' })); - }, - ); - - it('scaffolds an empty body without renames', () => { - const empty = createSqliteMigrationPlanner(stubLowerer).emptyMigration( - { - packageDir: '/tmp/migration-pkg', - fromHash: null, - toHash: TO_HASH, - snapshotsImportPath: '../../snapshots', - }, - APP_SPACE_ID, - ); - - expect(empty.operations).toEqual([]); - }); -}); diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index 6532f8b68eac..9c31a99c63fc 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -358,7 +358,7 @@ On Postgres the operations are **methods on the `Migration` base class**, each t **Postgres** operations (representative set, all `this.({...})`): -- Tables: `createTable`, `renameTable`, `dropTable`. +- Tables: `createTable`, `renameTable`, `dropTable`. `renameTable` reads the migration's start and end contracts and returns the table rename plus a rename of every constraint and index named after the old table, so spread it: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. - Columns: `addColumn` (`column: col(name, nativeType, { codecRef })`), `dropColumn`, `alterColumnType`, `setNotNull`, `dropNotNull`, `setDefault`, `dropDefault`. - Constraints: `addPrimaryKey`, `addForeignKey`, `addUnique`, `addCheckConstraint`, `renameConstraint` (`kind`: `primaryKey`, `unique`, `foreignKey` or `checkConstraint`), `renameCheckConstraint`, `dropCheckConstraint`, `dropConstraint`. - Indexes: `createIndex`, `renameIndex`, `dropIndex`. From 60536b0941ddb3c944b29ac31ae33e13e9e33bd3 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:07:13 +0200 Subject: [PATCH 48/55] refactor(migration): remove the --rename flag The documented way to state a rename is a planner hint in the contract source, which is a follow-up. The --rename flag on migration plan and migration new was a second, undocumented mechanism, so it is removed; a rename is stated in a hand-written migration with this.renameTable. Removed with it: the CLI flag parsing and registration and CLI.INVALID_RENAME_FLAG; the renames option on the framework planner and on the scaffold context, with StorageEntityRename and MigrationScaffoldRenames; migration new's scaffolding of rename operations and its structured-error pass-through that only a planner refusal used; the Mongo planner's refusal and MIGRATION.RENAME_UNSUPPORTED; the renames option and tableRenameUnmatched conflict kind on the SQL family planner options; MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT. Their tests, error-reference entries, CLI README lines and migration subsystem synopsis go too, and those files match main again. MIGRATION.TABLE_RENAME_UNMATCHED now describes a renameTable call that does not match the migration's contracts. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 4 +- docs/reference/error-reference.md | 16 +- .../src/control/control-migration-types.ts | 60 +----- .../src/exports/control.ts | 3 - packages/1-framework/3-tooling/cli/README.md | 3 +- .../control-api/operations/migration-new.ts | 94 +++------ .../control-api/operations/migration-plan.ts | 12 -- .../3-tooling/cli/src/orm/migration/new.ts | 11 - .../3-tooling/cli/src/orm/migration/plan.ts | 11 - .../3-tooling/cli/src/utils/rename-flag.ts | 98 --------- .../cli/test/orm/fixtures/offline-project.ts | 8 +- .../test/orm/migration-new-renames.test.ts | 90 -------- .../cli/test/orm/migration-new.test.ts | 26 --- .../test/orm/migration-rename-flag.test.ts | 196 ------------------ .../cli/test/utils/rename-flag.test.ts | 100 --------- .../9-family/src/core/migrations/types.ts | 12 +- .../1-mongo-target/src/core/mongo-planner.ts | 42 ---- .../src/core/mongo-target-errors.ts | 2 +- .../test/mongo-planner.renames.test.ts | 117 ----------- 19 files changed, 41 insertions(+), 864 deletions(-) delete mode 100644 packages/1-framework/3-tooling/cli/src/utils/rename-flag.ts delete mode 100644 packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts delete mode 100644 packages/1-framework/3-tooling/cli/test/orm/migration-rename-flag.test.ts delete mode 100644 packages/1-framework/3-tooling/cli/test/utils/rename-flag.test.ts delete mode 100644 packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index c7a83b16a6d0..7c6dfd9aa593 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -528,8 +528,8 @@ Top-level verbs: Migration namespace (artifacts and graph): -- `prisma migration plan [--from ] [--to ] [--rename =]... --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags accept the full [contract-reference grammar](#refs-environment-targets). `--rename =` (repeatable, either side `.`) states that the storage a model maps to was renamed, so the plan renames it and keeps its data instead of dropping and recreating it. Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. -- `prisma migration new [--from --to ] [--rename =]... --name ` — scaffold an empty `migration.ts` for hand-authoring. With `--rename`, the scaffold starts with the operations `migration plan` would plan for the stated renames. +- `prisma migration plan [--from ] [--to ] --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags accept the full [contract-reference grammar](#refs-environment-targets). Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. +- `prisma migration new [--from --to ] --name ` — scaffold an empty `migration.ts` for hand-authoring. - `prisma migration status [--db ] [--to ] [--from ]` — path/pending question. Live (uses marker) or offline (uses `--from`). - `prisma migration log --db ` — applied execution history (reads the marker; offline reading of the ledger is also supported). - `prisma migration list` — enumerate migrations on disk in topological order. Offline. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index bcb82597babf..4d65f10cae85 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -167,10 +167,6 @@ Raised by the commander `init` (deleted in the S5 cutover). On the engine-hosted The main CLI received a `--format` value other than `pretty` or `json`. Raised during global-flag resolution, before any command logic runs. Payload: `value`, `allowed`. -### CLI.INVALID_RENAME_FLAG - -A `--rename` value given to `migration plan` or `migration new` is not `=` (each side optionally qualified as `.`), names the same storage on both sides, qualifies its two sides with different namespaces (a rename cannot move storage between namespaces), or repeats an old or a new name already used by another value on the same command. Nothing is planned or written. Payload: `value`. - ### CLI.INVALID_VERIFY_MODE `prisma db verify` was given a contradictory mode combination: `--marker-only` together with `--schema-only`, or `--strict` together with `--marker-only` (strict requires schema verification, which marker-only skips). Payload: none. @@ -1278,10 +1274,6 @@ A ref name resolves to nothing: no pointer file with that name exists, and the f A reference parsed, but as the wrong kind for the argument position — e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. Payload: `input`, `expectedGrammar`. -### MIGRATION.RENAME_UNSUPPORTED - -A stated rename (`--rename =`) was given to `migration plan` or `migration new` on a MongoDB project. The MongoDB planner has no rename operation, so it refuses rather than planning a drop of the old collection and a create of the new one. Nothing is planned or written; on `migration plan` it is reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Rename the collection by hand with `renameCollection`, then run the command again without the rename. Payload: none. - ### MIGRATION.RUNNER_FAILED Generic wrapper for a migration runner failure during execution that has no more specific code; the summary/why carry the underlying detail (also used to surface the legacy-marker-shape condition from marker reads, with `meta.runnerErrorCode`). `migrate` and `db init` map unrecognized apply failures through it, passing the failure's own meta through unchanged. Inspect the reported summary/why detail and address the underlying failure before re-running the command. Payload: the wrapped failure's meta, when it has any; `runnerErrorCode` at the legacy-marker-shape site. @@ -1308,15 +1300,11 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.TABLE_NAME_CASE_CHANGED -The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename the table and keep its rows. In a project with migration history, state the rename when planning with `prisma migration plan --rename "X=Y"`, which the message writes as `.X=.Y` when the table is outside the default namespace or another namespace declares `X`: the plan renames the table and the constraints and indexes named after it instead of dropping and recreating the table. In a project that uses `db update`, which has no migration history, rename the table by hand with the statements the message gives, then run `db update` again. On Postgres that is `ALTER TABLE "X" RENAME TO "Y"`, schema-qualified inside a named schema. On SQLite, which refuses in one statement a rename that only changes case, it is `ALTER TABLE "X" RENAME TO "_prisma_rename_Y"; ALTER TABLE "_prisma_rename_Y" RENAME TO "Y"`. Payload: `droppedTable`, `createdTable`. - -### MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT - -A `--rename =` intent was given to `migration plan` or `migration new`, but there is no previous contract to rename the table in: there is no migration history, so the migration starts from an empty database. Nothing is planned or written. `migration plan` reports it as a conflict inside `MIGRATION.PLANNING_FAILED`, one per intent; `migration new` raises it directly. Plan from the migration that created the table. A database managed with `db update` has no migration history, so rename the table there by hand with `ALTER TABLE "X" RENAME TO "Y"` and drop the flag. Payload: none. +The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. The message gives three ways out. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table. In a project with migration history, make the rename its own schema change, create its migration with `prisma migration new`, and add `...this.renameTable({ table: "X", to: "Y" })` to its operations; the call renames the table and the constraints and indexes named after it, and on Postgres the message adds `schema: ""` when the table is outside the default schema or another schema declares `X`. In a project that uses `db update`, rename the table by hand with the statements the message gives, then run `db update` again: on Postgres `ALTER TABLE ""."X" RENAME TO "Y"`, and on SQLite, which refuses in one statement a rename that only changes the case of ASCII letters, `ALTER TABLE "X" RENAME TO "_prisma_rename_Y"; ALTER TABLE "_prisma_rename_Y" RENAME TO "Y"`. Payload: `droppedTable`, `createdTable`. ### MIGRATION.TABLE_RENAME_UNMATCHED -A `--rename =` intent given to `migration plan` does not match the two contracts being planned: `from` does not exist in the previous contract (or, unqualified, exists in more than one namespace), `to` already exists in the previous contract, `to` does not exist in the next contract, the two sides name different namespaces, or an earlier intent on the same command already resolved to the same old table or the same new table. Nothing is planned; reported as a conflict inside `MIGRATION.PLANNING_FAILED`. `migration new --rename` raises it directly for the same mismatches, including a namespace qualifier on SQLite, which has no namespaces; nothing is written. Check the spelling and the schema qualifier, or drop the flag if the table was not renamed. Payload: `from`, `to`. +`this.renameTable({ table, to })` in a hand-written migration does not match the migration's contracts: the migration has no start contract, the start contract has no table `table` (or, with no `schema`, declares it in more than one namespace), the start contract already has a table `to`, or the end contract has no table `to`. Raised when the migration's operations are built, so `migration.ts` writes no `ops.json`. Make the rename its own schema change, so the migration's start contract is the schema before the rename and its end contract the schema after it, and check the table names and the `schema`. Payload: `from`, `to`. ### MIGRATION.TARGET_MISMATCH diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index d32dd05d7646..2a1cf1db2063 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -266,8 +266,6 @@ export interface MigrationPlannerConflict { readonly summary: string; /** Optional explanation of why this conflict occurred. */ readonly why?: string; - /** Structured details for machine consumers, such as the conflict's error `code`. */ - readonly meta?: Readonly>; } /** @@ -422,24 +420,6 @@ export interface SchemaOwnership { declaresEntity(coordinate: SchemaEntityCoordinate): boolean; } -/** - * One side of a {@link StorageEntityRename}: a storage entity named within a - * namespace. `namespaceId` is omitted when the operator did not qualify the - * name; the family resolves it against the contract. - */ -export interface StorageEntityRenameCoordinate { - readonly namespaceId?: string; - readonly name: string; -} - -/** - * An operator-stated rename of the storage a model maps to, between the previous state and the next contract. The planner cannot infer a rename from a diff — the storage under the old name is gone and one under the new name appeared — so the operator states it and the planner applies it to the previous state before diffing. - */ -export interface StorageEntityRename { - readonly from: StorageEntityRenameCoordinate; - readonly to: StorageEntityRenameCoordinate; -} - /** * Migration planner interface for planning schema changes. * This is the minimal interface that CLI commands use. @@ -508,33 +488,21 @@ export interface MigrationPlanner< * `@internal/migration-tools`. */ readonly snapshotsImportPath: string; - /** - * Operator-stated renames, in the order given. Each becomes one rename operation at the head of the plan; one that matches neither side is a planning failure. Absent or empty means no renames. - * - * When renames are present, the planner applies them to `fromContract`, re-derives the previous schema from that renamed contract, and ignores `schema`. Pass renames only where `schema` is itself derived from `fromContract` (offline `migration plan`), never with a live introspected schema, whose drift and unowned objects would be lost. A planner that cannot rename returns a failure result. - */ - readonly renames?: readonly StorageEntityRename[]; }): MigrationPlannerResult; /** * Produce an empty migration with the target's authoring conventions. * * Used by `migration new` to scaffold a fresh `migration.ts`. The - * returned plan carries no operations unless `context.renames` states - * renames; its `renderTypeScript()` yields a stub the user can edit. - * - * With renames, the plan carries the same rename operations `plan` would - * plan for them between `context.renames.fromContract` and - * `context.renames.toContract`, and nothing else. A rename that `plan` - * would refuse, and any rename on a planner that cannot rename, throws a - * structured error. + * returned plan has no operations; its `renderTypeScript()` yields a + * stub the user can edit. * * `spaceId` is stamped onto the produced plan; reconciliation flows * (`db init`, `db update`) and authoring flows (`migration new`) all * pass it explicitly. */ emptyMigration( - context: MigrationScaffoldContext, + context: MigrationScaffoldContext, spaceId: string, ): MigrationPlanWithAuthoringSurface; } @@ -661,19 +629,6 @@ export interface TargetMigrationsCapability< // Migration Scaffolding SPI // ============================================================================ -/** - * Operator-stated renames for a scaffold, with the two contracts a planner resolves them against. `fromContract` is `null` when the migration has no previous contract. - */ -export interface MigrationScaffoldRenames< - TFamilyId extends string = string, - TTargetId extends string = string, -> { - readonly intents: readonly StorageEntityRename[]; - readonly fromContract: Contract | null; - readonly toContract: Contract; - readonly frameworkComponents: ReadonlyArray>; -} - /** * Context for rendering migration source files. * @@ -681,10 +636,7 @@ export interface MigrationScaffoldRenames< * (e.g. the contract `.d.ts` import for typed-contract builders). Passed to * `MigrationPlanner.emptyMigration(context)`. */ -export interface MigrationScaffoldContext< - TFamilyId extends string = string, - TTargetId extends string = string, -> { +export interface MigrationScaffoldContext { /** Absolute path to the migration package directory. Used by targets to compute relative imports. */ readonly packageDir: string; /** Absolute path to the contract.json file, if one exists. Used by targets that emit typed-contract imports. */ @@ -709,8 +661,4 @@ export interface MigrationScaffoldContext< * through to their renderer's `RenderMigrationMeta.snapshotsImportPath`. */ readonly snapshotsImportPath: string; - /** - * Operator-stated renames the scaffold starts with, so a hand-authored migration that only renames needs no editing. Absent scaffolds an empty body. - */ - readonly renames?: MigrationScaffoldRenames; } diff --git a/packages/1-framework/1-core/framework-components/src/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts index d6b9fb5ace15..f557c9a4e746 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/control.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/control.ts @@ -55,12 +55,9 @@ export type { MigrationRunnerResult, MigrationRunnerSuccessValue, MigrationScaffoldContext, - MigrationScaffoldRenames, OpFactoryCall, SchemaEntityCoordinate, SchemaOwnership, - StorageEntityRename, - StorageEntityRenameCoordinate, TargetMigrationsCapability, } from '../control/control-migration-types'; export type { diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 315610958829..a9690a04ae2c 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -932,7 +932,7 @@ The `contract.output` field specifies the path to `contract.json`. This is the c Plan a migration from contract changes. Compares a starting contract against a destination contract and produces a new migration package with the required operations. No database connection is needed — fully offline. ```bash -prisma migration plan [--config ] [--name ] [--from ] [--to ] [--rename =]... [--json] [-v] [-q] [--color/--no-color] +prisma migration plan [--config ] [--name ] [--from ] [--to ] [--json] [-v] [-q] [--color/--no-color] ``` **Options:** @@ -940,7 +940,6 @@ prisma migration plan [--config ] [--name ] [--from ] [--t - `--name `: Name slug for the migration directory (default: `migration`) - `--from `: Starting contract reference (hash, prefix, ref name, migration directory, `^`, `@empty`, or filesystem path). `@empty` names the empty-database origin deliberately. Defaults to the `db` ref; when the ref is absent, greenfield only on an empty graph — over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed. - `--to `: Destination contract reference (same grammar as `--from`). Defaults to the emitted `contract.json`. Use `--to ^` to plan a rollback toward a predecessor state. -- `--rename =`: States that the storage a model maps to was renamed, so the plan renames it and keeps its data instead of dropping and recreating it. Either side may be `.`. Repeat once per rename. `migration new` accepts the same flag and scaffolds the operations `migration plan` would plan for it. MongoDB refuses the flag. - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output (debug info, timings) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index 773880e94c67..489c9c00f6cb 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -5,11 +5,7 @@ import { readFile } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; -import { - APP_SPACE_ID, - createControlStack, - type StorageEntityRename, -} from '@internal/framework-components/control'; +import { APP_SPACE_ID, createControlStack } from '@internal/framework-components/control'; import { loadContractSpaceAggregate } from '@internal/migration-tools/aggregate'; import { contractSnapshotDir, @@ -21,9 +17,7 @@ import { formatMigrationDirName, writeMigrationPackage } from '@internal/migrati import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { findLatestMigration } from '@internal/migration-tools/migration-graph'; import { writeMigrationTs } from '@internal/migration-tools/migration-ts'; -import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; -import { isStructuredError } from '@internal/utils/structured-error'; import { join, relative } from 'pathe'; import { CliStructuredError, @@ -50,10 +44,6 @@ export interface MigrationNewOptions { readonly configPath?: string; readonly name?: string; readonly from?: string; - /** - * Operator-stated renames (`--rename`): the scaffold starts with the rename operations `migration plan` would plan for them, and its manifest is attested over those ops, so a migration that only renames is complete as written. - */ - readonly renames?: readonly StorageEntityRename[]; /** Renders the declarations of the destination snapshot from its `contract.json`. */ readonly client: Pick; } @@ -175,6 +165,21 @@ export async function executeMigrationNewCommand( const dirName = formatMigrationDirName(timestamp, slug); const packageDir = join(appMigrationsDir, dirName); + // `migration new` scaffolds an empty `migration.ts` for the user to + // fill, so we attest over `ops: []`. Re-running self-emit after the + // user adds operations will produce a different `migrationHash` (over + // the real ops). This is intentional — there is no on-disk draft. + const baseMetadata: Omit = { + from: fromHash, + to: toStorageHash, + providedInvariants: [], + createdAt: timestamp.toISOString(), + }; + const metadata: MigrationMetadata = { + ...baseMetadata, + migrationHash: computeMigrationHash(baseMetadata, []), + }; + const migrations = getTargetMigrations(config.target); if (!migrations) { return notOk( @@ -185,11 +190,11 @@ export async function executeMigrationNewCommand( } try { - const frameworkComponents = assertFrameworkComponentsCompatible( - config.family.familyId, - config.target.targetId, - [config.target, config.adapter, ...(config.extensions ?? [])], - ); + assertFrameworkComponentsCompatible(config.family.familyId, config.target.targetId, [ + config.target, + config.adapter, + ...(config.extensions ?? []), + ]); // Before any write: an unreadable or contradictory project manifest fails // the command outright rather than after a half-scaffolded migration @@ -205,55 +210,24 @@ export async function executeMigrationNewCommand( return notOk(declarations.failure); } - // The planner resolves stated renames against the contract the migration - // starts from and the one it ends at, so the scaffold carries the same - // rename operations `migration plan` would plan. - const renames = - options.renames === undefined || options.renames.length === 0 - ? undefined - : { - intents: options.renames, - fromContract: - fromHash === null ? null : (await aggregate.app.contractAt(fromHash)).contract, - toContract, - frameworkComponents, - }; + await writeMigrationPackage(packageDir, metadata, []); + await writeContractSnapshot(migrationsDir, toStorageHash, { + contractJson: parsedContract, + contractDts: declarations.value, + }); + const planner = migrations.createPlanner(controlAdapter); - const scaffold = planner.emptyMigration( + const emptyPlan = planner.emptyMigration( { packageDir, contractJsonPath: join(contractSnapshotDir(migrationsDir, toStorageHash), 'contract.json'), fromHash, toHash: toStorageHash, snapshotsImportPath: snapshotsImportPathFrom(packageDir, migrationsDir), - ...ifDefined('renames', renames), }, APP_SPACE_ID, ); - - // The scaffold carries only the operations the operator stated on the - // command line (none by default), and the manifest is attested over - // exactly those. Re-running self-emit after the user adds operations - // produces a different `migrationHash` (over the real ops). This is - // intentional — there is no on-disk draft. - const scaffoldOps = await Promise.all(scaffold.operations); - const baseMetadata: Omit = { - from: fromHash, - to: toStorageHash, - providedInvariants: [], - createdAt: timestamp.toISOString(), - }; - const metadata: MigrationMetadata = { - ...baseMetadata, - migrationHash: computeMigrationHash(baseMetadata, scaffoldOps), - }; - - await writeMigrationPackage(packageDir, metadata, scaffoldOps); - await writeContractSnapshot(migrationsDir, toStorageHash, { - contractJson: parsedContract, - contractDts: declarations.value, - }); - await writeMigrationTs(packageDir, scaffold.renderTypeScript(resolveSpecifier)); + await writeMigrationTs(packageDir, emptyPlan.renderTypeScript(resolveSpecifier)); return ok({ ok: true as const, @@ -266,16 +240,6 @@ export async function executeMigrationNewCommand( if (CliStructuredError.is(error)) { return notOk(error); } - if (isStructuredError(error)) { - return notOk( - new CliStructuredError(error.code, error.message, { - ...ifDefined('why', error.why), - ...ifDefined('fix', error.fix), - ...ifDefined('meta', error.meta), - cause: error, - }), - ); - } return notOk( errorUnexpected(error instanceof Error ? error.message : String(error), { why: `Failed to scaffold migration: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index 5c69ef24e1c7..48f7bec8a894 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -11,7 +11,6 @@ import { type MigrationPlanOperation, type OperationPreview, type SchemaOwnership, - type StorageEntityRename, } from '@internal/framework-components/control'; import { snapshotsImportPathFrom, @@ -69,13 +68,6 @@ export interface MigrationPlanOptions { readonly name?: string; readonly from?: string; readonly to?: string; - /** - * Operator-stated renames (`--rename`), applied by the planner to the - * origin contract before it diffs; each becomes the first operations of - * the planned migration. Only the delta leg receives them: an auto-baseline - * leg plans the origin from nothing and has no previous state to rename. - */ - readonly renames?: readonly StorageEntityRename[]; /** Renders the declarations of the destination snapshot from its `contract.json`. */ readonly client: Pick; } @@ -98,7 +90,6 @@ async function runPlannerLeg( ownership: SchemaOwnership, snapshotsImportPath: string, resolveImportSpecifier: ImportSpecifierResolver, - renames: readonly StorageEntityRename[] = [], ): Promise> { const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents); const plannerResult = planner.plan({ @@ -108,7 +99,6 @@ async function runPlannerLeg( fromContract, frameworkComponents, spaceId, - ...(renames.length === 0 ? {} : { renames }), // Offline `migration plan` is the aggregate-of-(possibly one) degenerate // case: the same ownership consultation the live aggregate flow uses. A // from→to extra (a table removed from the contract) is not declared by any @@ -588,7 +578,6 @@ async function executeMigrationPlanCommandInner( aggregate, snapshotsImportPathFrom(deltaPackageDir, migrationsDir), resolveImportSpecifier, - options.renames, ); if (!deltaLeg.ok) { return notOk(deltaLeg.failure); @@ -660,7 +649,6 @@ async function executeMigrationPlanCommandInner( aggregate, snapshotsImportPathFrom(packageDir, migrationsDir), resolveImportSpecifier, - options.renames, ); if (!deltaLeg.ok) { return notOk(deltaLeg.failure); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts index 2a88b8642cb2..52a1ebc63bc6 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts @@ -9,7 +9,6 @@ import type { MigrationNewResult } from '../../control-api/operations/migration- import { executeMigrationNewCommand } from '../../control-api/operations/migration-new'; import type { CreateControlClient } from '../../control-api/types'; import { runCommandAction } from '../../utils/next-actions'; -import { parseRenameFlags } from '../../utils/rename-flag'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -84,26 +83,16 @@ export function createMigrationNewCommand(createClient: CreateControlClient) { brief: 'Starting contract hash (default: latest migration target)', placeholder: 'hash', }), - rename: flag.repeated({ - brief: - 'Storage a model maps to that this migration renames, as = (either side may be .); the scaffold starts with the operations migration plan would plan for it', - placeholder: 'from=to', - }), }, }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { - const renames = parseRenameFlags(args.flags.rename); - if (!renames.ok) { - return notOk(normalizeError(renames.failure)); - } const scaffolded = await executeMigrationNewCommand({ config: ctx.config, cwd: ctx.cwd, configPath: projectConfigPathFor(ctx.cwd), ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), - ...(renames.value.length === 0 ? {} : { renames: renames.value }), client: createClient({ family: ctx.config.family, target: ctx.config.target, diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 67426ff99c7d..f531ae53b285 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -11,7 +11,6 @@ import { executeMigrationPlanCommand } from '../../control-api/operations/migrat import type { CreateControlClient } from '../../control-api/types'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; -import { parseRenameFlags } from '../../utils/rename-flag'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -234,19 +233,10 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { 'Destination contract reference; defaults to the emitted contract. Same grammar as --from', placeholder: 'contract', }), - rename: flag.repeated({ - brief: - 'Storage a model maps to that the contract change renames, as = (either side may be .); repeat per rename', - placeholder: 'from=to', - }), }, }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { - const renames = parseRenameFlags(args.flags.rename); - if (!renames.ok) { - return notOk(normalizeError(renames.failure)); - } const seeded = (record: ContractSpaceSeedPhaseRecord): void => { if (record.action !== 'updated') { return; @@ -270,7 +260,6 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), ...ifDefined('to', args.flags.to), - ...(renames.value.length === 0 ? {} : { renames: renames.value }), client: createClient({ family: ctx.config.family, target: ctx.config.target, diff --git a/packages/1-framework/3-tooling/cli/src/utils/rename-flag.ts b/packages/1-framework/3-tooling/cli/src/utils/rename-flag.ts deleted file mode 100644 index 45b66ff75fa8..000000000000 --- a/packages/1-framework/3-tooling/cli/src/utils/rename-flag.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { CliStructuredError } from '@internal/errors/control'; -import type { - StorageEntityRename, - StorageEntityRenameCoordinate, -} from '@internal/framework-components/control'; -import { notOk, ok, type Result } from '@internal/utils/result'; - -const GRAMMAR = '=, each side optionally qualified as .'; - -function invalid(value: string, reason: string): CliStructuredError { - return new CliStructuredError( - 'CLI.INVALID_RENAME_FLAG', - `Invalid --rename value "${value}": ${reason}.`, - { - why: `--rename names the storage a model maps to and its new name, as ${GRAMMAR}.`, - fix: `Write the flag as --rename ${GRAMMAR}, once per rename.`, - meta: { value }, - }, - ); -} - -function parseCoordinate( - value: string, - side: string, -): Result { - const dot = side.indexOf('.'); - if (dot === -1) { - return side.length === 0 ? notOk(invalid(value, 'a name is missing')) : ok({ name: side }); - } - const namespaceId = side.slice(0, dot); - const name = side.slice(dot + 1); - if (namespaceId.length === 0) return notOk(invalid(value, 'the namespace qualifier is empty')); - if (name.length === 0) return notOk(invalid(value, `no name follows "${namespaceId}."`)); - return ok({ namespaceId, name }); -} - -function coordinateKey(coordinate: StorageEntityRenameCoordinate): string { - return `${coordinate.namespaceId ?? ''}.${coordinate.name}`; -} - -function coordinateLabel(coordinate: StorageEntityRenameCoordinate): string { - return coordinate.namespaceId === undefined - ? coordinate.name - : `${coordinate.namespaceId}.${coordinate.name}`; -} - -/** - * Parses every `--rename =` value the operator gave, in the order given. A malformed value, a value whose two sides are the same, a value whose sides name different namespaces, and two values that share an old or a new name are each refused with `CLI.INVALID_RENAME_FLAG`. - */ -export function parseRenameFlags( - values: readonly string[] | undefined, -): Result { - const renames: StorageEntityRename[] = []; - const seenFrom = new Set(); - const seenTo = new Set(); - for (const raw of values ?? []) { - const value = raw.trim(); - const parts = value.split('='); - if (parts.length !== 2) { - return notOk( - invalid( - value, - parts.length < 2 ? 'expected one "=" between the two names' : 'expected exactly one "="', - ), - ); - } - const [left = '', right = ''] = parts.map((part) => part.trim()); - const from = parseCoordinate(value, left); - if (!from.ok) return from; - const to = parseCoordinate(value, right); - if (!to.ok) return to; - if ( - from.value.namespaceId !== undefined && - to.value.namespaceId !== undefined && - from.value.namespaceId !== to.value.namespaceId - ) { - return notOk( - invalid( - value, - `a rename cannot move "${coordinateLabel(from.value)}" to namespace "${to.value.namespaceId}"`, - ), - ); - } - const fromKey = coordinateKey(from.value); - const toKey = coordinateKey(to.value); - if (fromKey === toKey) return notOk(invalid(value, 'the old and new names are the same')); - if (seenFrom.has(fromKey)) { - return notOk(invalid(value, `"${coordinateLabel(from.value)}" is renamed more than once`)); - } - if (seenTo.has(toKey)) { - return notOk(invalid(value, `more than one value renames to "${coordinateLabel(to.value)}"`)); - } - seenFrom.add(fromKey); - seenTo.add(toKey); - renames.push({ from: from.value, to: to.value }); - } - return ok(renames); -} diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts index 0f503be97b35..d2eaa31c61cd 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts @@ -144,8 +144,6 @@ export interface FakePlannerScript { readonly operations?: readonly MigrationPlanOperation[]; readonly conflicts?: ReadonlyArray<{ readonly kind: string; readonly summary: string }>; readonly throwOnOperations?: unknown; - readonly throwOnEmptyMigration?: unknown; - readonly onEmptyMigration?: (context: unknown) => void; } function fakePlanner(script: FakePlannerScript): Record { @@ -163,11 +161,7 @@ function fakePlanner(script: FakePlannerScript): Record { }, } : { kind: 'failure', conflicts: script.conflicts }, - emptyMigration: (context: unknown) => { - script.onEmptyMigration?.(context); - if (script.throwOnEmptyMigration !== undefined) throw script.throwOnEmptyMigration; - return { operations: [], renderTypeScript: () => '// empty migration\n' }; - }, + emptyMigration: () => ({ renderTypeScript: () => '// empty migration\n' }), }; } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts deleted file mode 100644 index 9bf717d30a64..000000000000 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-new-renames.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * `migration new --rename` hands the target planner the stated renames together with the contract the migration starts from and the one it ends at, so the planner can scaffold the same rename operations `migration plan` would plan. - */ - -import { createTestCli } from '@prisma/cli-engine/testing'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { BIN_GROUPS } from '../../src/orm/cli'; -import { - contractJson, - createOfflineProject, - OFFLINE_COMMANDS, - type OfflineProject, - offlineConfig, - removeOfflineProjects, - resetRenderContractDtsMock, - seedContractSnapshot, - seedMigrationPackage, -} from './fixtures/offline-project'; - -const HASH_TO = `c0ffee${'0'.repeat(58)}`; -const HASH_FROM = `beef${'1'.repeat(60)}`; -const INTENT = { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }; - -beforeEach(resetRenderContractDtsMock); -afterEach(removeOfflineProjects); - -async function scaffoldContext( - project: OfflineProject, - args: readonly string[], -): Promise | undefined> { - let received: unknown; - const run = await createTestCli({ - commands: OFFLINE_COMMANDS, - groups: BIN_GROUPS, - config: { - orm: offlineConfig({ - project, - script: { - onEmptyMigration: (context) => { - received = context; - }, - }, - }), - }, - }).run(['migration', 'new', '--json', ...args], { cwd: project.dir }); - expect(run.exitCode).toBe(0); - return typeof received === 'object' && received !== null ? { ...received } : undefined; -} - -describe('migration new --rename', () => { - it('passes the renames with the previous and the next contract', async () => { - const project = await createOfflineProject({ storageHash: HASH_TO }); - await seedMigrationPackage({ - appMigrationsDir: project.appMigrationsDir, - dirName: '20260101T0000_initial', - from: null, - to: HASH_FROM, - }); - await seedContractSnapshot({ migrationsDir: project.migrationsDir, storageHash: HASH_FROM }); - - const context = await scaffoldContext(project, ['--rename', 'userProfile=UserProfile']); - - expect(context?.['renames']).toMatchObject({ - intents: [INTENT], - fromContract: contractJson(HASH_FROM), - toContract: contractJson(HASH_TO), - frameworkComponents: expect.any(Array), - }); - }); - - it('passes no previous contract when the project has no migration history', async () => { - const project = await createOfflineProject({ storageHash: HASH_TO }); - - const context = await scaffoldContext(project, ['--rename', 'userProfile=UserProfile']); - - expect(context?.['renames']).toMatchObject({ - intents: [INTENT], - fromContract: null, - toContract: contractJson(HASH_TO), - }); - }); - - it('passes no renames when the flag is absent', async () => { - const project = await createOfflineProject({ storageHash: HASH_TO }); - - const context = await scaffoldContext(project, []); - - expect(context).not.toHaveProperty('renames'); - }); -}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts index 3f6c653e5e90..b6a649415a98 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts @@ -2,7 +2,6 @@ import { existsSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { notOk } from '@internal/utils/result'; -import { structuredError } from '@internal/utils/structured-error'; import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -257,31 +256,6 @@ describe('migration new', () => { }); }); - it('keeps the code and explanation of a structured error the planner raises', async () => { - const project = await createOfflineProject({ storageHash: HASH_TO }); - const refusal = structuredError('MIGRATION.RENAME_UNSUPPORTED', 'Renames are not supported.', { - why: 'Rename the collection by hand.', - }); - - const run = await harness(project, { - ...offlineConfig({ project, script: { throwOnEmptyMigration: refusal } }), - }).run(['migration', 'new', '--json'], { cwd: project.dir }); - - expect(run.exitCode).toBe(2); - expect(run.json.at(-1)).toMatchObject({ - kind: 'result', - envelope: { - ok: false, - error: { - code: 'MIGRATION.RENAME_UNSUPPORTED', - summary: 'Renames are not supported.', - why: 'Rename the collection by hand.', - }, - }, - }); - expect(existsSync(project.appMigrationsDir)).toBe(false); - }); - it('settles every error with typed next actions and no fix prose', async () => { const project = await createOfflineProject({ storageHash: HASH_TO }); await seedMigrationPackage({ diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-rename-flag.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-rename-flag.test.ts deleted file mode 100644 index 1723c7b19332..000000000000 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-rename-flag.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * `--rename` is parsed once, the same way, on `migration plan` and - * `migration new`, and the parsed intents reach each command's control - * operation. The operations themselves are doubles here; what they do with the - * intents is covered by the planner tests and the journeys. - */ - -import { rmSync, writeFileSync } from 'node:fs'; -import { ok } from '@internal/utils/result'; -import type { StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; -import { join } from 'pathe'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ControlClient } from '../../src/control-api/types'; -import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; - -const mocks = vi.hoisted(() => ({ - executeMigrationPlanCommand: vi.fn(), - executeMigrationNewCommand: vi.fn(), -})); - -vi.mock('../../src/control-api/operations/migration-plan', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, executeMigrationPlanCommand: mocks.executeMigrationPlanCommand }; -}); - -vi.mock('../../src/control-api/operations/migration-new', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, executeMigrationNewCommand: mocks.executeMigrationNewCommand }; -}); - -vi.resetModules(); -const { BIN_GROUPS, createBinCommands } = await import('../../src/orm/cli'); - -const commands = createBinCommands(() => ({}) as unknown as ControlClient); - -const DESCRIPTOR = { familyId: 'sql', targetId: 'postgres', version: '1.0.0', create: () => ({}) }; -const TO_HASH = 'b'.repeat(64); - -let projectDir: string; -const projectDirs: string[] = []; - -afterAll(() => { - for (const dir of projectDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - vi.doUnmock('../../src/control-api/operations/migration-plan'); - vi.doUnmock('../../src/control-api/operations/migration-new'); - vi.resetModules(); -}); - -beforeEach(() => { - projectDir = createTestProjectDir('orm-migration-rename'); - projectDirs.push(projectDir); - writeProjectManifest(projectDir); - writeFileSync( - join(projectDir, 'contract.json'), - JSON.stringify({ storage: { storageHash: TO_HASH } }), - ); - mocks.executeMigrationPlanCommand.mockReset().mockResolvedValue( - ok({ - ok: true, - noOp: true, - from: null, - to: TO_HASH, - operations: [], - emittedExtensionDirs: [], - summary: 'No changes detected between contracts', - timings: { total: 1 }, - }), - ); - mocks.executeMigrationNewCommand.mockReset().mockResolvedValue( - ok({ - ok: true, - dir: 'migrations/app/20260101T0000_x', - from: null, - to: TO_HASH, - summary: 'Scaffolded migration at migrations/app/20260101T0000_x', - }), - ); -}); - -function ormConfig(): Record { - return { - family: { - kind: 'family', - id: 'sql', - familyId: 'sql', - version: '1.0.0', - emission: {}, - create: () => ({}), - }, - target: { ...DESCRIPTOR, kind: 'target', id: 'postgres', migrations: {} }, - adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, - driver: { ...DESCRIPTOR, kind: 'driver', id: 'pg-driver' }, - contract: { - source: { format: 'typescript', inputs: [], load: async () => ({}) }, - output: join(projectDir, 'contract.json'), - }, - }; -} - -function harness() { - return createTestCli({ commands, groups: BIN_GROUPS, config: { orm: ormConfig() } }); -} - -function envelopeOf(json: readonly StreamEvent[]): unknown { - const terminal = json.at(-1); - return terminal?.kind === 'result' ? terminal.envelope : undefined; -} - -const EXPECTED_RENAMES = [ - { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, - { from: { namespaceId: 'auth', name: 'orderLine' }, to: { name: 'OrderLine' } }, -]; - -describe('migration plan --rename', () => { - it('passes every parsed intent to the plan operation', async () => { - const run = await harness().run( - [ - 'migration', - 'plan', - '--json', - '--rename', - 'userProfile=UserProfile', - '--rename', - 'auth.orderLine=OrderLine', - ], - { cwd: projectDir }, - ); - - expect(run.exitCode).toBe(0); - expect(mocks.executeMigrationPlanCommand).toHaveBeenCalledTimes(1); - expect(mocks.executeMigrationPlanCommand.mock.calls[0]?.[0]).toMatchObject({ - renames: EXPECTED_RENAMES, - }); - }); - - it('passes no intents when the flag is absent', async () => { - const run = await harness().run(['migration', 'plan', '--json'], { cwd: projectDir }); - - expect(run.exitCode).toBe(0); - expect(mocks.executeMigrationPlanCommand.mock.calls[0]?.[0]).not.toHaveProperty('renames'); - }); - - it('rejects a malformed value before the operation runs', async () => { - const run = await harness().run(['migration', 'plan', '--json', '--rename', 'userProfile'], { - cwd: projectDir, - }); - - expect(run.exitCode).not.toBe(0); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.INVALID_RENAME_FLAG' }, - }); - expect(mocks.executeMigrationPlanCommand).not.toHaveBeenCalled(); - }); -}); - -describe('migration new --rename', () => { - it('passes every parsed intent to the scaffold operation', async () => { - const run = await harness().run( - [ - 'migration', - 'new', - '--json', - '--rename', - 'userProfile=UserProfile', - '--rename', - 'auth.orderLine=OrderLine', - ], - { cwd: projectDir }, - ); - - expect(run.exitCode).toBe(0); - expect(mocks.executeMigrationNewCommand).toHaveBeenCalledTimes(1); - expect(mocks.executeMigrationNewCommand.mock.calls[0]?.[0]).toMatchObject({ - renames: EXPECTED_RENAMES, - }); - }); - - it('rejects a malformed value before the operation runs', async () => { - const run = await harness().run(['migration', 'new', '--json', '--rename', 'a=b=c'], { - cwd: projectDir, - }); - - expect(run.exitCode).not.toBe(0); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.INVALID_RENAME_FLAG' }, - }); - expect(mocks.executeMigrationNewCommand).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/1-framework/3-tooling/cli/test/utils/rename-flag.test.ts b/packages/1-framework/3-tooling/cli/test/utils/rename-flag.test.ts deleted file mode 100644 index 7f7f21c51518..000000000000 --- a/packages/1-framework/3-tooling/cli/test/utils/rename-flag.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseRenameFlags } from '../../src/utils/rename-flag'; - -function parsedValue(values: readonly string[]) { - const result = parseRenameFlags(values); - expect(result.ok).toBe(true); - return result.ok ? result.value : undefined; -} - -function failureOf(values: readonly string[]) { - const result = parseRenameFlags(values); - expect(result.ok).toBe(false); - return result.ok ? undefined : result.failure.toEnvelope(); -} - -describe('parseRenameFlags', () => { - it('parses an unqualified pair', () => { - expect(parsedValue(['userProfile=UserProfile'])).toEqual([ - { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, - ]); - }); - - it('parses a namespace qualifier on either side', () => { - expect(parsedValue(['auth.userProfile=UserProfile', 'orderLine=sales.OrderLine'])).toEqual([ - { from: { namespaceId: 'auth', name: 'userProfile' }, to: { name: 'UserProfile' } }, - { from: { name: 'orderLine' }, to: { namespaceId: 'sales', name: 'OrderLine' } }, - ]); - }); - - it('keeps repeated values in the order given', () => { - const result = parseRenameFlags(['a=b', 'c=d']); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.map((intent) => `${intent.from.name}>${intent.to.name}`)).toEqual([ - 'a>b', - 'c>d', - ]); - }); - - it('returns no intents for no values', () => { - expect(parsedValue([])).toEqual([]); - }); - - it.each([ - ['no separator', 'userProfile'], - ['empty left side', '=UserProfile'], - ['empty right side', 'userProfile='], - ['two separators', 'a=b=c'], - ['empty namespace', '.userProfile=UserProfile'], - ['empty name after the namespace', 'auth.=UserProfile'], - ['blank value', ' '], - ])('rejects a malformed value (%s) with CLI.INVALID_RENAME_FLAG', (_label, value) => { - const envelope = failureOf([value]); - expect(envelope).toMatchObject({ - code: 'CLI.INVALID_RENAME_FLAG', - summary: expect.stringContaining(value.trim() === '' ? '--rename' : value), - }); - expect(envelope?.fix).toContain('='); - }); - - it('accepts both sides qualified with the same namespace', () => { - expect(parsedValue(['auth.userProfile=auth.UserProfile'])).toEqual([ - { - from: { namespaceId: 'auth', name: 'userProfile' }, - to: { namespaceId: 'auth', name: 'UserProfile' }, - }, - ]); - }); - - it('rejects a value that moves the storage to another namespace', () => { - expect(failureOf(['auth.userProfile=sales.UserProfile'])?.summary).toBe( - 'Invalid --rename value "auth.userProfile=sales.UserProfile": a rename cannot move "auth.userProfile" to namespace "sales".', - ); - }); - - it('rejects a value whose two sides name the same storage', () => { - expect(failureOf(['userProfile=userProfile'])).toMatchObject({ - code: 'CLI.INVALID_RENAME_FLAG', - summary: expect.stringContaining('userProfile=userProfile'), - }); - }); - - it('rejects two values that rename the same storage, naming it', () => { - expect(failureOf(['userProfile=UserProfile', 'userProfile=Profile'])?.summary).toBe( - 'Invalid --rename value "userProfile=Profile": "userProfile" is renamed more than once.', - ); - }); - - it('names the qualified storage when two qualified values rename it', () => { - expect(failureOf(['auth.userProfile=UserProfile', 'auth.userProfile=Profile'])?.summary).toBe( - 'Invalid --rename value "auth.userProfile=Profile": "auth.userProfile" is renamed more than once.', - ); - }); - - it('rejects two values that rename to the same name, naming it', () => { - expect(failureOf(['userProfile=UserProfile', 'profile=UserProfile'])?.summary).toBe( - 'Invalid --rename value "profile=UserProfile": more than one value renames to "UserProfile".', - ); - }); -}); diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index b32953ba55ed..bd9e4c0685f4 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -18,7 +18,6 @@ import type { OpFactoryCall, SchemaDiffIssue, SchemaOwnership, - StorageEntityRename, } from '@internal/framework-components/control'; import type { AggregateMigrationEdgeRef } from '@internal/migration-tools/aggregate'; import type { @@ -282,8 +281,7 @@ export type SqlPlannerConflictKind = | 'missingButNonAdditive' | 'unsupportedOperation' | 'controlPolicySuppressedCall' - | 'tableNameCaseChanged' - | 'tableRenameUnmatched'; + | 'tableNameCaseChanged'; export interface SqlPlannerConflictLocation { readonly namespaceId?: string; @@ -375,14 +373,6 @@ export interface SqlMigrationPlannerPlanOptions { * aggregate. See {@link SchemaOwnership}. */ readonly ownership?: SchemaOwnership; - /** - * Operator-stated table renames, applied to `fromContract` before the diff - * so the differ sees each table under its new name; one `renameTable` op - * per intent is prepended to the plan. See {@link StorageEntityRename}. - * - * When present, the previous schema is re-derived from the renamed `fromContract` and `schema` is ignored, so pass renames only where `schema` is derived from `fromContract`. - */ - readonly renames?: readonly StorageEntityRename[]; } export interface SqlMigrationPlanner { diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts index 4365246d37f5..18d829f666ea 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-planner.ts @@ -9,8 +9,6 @@ import type { MigrationPlannerResult, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, - StorageEntityRename, - StorageEntityRenameCoordinate, } from '@internal/framework-components/control'; import type { MongoContract } from '@internal/mongo-contract'; import type { @@ -21,7 +19,6 @@ import type { MongoSchemaValidator, } from '@internal/mongo-schema-ir'; import { canonicalize, deepEqual } from '@internal/mongo-schema-ir'; -import { mongoTargetError } from './mongo-target-errors'; import type { OpFactoryCall } from './op-factory-call'; import { CollModCall, @@ -34,24 +31,6 @@ import { } from './op-factory-call'; import { PlannerProducedMongoMigration } from './planner-produced-migration'; -const RENAME_UNSUPPORTED_CODE = 'MIGRATION.RENAME_UNSUPPORTED'; - -function coordinateLabel(coordinate: StorageEntityRenameCoordinate): string { - return coordinate.namespaceId === undefined - ? coordinate.name - : `${coordinate.namespaceId}.${coordinate.name}`; -} - -function renamesLabel(renames: readonly StorageEntityRename[]): string { - return renames - .map((rename) => `"${coordinateLabel(rename.from)}=${coordinateLabel(rename.to)}"`) - .join(', '); -} - -function renamesUnsupportedWhy(retry: 'plan' | 'scaffold'): string { - return `The MongoDB planner has no rename operation, so it cannot keep the documents of a renamed collection. Rename the collection by hand with renameCollection, then ${retry} again without the rename.`; -} - function buildIndexLookupKey(index: MongoSchemaIndex): string { const keys = index.keys.map((k) => `${k.field}:${k.direction}`).join(','); const opts = [ @@ -320,21 +299,7 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> * into the produced plan's `renderTypeScript()` metadata. */ readonly snapshotsImportPath: string; - readonly renames?: readonly StorageEntityRename[]; }): MigrationPlannerResult { - if (options.renames !== undefined && options.renames.length > 0) { - return { - kind: 'failure', - conflicts: [ - { - kind: 'renameUnsupported', - summary: `${RENAME_UNSUPPORTED_CODE}: MongoDB does not support stated renames (${renamesLabel(options.renames)}), so nothing was planned.`, - why: renamesUnsupportedWhy('plan'), - meta: { code: RENAME_UNSUPPORTED_CODE }, - }, - ], - }; - } const contract = options.contract as MongoContract; const result = this.planCalls(options); if (result.kind === 'failure') return result; @@ -361,13 +326,6 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> * not import from the generated contract `.d.ts`. */ emptyMigration(context: MigrationScaffoldContext): MigrationPlanWithAuthoringSurface { - if (context.renames !== undefined && context.renames.intents.length > 0) { - throw mongoTargetError( - RENAME_UNSUPPORTED_CODE, - `MongoDB does not support stated renames (${renamesLabel(context.renames.intents)}), so nothing was scaffolded.`, - { why: renamesUnsupportedWhy('scaffold') }, - ); - } return new PlannerProducedMongoMigration( [], { diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-errors.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-errors.ts index d9ec68d00a89..ffea3afd74ae 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-errors.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-errors.ts @@ -3,7 +3,7 @@ import { structuredError } from '@internal/utils/structured-error'; export type MongoTargetErrorCode = `MIGRATION.${MigrationSubcode}`; -type MigrationSubcode = 'INVALID_OPERATION_ENTRY' | 'OPERATION_UNSUPPORTED' | 'RENAME_UNSUPPORTED'; +type MigrationSubcode = 'INVALID_OPERATION_ENTRY' | 'OPERATION_UNSUPPORTED'; export function mongoTargetError( code: MongoTargetErrorCode, diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts deleted file mode 100644 index 9aaefda0026b..000000000000 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.renames.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { coreHash } from '@internal/contract/types'; -import type { StorageEntityRename } from '@internal/framework-components/control'; -import { - buildMongoNamespace, - MongoCollection, - type MongoContract, - MongoStorage, -} from '@internal/mongo-contract'; -import { MongoSchemaCollection, MongoSchemaIR } from '@internal/mongo-schema-ir'; -import { describe, expect, it } from 'vitest'; -import { MongoMigrationPlanner } from '../src/core/mongo-planner'; - -const RENAMES: readonly StorageEntityRename[] = [ - { from: { name: 'userProfile' }, to: { name: 'UserProfile' } }, -]; - -function contractWith(collectionName: string): MongoContract { - return { - target: 'mongo', - targetFamily: 'mongo', - profileHash: 'test-profile', - capabilities: {}, - extensions: {}, - meta: {}, - roots: {}, - models: {}, - storage: new MongoStorage({ - storageHash: coreHash('to-storage'), - namespaces: { - __unbound__: buildMongoNamespace({ - id: '__unbound__', - entries: { collection: { [collectionName]: new MongoCollection({}) } }, - }), - }, - }), - } as unknown as MongoContract; -} - -describe('MongoMigrationPlanner stated renames', () => { - const planner = new MongoMigrationPlanner(); - - it('refuses to plan when renames are given, instead of planning a collection drop', () => { - const result = planner.plan({ - contract: contractWith('UserProfile'), - schema: new MongoSchemaIR([new MongoSchemaCollection({ name: 'userProfile', indexes: [] })]), - policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, - fromContract: contractWith('userProfile'), - frameworkComponents: [], - snapshotsImportPath: '../../snapshots', - renames: RENAMES, - }); - - expect(result).toEqual({ - kind: 'failure', - conflicts: [ - { - kind: 'renameUnsupported', - summary: - 'MIGRATION.RENAME_UNSUPPORTED: MongoDB does not support stated renames ("userProfile=UserProfile"), so nothing was planned.', - why: 'The MongoDB planner has no rename operation, so it cannot keep the documents of a renamed collection. Rename the collection by hand with renameCollection, then plan again without the rename.', - meta: { code: 'MIGRATION.RENAME_UNSUPPORTED' }, - }, - ], - }); - }); - - it('refuses to scaffold when renames are given', () => { - expect(() => - planner.emptyMigration({ - packageDir: '/tmp/migration-pkg', - fromHash: '00', - toHash: '01', - snapshotsImportPath: '../../snapshots', - renames: { - intents: RENAMES, - fromContract: contractWith('userProfile'), - toContract: contractWith('UserProfile'), - frameworkComponents: [], - }, - }), - ).toThrow( - expect.objectContaining({ - code: 'MIGRATION.RENAME_UNSUPPORTED', - message: - 'MongoDB does not support stated renames ("userProfile=UserProfile"), so nothing was scaffolded.', - why: 'The MongoDB planner has no rename operation, so it cannot keep the documents of a renamed collection. Rename the collection by hand with renameCollection, then scaffold again without the rename.', - }), - ); - }); - - it('plans and scaffolds as usual when the renames list is empty', () => { - const result = planner.plan({ - contract: contractWith('users'), - schema: new MongoSchemaIR([new MongoSchemaCollection({ name: 'users', indexes: [] })]), - policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, - fromContract: null, - frameworkComponents: [], - snapshotsImportPath: '../../snapshots', - renames: [], - }); - const scaffold = planner.emptyMigration({ - packageDir: '/tmp/migration-pkg', - fromHash: '00', - toHash: '01', - snapshotsImportPath: '../../snapshots', - renames: { - intents: [], - fromContract: contractWith('users'), - toContract: contractWith('users'), - frameworkComponents: [], - }, - }); - - expect(result.kind).toBe('success'); - expect(scaffold.operations).toEqual([]); - }); -}); From c31c5ed9b9d76fdc9f8f9ad23540314b5c736ae7 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:07:17 +0200 Subject: [PATCH 49/55] feat(migration): the case guard points at migration new and this.renameTable MIGRATION.TABLE_NAME_CASE_CHANGED keeps three remedies. Add @@map to keep the old table. In a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add the renameTable call the message gives. In a project that uses db update, run the target's by-hand statements, then db update again. The target writes the call: Postgres renders it through RenameTableCall and adds schema: "" when the table is outside the default schema or another schema declares the old name; SQLite has one namespace and never adds it. The Postgres tests take the suggested call, run it in a migration built from the same contracts, and check the rename statements. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../core/migrations/table-name-case-guard.ts | 20 ++- .../planner.table-name-case-guard.test.ts | 116 ++++++++++-------- .../planner.table-name-case-guard.test.ts | 4 +- 3 files changed, 86 insertions(+), 54 deletions(-) diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index 5eb3fad37f39..a8823f862ef4 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -19,6 +19,13 @@ export interface TableRenameByHand { readonly to: string; } +/** A table rename to write as a migration call; `namespaceId` is `undefined` when the call does not need to name the namespace. */ +export interface TableRenameInMigration { + readonly namespaceId: string | undefined; + readonly from: string; + readonly to: string; +} + interface PlannedTable { readonly namespaceId: string; readonly tableName: string; @@ -69,6 +76,7 @@ export function detectTableNameCaseChanges(input: { readonly tableOf: (issue: SchemaDiffIssue) => TableNameCaseGuardTable | undefined; readonly namespaceIdOf: (issue: SchemaDiffIssue) => string; readonly renameByHandStatements: (rename: TableRenameByHand) => readonly string[]; + readonly renameTableCall: (rename: TableRenameInMigration) => string; readonly contract: Contract; readonly defaultNamespaceId: string; }): SqlPlannerConflict[] { @@ -92,13 +100,17 @@ export function detectTableNameCaseChanges(input: { candidate.tableName === lowerFirst(create.tableName), ); if (drop === undefined) continue; - const renameFlag = renameNeedsNamespace(drop, dropped, input.contract, input.defaultNamespaceId) - ? `${drop.namespaceId}.${drop.tableName}=${create.namespaceId}.${create.tableName}` - : `${drop.tableName}=${create.tableName}`; + const renameCall = input.renameTableCall({ + namespaceId: renameNeedsNamespace(drop, dropped, input.contract, input.defaultNamespaceId) + ? drop.namespaceId + : undefined, + from: drop.tableName, + to: create.tableName, + }); conflicts.push({ kind: 'tableNameCaseChanged', summary: `${TABLE_NAME_CASE_CHANGED_CODE}: table "${create.tableName}" would be created and table "${drop.tableName}" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model ${create.tableName} points at "${create.tableName}" instead of "${drop.tableName}".`, - why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "${renameFlag}" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand with ${input.renameByHandStatements({ namespaceId: create.namespaceId, from: drop.tableName, to: create.tableName }).join('; ')}, then run db update again.`, + why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ${renameCall} to the migration's operations, which renames the table and the objects named after it; in a project that uses db update, rename it by hand with ${input.renameByHandStatements({ namespaceId: create.namespaceId, from: drop.tableName, to: create.tableName }).join('; ')}, then run db update again.`, location: { namespaceId: create.namespaceId, entityKind: 'table', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index 154a2dee0c94..6b60d7f1692e 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -11,8 +11,11 @@ */ import { type Contract, type ControlPolicy, coreHash, profileHash } from '@internal/contract/types'; -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import { APP_SPACE_ID, type StorageEntityRename } from '@internal/framework-components/control'; +import type { + ExecuteRequestLowerer, + SqlControlAdapter, +} from '@internal/family-sql/control-adapter'; +import { APP_SPACE_ID, type ControlStack } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; @@ -20,6 +23,8 @@ import { describe, expect, it } from 'vitest'; import { postgresResolveDefault } from '../../src/core/default-normalizer'; import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { PostgresMigration } from '../../src/core/migrations/postgres-migration'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; import { type PostgresContract, postgresCreateNamespace } from '../../src/core/postgres-schema'; import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; @@ -171,9 +176,9 @@ describe('Postgres planner table-name case guard', () => { ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); expect(result.conflicts[0]?.why).toContain( - 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table;', + 'in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" }) to the migration\'s operations, which renames the table and the objects named after it;', ); - expect(result.conflicts[0]?.why).not.toContain('one renameTable operation'); + expect(result.conflicts[0]?.why).not.toContain('--rename'); expect(result.conflicts[0]?.why).toContain( 'in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "UserProfile", then run db update again.', ); @@ -279,11 +284,7 @@ function namespacedContract( }; } -function planMigration( - from: PostgresContract, - to: PostgresContract, - renames: readonly StorageEntityRename[] = [], -) { +function planMigration(from: PostgresContract, to: PostgresContract) { return createPostgresMigrationPlanner(stubLowerer).plan({ contract: to, schema: contractToPostgresDatabaseSchemaNode(from, { @@ -296,77 +297,96 @@ function planMigration( frameworkComponents: [], spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', - ...(renames.length === 0 ? {} : { renames }), }); } -function suggestedRenameFlag(why: string | undefined): string | undefined { - return /prisma migration plan --rename "([^"]+)"/.exec(why ?? '')?.[1]; +type ContractJson = { readonly storage: { readonly storageHash: string } }; +type RenameTableOptions = { readonly schema?: string; readonly table: string; readonly to: string }; + +const stack = { + adapter: { create: () => stubLowerer as unknown as SqlControlAdapter<'postgres'> }, + target: { kind: 'target', familyId: 'sql', targetId: 'postgres' }, + extensions: [], +} as unknown as ControlStack<'sql', 'postgres'>; + +function jsonOf(contract: PostgresContract): ContractJson { + return new PostgresContractSerializer().serializeContract(contract) as unknown as ContractJson; } -function coordinate(side: string): StorageEntityRename['from'] { - const dot = side.indexOf('.'); - return dot === -1 - ? { name: side } - : { namespaceId: side.slice(0, dot), name: side.slice(dot + 1) }; +function suggestedRenameTable(why: string | undefined): { + readonly call: string; + readonly options: RenameTableOptions; +} { + const call = /\.\.\.this\.renameTable\(\{[^}]*\}\)/.exec(why ?? '')?.[0]; + if (call === undefined) throw new Error('the case guard suggested no renameTable call'); + const fields = Object.fromEntries( + [...call.matchAll(/(\w+): "([^"]*)"/g)].map(([, key, value]) => [key, value]), + ); + return { call, options: fields as unknown as RenameTableOptions }; } -function renameFromFlag(flag: string): StorageEntityRename { - const [from = '', to = ''] = flag.split('='); - return { from: coordinate(from), to: coordinate(to) }; +async function renameStatements( + from: PostgresContract, + to: PostgresContract, + options: RenameTableOptions, +): Promise { + const startJson = jsonOf(from); + const endJson = jsonOf(to); + class RenameMigration extends PostgresMigration { + override readonly startContractJson = startJson; + override readonly endContractJson = endJson; + override get operations() { + return [...this.renameTable(options)]; + } + } + const ops = await Promise.all(new RenameMigration(stack).operations); + return ops.map((op) => op.execute.map((step) => step.sql)); } -describe('the --rename the Postgres case guard suggests', () => { - async function planWithSuggestion(from: PostgresContract, to: PostgresContract) { +describe('the renameTable call the Postgres case guard suggests', () => { + async function renameWithSuggestion(from: PostgresContract, to: PostgresContract) { const refused = planMigration(from, to); expect(refused.kind).toBe('failure'); if (refused.kind !== 'failure') throw new Error('the case guard did not refuse'); - const flag = suggestedRenameFlag(refused.conflicts[0]?.why); - if (flag === undefined) throw new Error('the case guard suggested no --rename'); - const renamed = planMigration(from, to, [renameFromFlag(flag)]); - expect( - renamed.kind === 'failure' ? renamed.conflicts.map((conflict) => conflict.summary) : [], - ).toEqual([]); - if (renamed.kind !== 'success') throw new Error('planning with the suggestion failed'); - const statements = (await Promise.all(renamed.plan.operations)).map((op) => - op.execute.map((step) => step.sql), - ); - return { flag, statements }; + const { call, options } = suggestedRenameTable(refused.conflicts[0]?.why); + return { call, statements: await renameStatements(from, to, options) }; } - it('names the namespace when another namespace declares the old table name, and planning with it renames the table', async () => { - const result = await planWithSuggestion( + it('names the schema when another schema declares the old table name, and the call renames the table', async () => { + const result = await renameWithSuggestion( namespacedContract({ public: 'userProfile', auth: 'userProfile' }, 'two-namespaces-from'), namespacedContract({ public: 'UserProfile', auth: 'userProfile' }, 'two-namespaces-to'), ); expect(result).toEqual({ - flag: 'public.userProfile=public.UserProfile', + call: '...this.renameTable({ schema: "public", table: "userProfile", to: "UserProfile" })', statements: [['ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"']], }); }); - it('names the namespace when the table is not in the default namespace', async () => { - const result = await planWithSuggestion( + it('names the schema when the table is not in the default schema', async () => { + const result = await renameWithSuggestion( namespacedContract({ auth: 'userProfile' }, 'auth-from'), namespacedContract({ auth: 'UserProfile' }, 'auth-to'), ); - expect(result).toEqual({ - flag: 'auth.userProfile=auth.UserProfile', - statements: [['ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"']], - }); + expect(result.call).toBe( + '...this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', + ); + expect(result.statements[0]).toEqual([ + 'ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"', + ]); }); - it('stays unqualified for a table of the default namespace whose name no other namespace declares', async () => { - const result = await planWithSuggestion( + it('leaves the schema out for a table of the default schema whose name no other schema declares', async () => { + const result = await renameWithSuggestion( namespacedContract({ public: 'userProfile', auth: 'account' }, 'public-from'), namespacedContract({ public: 'UserProfile', auth: 'account' }, 'public-to'), ); - expect(result).toEqual({ - flag: 'userProfile=UserProfile', - statements: [['ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"']], - }); + expect(result.call).toBe('...this.renameTable({ table: "userProfile", to: "UserProfile" })'); + expect(result.statements[0]).toEqual([ + 'ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"', + ]); }); }); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts index b1f0e7a73f36..fdcbf82ef23c 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts @@ -138,9 +138,9 @@ describe('SQLite planner table-name case guard', () => { ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); expect(result.conflicts[0]?.why).toContain( - 'in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table;', + 'in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" }) to the migration\'s operations, which renames the table and the objects named after it;', ); - expect(result.conflicts[0]?.why).not.toContain('one renameTable operation'); + expect(result.conflicts[0]?.why).not.toContain('--rename'); }); it('gives a by-hand rename through a temporary name, because SQLite refuses a case-only rename in one statement', () => { From b1c2f5123856c8bed738e4f7fff3ada545d5544b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:10:53 +0200 Subject: [PATCH 50/55] test: rename journeys state the rename with migration new and this.renameTable The rename journeys used the removed --rename flag. They now author the rename by hand: migration new, then ...this.renameTable({ table: 'userProfile', to: 'UserProfile' }) in the scaffolded operations, then self-emit and migrate. A new journey helper, authorMigration, does the scaffold, edit and self-emit. Postgres R1 keeps its assertions: the guard refuses the bare plan and now points at renameTable; a call naming a table the end contract lacks fails at self-emit with the unmatched-rename message; after migrate the rows, constraints, indexes, policy and RLS follow the new name, verify is clean, a no-change plan is empty, and a follow-up removal migration applies. R2 only differed from R1 in how the migration was written, so it is merged into R1. SQLite R3 is rewritten the same way; the SQLite db update journey R4 is unchanged. R5 now changes the table name and the foreign key target in one schema change and writes a migration that only renames: migrate refuses it with MIGRATION.RUNNER_FAILED because the database does not satisfy the end contract, and the table is left as it was. Made as two changes, a rename-only migration and then a planned foreign key change, it keeps the rows and verifies clean. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/cli-journeys/README.md | 2 +- .../rename-table-migration.e2e.test.ts | 218 +++++++++--------- .../rename-table-migration.sqlite.e2e.test.ts | 62 ++--- .../test/utils/journey-test-helpers.ts | 27 +++ 4 files changed, 164 insertions(+), 145 deletions(-) diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 727f22e0b896..a70d9b4493ea 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -30,7 +30,7 @@ pnpm test:journeys | `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column, tests `--no-interactive` rejection, `--json` error envelope, and `--json -y` auto-accept. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | -| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard and a stale `--rename` intent fails planning, then `migration plan --rename userProfile=UserProfile` plans the `renameTable` plus a rename of each constraint and index named after the old table. Journey R2 authors the same rename through `migration new --rename`, whose scaffold carries the same operations. In both, `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. Journey R5 renames the table in the same migration that points its foreign key at another table under an explicit name: the foreign key is renamed after the new table, then dropped and created, the rows stay and `db verify --schema-only` is clean | +| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard, which points at `this.renameTable`, and that a `renameTable` call naming a table the end contract lacks fails when `migration.ts` builds its operations. A migration created with `migration new` and `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })` renames the table and each constraint and index named after it; `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. Journey R5 changes the table name and its foreign key target in one schema change: a rename-only migration for it fails at `migrate`, which verifies against the end contract; made as a rename-only change and then a planned foreign key change, it keeps the rows and verifies clean | | `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration. A second journey follows the by-hand path of a `db update` project: the case guard gives the two-statement rename through `_prisma_rename_UserProfile`, and after running it `db update` drops and recreates the indexes named after the old table, keeps the rows and verifies clean | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts index 43ad78d9ae91..3871ee8bfff4 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -1,24 +1,20 @@ /** * Renaming a table keeps its rows (Postgres). * - * A model whose table name changes used to plan as `dropTable` plus `createTable`. With `--rename =` the operator states the rename and the migration carries a `renameTable` operation instead, plus a rename of each constraint and index named after the old table. + * A model whose table name changes plans as `dropTable` plus `createTable`. A user who wants the rows makes the rename its own schema change, creates its migration with `migration new`, and writes `...this.renameTable({ table, to })`, which renames the table and each constraint and index named after the old table. * - * Both journeys create `userProfile` with rows, a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map` so it names `UserProfile`. After `migrate`, the rows, the policy and RLS are kept, every constraint and index carries the new table name, `db verify --schema-only` is clean, a plan with no schema change is empty, and a later migration that removes the unique constraint, the foreign key and the index applies. + * Journey R1 creates `userProfile` with rows, a unique constraint, a foreign key, an index, row-level security and a policy, then drops the model's `@@map` so it names `UserProfile`. Planning the change is refused by the case-change guard, which points at the `renameTable` call; a call naming a table the end contract lacks fails when `migration.ts` builds its operations. After `migrate` the rows, the policy and RLS are kept, every constraint and index carries the new table name, `db verify --schema-only` is clean, a plan with no schema change is empty, and a later migration that removes the unique constraint, the foreign key and the index applies. * - * Journey R1 (`migration plan`) also shows that planning without the flag is refused by the case-change guard and that a stale intent fails planning. - * - * Journey R2 (`migration new`) authors the rename by hand: the flag pre-fills the scaffolded `migration.ts` with the same operations `migration plan` would plan. - * - * Journey R5 renames the table in the same migration that points its foreign key at another table under an explicit name. The foreign key is renamed after the new table, then dropped and created against the new table; the rows stay and `db verify --schema-only` is clean. + * Journey R5 changes a table's name and its foreign key's target in one schema change. A hand-written migration that only renames fails at `migrate`, which verifies the database against the migration's end contract. Made as two changes, a rename-only migration and then a planned foreign key change, it keeps the rows and verifies clean. */ -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { + authorMigration, engineError, - getLatestMigrationDir, getMigrationDirs, type JourneyContext, latestMigrationDirName, @@ -27,9 +23,7 @@ import { runContractEmit, runDbVerify, runMigrate, - runMigrationNew, runMigrationPlan, - selfEmitMigration, setupJourney, sql, swapPslContract, @@ -57,6 +51,24 @@ model UserProfile { } `; +const FOREIGN_KEY_RENAMED_PSL = `// use prisma-8 + +model Account { + id Int @id + profiles UserProfile[] +} + +model Member { + id Int @id +} + +model UserProfile { + id Int @id + accountId Int + account Account @relation(fields: [accountId], references: [id]) +} +`; + const FOREIGN_KEY_TO_PSL = `// use prisma-8 model Account { @@ -75,9 +87,7 @@ model UserProfile { } `; -interface PlanDocument { - readonly operations: readonly { id: string; label: string; operationClass: string }[]; -} +const RENAME_CALL = "...this.renameTable({ table: 'userProfile', to: 'UserProfile' })"; const RENAME_LABELS = [ 'Rename table "userProfile" to "UserProfile"', @@ -199,12 +209,18 @@ async function expectLaterChangesApply( ).toBe(0); } +function operationsOf(ctx: JourneyContext, dirName: string) { + return JSON.parse( + readFileSync(join(ctx.testDir, 'migrations', 'app', dirName, 'ops.json'), 'utf-8'), + ) as readonly { readonly label: string; readonly operationClass: string }[]; +} + withTempDir(({ createTempDir }) => { - describe('Journey R1: rename a table with migration plan --rename', () => { + describe('Journey R1: rename a table with migration new and this.renameTable', () => { const db = useDevDatabase(); it( - 'guard refuses the bare plan; stale intent fails; stated intent renames the table and its named objects, keeps rows and policies, and later changes apply', + 'guard points at renameTable; a call naming a missing table fails; the call renames the table and its named objects, keeps rows and policies, and later changes apply', async () => { const ctx = setupJourney({ connectionString: db.connectionString, @@ -222,44 +238,32 @@ withTempDir(({ createTempDir }) => { ); expect( bareError?.nextActions?.map((action) => action.label).join('\n'), - 'R1.05: guard points at the flag', - ).toContain('prisma migration plan --rename "userProfile=UserProfile"'); + 'R1.05: guard points at migration new and renameTable', + ).toContain( + 'create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); expect(getMigrationDirs(ctx), 'R1.05: nothing written').toHaveLength(1); - const stale = await runMigrationPlan(ctx, [ - '--name', + const stale = await authorMigration( + ctx, 'stale', - '--from', - origin, - '--rename', - 'userProfile=Nope', - '--json', - ]); - expect(stale.exitCode, 'R1.06: stale intent is refused').not.toBe(0); - const staleError = engineError(stale); - expect(staleError?.code, 'R1.06: planning failure').toBe('MIGRATION.PLANNING_FAILED'); - expect(staleError?.why, 'R1.06: names the unmatched intent').toContain( - 'MIGRATION.TABLE_RENAME_UNMATCHED', + "...this.renameTable({ table: 'userProfile', to: 'Nope' })", ); - expect(getMigrationDirs(ctx), 'R1.06: nothing written').toHaveLength(1); + expect(stale.emit.exitCode, 'R1.06: a call naming a missing table fails').not.toBe(0); + expect(stale.emit.stderr, 'R1.06: names the unmatched rename').toContain( + 'renameTable "userProfile" to "Nope" does not match the migration\'s contracts: table "public.Nope" does not exist in the end contract.', + ); + rmSync(join(ctx.testDir, 'migrations', 'app', stale.dirName), { recursive: true }); - const plan = await planMigrationAndSelfEmit(ctx, [ - '--name', - 'rename-user-profile', - '--from', - origin, - '--rename', - 'userProfile=UserProfile', - '--json', - ]); - expect(plan.exitCode, `R1.07: plan with intent: ${plan.stderr}`).toBe(0); - const document = parseJsonOutput(plan); + const rename = await authorMigration(ctx, 'rename-user-profile', RENAME_CALL); + expect(rename.emit.exitCode, `R1.07: self-emit: ${rename.emit.stderr}`).toBe(0); + const ops = operationsOf(ctx, rename.dirName); expect( - document.operations.map((op) => op.label), + ops.map((op) => op.label), 'R1.07: the rename, then a rename of each object named after the old table', ).toEqual(RENAME_LABELS); expect( - document.operations.every((op) => op.operationClass === 'widening'), + ops.every((op) => op.operationClass === 'widening'), 'R1.07: every operation is a widening rename', ).toBe(true); @@ -272,58 +276,11 @@ withTempDir(({ createTempDir }) => { ); }); - describe('Journey R2: rename a table with migration new --rename', () => { + describe('Journey R5: rename a table, then point its foreign key at another table', () => { const db = useDevDatabase(); it( - 'the scaffold carries the same renames migration plan plans; migrate keeps rows and policies, and later changes apply', - async () => { - const ctx = setupJourney({ - connectionString: db.connectionString, - createTempDir, - contractMode: 'psl', - }); - await seedTableWithObjects(ctx, db.connectionString, 'R2'); - - const scaffold = await runMigrationNew(ctx, [ - '--name', - 'rename-user-profile', - '--rename', - 'userProfile=UserProfile', - ]); - expect(scaffold.exitCode, `R2.05: migration new: ${scaffold.stderr}`).toBe(0); - const latest = getLatestMigrationDir(ctx); - expect(latest, 'R2.05: scaffold dir').toBeDefined(); - const packageDir = join(ctx.testDir, 'migrations', 'app', latest!); - const migrationTs = readFileSync(join(packageDir, 'migration.ts'), 'utf-8'); - expect(migrationTs, 'R2.05: table rename call').toMatch(/this\.renameTable\(/); - expect(migrationTs, 'R2.05: constraint rename calls').toMatch(/this\.renameConstraint\(/); - expect(migrationTs, 'R2.05: index rename calls').toMatch(/this\.renameIndex\(/); - - const emitted = await selfEmitMigration(ctx, ['--dir', `migrations/app/${latest}`]); - expect(emitted.exitCode, `R2.06: self-emit: ${emitted.stderr}`).toBe(0); - const ops = JSON.parse(readFileSync(join(packageDir, 'ops.json'), 'utf-8')) as { - readonly label: string; - }[]; - expect( - ops.map((op) => op.label), - 'R2.06: the same renames migration plan plans', - ).toEqual(RENAME_LABELS); - - const apply = await runMigrate(ctx); - expect(apply.exitCode, `R2.07: migrate: ${apply.stderr}`).toBe(0); - await expectRenameApplied(ctx, db.connectionString, 'R2.08'); - await expectLaterChangesApply(ctx, db.connectionString, 'R2.09'); - }, - timeouts.spinUpPpgDev, - ); - }); - - describe('Journey R5: rename a table and point its foreign key at another table', () => { - const db = useDevDatabase(); - - it( - 'renames the foreign key after the new table, replaces it, keeps the rows, and verifies clean', + 'a rename migration that omits the foreign key change fails at migrate; a rename-only change, then a planned foreign key change, keeps the rows and verifies clean', async () => { const ctx = setupJourney({ connectionString: db.connectionString, @@ -345,31 +302,66 @@ withTempDir(({ createTempDir }) => { ); writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_TO_PSL); + const emitBoth = await runContractEmit(ctx); + expect(emitBoth.exitCode, `R5.04: emit both changes: ${emitBoth.stderr}`).toBe(0); + const incomplete = await authorMigration(ctx, 'rename-and-retarget', RENAME_CALL); + expect(incomplete.emit.exitCode, `R5.05: self-emit: ${incomplete.emit.stderr}`).toBe(0); + const refused = await runMigrate(ctx, ['--json']); + expect(refused.exitCode, 'R5.06: migrate refuses the incomplete migration').not.toBe(0); + expect( + engineError(refused), + 'R5.06: the database does not match the end contract', + ).toMatchObject({ + code: 'MIGRATION.RUNNER_FAILED', + summary: expect.stringContaining('Database schema does not satisfy contract'), + }); + const untouched = await sql( + db.connectionString, + `SELECT to_regclass('"public"."userProfile"')::text AS old, to_regclass('"public"."UserProfile"')::text AS new`, + ); + expect(untouched.rows[0], 'R5.06: the failed migration left the table as it was').toEqual({ + old: '"userProfile"', + new: null, + }); + rmSync(join(ctx.testDir, 'migrations', 'app', incomplete.dirName), { recursive: true }); + expect( + existsSync(join(ctx.testDir, 'migrations', 'app', incomplete.dirName)), + 'R5.06: the incomplete migration is removed', + ).toBe(false); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_RENAMED_PSL); const emitRenamed = await runContractEmit(ctx); - expect(emitRenamed.exitCode, `R5.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); - const plan = await planMigrationAndSelfEmit(ctx, [ - '--name', - 'rename-and-retarget', - '--from', - latestMigrationDirName(ctx), - '--rename', - 'userProfile=UserProfile', - '--json', - ]); - expect(plan.exitCode, `R5.05: plan: ${plan.stderr}`).toBe(0); + expect(emitRenamed.exitCode, `R5.07: emit the rename alone: ${emitRenamed.stderr}`).toBe(0); + const rename = await authorMigration(ctx, 'rename-user-profile', RENAME_CALL); + expect(rename.emit.exitCode, `R5.07: self-emit: ${rename.emit.stderr}`).toBe(0); expect( - parseJsonOutput(plan) - .operations.map((op) => op.label) - .slice(0, 3), - 'R5.05: the table rename, then its keys renamed after the new table', + operationsOf(ctx, rename.dirName).map((op) => op.label), + 'R5.07: the rename', ).toEqual([ 'Rename table "userProfile" to "UserProfile"', 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + 'Rename index "userProfile_accountId_idx_cbfb3085" to "UserProfile_accountId_idx_cbfb3085" on "UserProfile"', ]); + const applyRename = await runMigrate(ctx); + expect(applyRename.exitCode, `R5.08: migrate the rename: ${applyRename.stderr}`).toBe(0); - const apply = await runMigrate(ctx); - expect(apply.exitCode, `R5.06: migrate: ${apply.stderr}`).toBe(0); + writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_TO_PSL); + const emitRetarget = await runContractEmit(ctx); + expect( + emitRetarget.exitCode, + `R5.09: emit the foreign key change: ${emitRetarget.stderr}`, + ).toBe(0); + const retarget = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'retarget-foreign-key', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(retarget.exitCode, `R5.09: plan the foreign key change: ${retarget.stderr}`).toBe(0); + const applyRetarget = await runMigrate(ctx); + expect(applyRetarget.exitCode, `R5.10: migrate: ${applyRetarget.stderr}`).toBe(0); const live = await sql( db.connectionString, `SELECT @@ -378,12 +370,12 @@ withTempDir(({ createTempDir }) => { FROM pg_constraint WHERE conrelid = '"public"."UserProfile"'::regclass AND contype = 'f') AS foreign_keys`, ); - expect(live.rows[0], 'R5.07: rows kept, one foreign key to Member').toEqual({ + expect(live.rows[0], 'R5.11: rows kept, one foreign key to Member').toEqual({ ids: [1], foreign_keys: ['profile_member_fk->"Member"'], }); const verify = await runDbVerify(ctx, ['--schema-only']); - expect(verify.exitCode, `R5.08: db verify --schema-only: ${verify.stderr}`).toBe(0); + expect(verify.exitCode, `R5.12: db verify --schema-only: ${verify.stderr}`).toBe(0); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts index a8652d804bf7..ffac237dc85a 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -1,17 +1,18 @@ /** * Renaming a table keeps its rows (SQLite). * - * The SQLite twin of `rename-table-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. Journey R3: create `userProfile` with rows, a unique constraint, a foreign key and an index, then drop the `@@map` so the model names `UserProfile`. `migration plan --rename` plans the rename plus a drop and a create of each index named after the old table; `migrate` keeps the rows; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. + * The SQLite twin of `rename-table-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. Journey R3: create `userProfile` with rows, a unique constraint, a foreign key and an index, then drop the `@@map` so the model names `UserProfile`. Planning the change is refused by the case guard, which points at the `renameTable` call; a call naming a table the end contract lacks fails when `migration.ts` builds its operations. A migration created with `migration new` and `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })` renames the table and drops and recreates each index named after the old table; `migrate` keeps the rows; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. * * Journey R4 follows the by-hand path of a project managed with `db update`: the case guard refuses and gives the two-statement rename, the statements are run by hand, and `db update` then drops each index named after the old table before creating it under the new name, keeping the rows. */ -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import { DatabaseSync } from 'node:sqlite'; import { join } from 'pathe'; import { describe, expect, it } from 'vitest'; import { withTempDir, writeProjectManifest } from '../utils/cli-test-helpers'; import { + authorMigration, engineError, getMigrationDirs, type JourneyContext, @@ -78,10 +79,6 @@ model UserProfile { } `; -interface PlanDocument { - readonly operations: readonly { id: string; label: string; operationClass: string }[]; -} - function setupSqliteJourney(createTempDir: () => string): JourneyContext & { dbPath: string } { const testDir = createTempDir(); const dbPath = join(testDir, 'journey.db'); @@ -103,9 +100,9 @@ function withDatabase(dbPath: string, run: (db: DatabaseSync) => T): T { } withTempDir(({ createTempDir }) => { - describe('Journey R3 (SQLite): rename a table with migration plan --rename', () => { + describe('Journey R3 (SQLite): rename a table with migration new and this.renameTable', () => { it( - 'stale intent fails; stated intent renames the table and recreates its named indexes, keeps the rows, and later changes apply', + 'guard points at renameTable; a call naming a missing table fails; the call renames the table and recreates its named indexes, keeps the rows, and later changes apply', async () => { const ctx = setupSqliteJourney(createTempDir); @@ -133,35 +130,38 @@ withTempDir(({ createTempDir }) => { expect(engineError(bare)?.why, 'R3.05: guard names the case change').toContain( 'MIGRATION.TABLE_NAME_CASE_CHANGED', ); + expect( + engineError(bare) + ?.nextActions?.map((action) => action.label) + .join('\n'), + 'R3.05: guard points at migration new and renameTable', + ).toContain( + 'create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); - const stale = await runMigrationPlan(ctx, [ - '--name', + const stale = await authorMigration( + ctx, 'stale', - '--from', - origin, - '--rename', - 'userProfile=Nope', - '--json', - ]); - expect(stale.exitCode, 'R3.06: stale intent is refused').not.toBe(0); - expect(engineError(stale)?.why, 'R3.06: names the unmatched intent').toContain( - 'MIGRATION.TABLE_RENAME_UNMATCHED', + "...this.renameTable({ table: 'userProfile', to: 'Nope' })", + ); + expect(stale.emit.exitCode, 'R3.06: a call naming a missing table fails').not.toBe(0); + expect(stale.emit.stderr, 'R3.06: names the unmatched rename').toContain( + 'renameTable "userProfile" to "Nope" does not match the migration\'s contracts: table "Nope" does not exist in the end contract.', ); - expect(getMigrationDirs(ctx), 'R3.06: nothing written').toHaveLength(1); + rmSync(join(ctx.testDir, 'migrations', 'app', stale.dirName), { recursive: true }); + expect(getMigrationDirs(ctx), 'R3.06: only the initial migration remains').toHaveLength(1); - const plan = await planMigrationAndSelfEmit(ctx, [ - '--name', + const rename = await authorMigration( + ctx, 'rename-user-profile', - '--from', - origin, - '--rename', - 'userProfile=UserProfile', - '--json', - ]); - expect(plan.exitCode, `R3.07: plan with intent: ${plan.stderr}`).toBe(0); - const document = parseJsonOutput(plan); + "...this.renameTable({ table: 'userProfile', to: 'UserProfile' })", + ); + expect(rename.emit.exitCode, `R3.07: self-emit: ${rename.emit.stderr}`).toBe(0); + const ops = JSON.parse( + readFileSync(join(ctx.testDir, 'migrations', 'app', rename.dirName, 'ops.json'), 'utf-8'), + ) as readonly { readonly label: string }[]; expect( - document.operations.map((op) => op.label), + ops.map((op) => op.label), 'R3.07: the rename, then each index named after the old table dropped and recreated', ).toEqual([ 'Rename table userProfile to UserProfile', diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index a783b8bb6331..6d07a0626bc3 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -377,6 +377,33 @@ export async function runMigrationNew( return runOnEngine(ctx, ['migration', 'new', ...extraArgs], options); } +/** + * Authors a migration by hand: `migration new` scaffolds `migration.ts`, the given source replaces the body of its `operations` array, and `migration.ts` is run to write `ops.json` and `migration.json`. Returns the directory name and the self-emit result, which carries the error when building the operations fails. + */ +export async function authorMigration( + ctx: JourneyContext, + name: string, + operationsSource: string, +): Promise<{ readonly dirName: string; readonly emit: CommandResult }> { + const scaffold = await runMigrationNew(ctx, ['--name', name]); + if (scaffold.exitCode !== 0) { + throw new Error(`authorMigration: migration new failed: ${scaffold.stderr}`); + } + const dirName = latestMigrationDirName(ctx); + const migrationTsPath = join(appMigrationsDir(ctx), dirName, 'migration.ts'); + const scaffolded = readFileSync(migrationTsPath, 'utf-8'); + const authored = scaffolded.replace( + /return \[[\s\S]*?\];/, + () => `return [\n ${operationsSource},\n ];`, + ); + if (authored === scaffolded) { + throw new Error('authorMigration: the scaffold has no operations array to fill'); + } + writeFileSync(migrationTsPath, authored, 'utf-8'); + const emit = await selfEmitMigration(ctx, ['--dir', `migrations/app/${dirName}`]); + return { dirName, emit }; +} + export async function runMigrate( ctx: JourneyContext, extraArgs: readonly string[] = [], From 6a0b34e7081a4f087afa2f67bf79bc3112e8551f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:12:19 +0200 Subject: [PATCH 51/55] docs(upgrade): verbatim table name fragments state a rename in a hand-written migration Both psl-verbatim-table-names fragments described the removed --rename flag. The guard quote is replaced with the text the planner now produces, taken from a run on Postgres and SQLite. The application guide: with migration history, make the rename its own schema change, create its migration with prisma migration new, spread this.renameTable into its operations, self-emit and run db migrate, then make other edits and plan them; db migrate checks the end contract, so a rename migration that omits other edits fails. With db update, the by-hand statements, now schema-qualified like the message. On Mongo, rename the collection by hand. The extension guide ships the hand-written rename migration from the contract space: add it to contractSpace.migrations and move the head ref hash. It keeps the caveat that this needs the package's history in migrations/app/; with migrations directly under migrations/, migration new sees no history, the migration has no start contract, and renameTable refuses. Mongo contract spaces keep @@map. The codemod, the detection and the rename-constraint-call fragment are unchanged. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../app/instructions.md | 34 ++++++++++++------- .../extension/instructions.md | 22 ++++++++---- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md index c6e3c62cee60..8d995369d7b0 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md @@ -37,38 +37,48 @@ If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma mi ```text ✘ [MIGRATION.PLANNING_FAILED] Migration planning failed why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile". -→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "UserProfile", then run db update again. +→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" }) to the migration's operations, which renames the table and the objects named after it; in a project that uses db update, rename it by hand with ALTER TABLE "public"."userProfile" RENAME TO "UserProfile", then run db update again. ``` -That is the Postgres output. On SQLite the last clause gives the two statements SQLite needs for a rename that only changes case: `in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.` +That is the Postgres output for a model in the default `public` schema. On SQLite the last clause gives the two statements SQLite needs for a rename that only changes case: `in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.` The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and rename the storage. -**Postgres or SQLite, with migration history.** State each rename when you plan the migration: +**Postgres or SQLite, with migration history.** Make the rename its own schema change: remove the `@@map` from the models you rename, change nothing else, and emit the contract. Create a migration for the change: ```bash -prisma migration plan --rename "userProfile=UserProfile" +prisma migration new --name rename-user-profile ``` -Repeat `--rename` once per model, and write either side as `.` for a table in a named schema. The plan renames the table and the constraints and indexes named after it, and the rows stay. `prisma migration new --rename "userProfile=UserProfile"` puts the same operations into a hand-written migration. Without migration history the flag is refused with `MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT`; rename by hand as below. +In the new `migration.ts`, spread one `renameTable` call per renamed table into the operations: + +```ts +override get operations() { + return [...this.renameTable({ table: 'userProfile', to: 'UserProfile' })]; +} +``` + +On Postgres, add `schema: 'auth'` when the table is not in the default schema or another schema has a table with the same name. The call reads the migration's start and end contracts. It renames the table, then each primary key, unique constraint, foreign key, index and check constraint named after the old table; on SQLite it drops each such index and creates it under the new name. Run `node migrations/app//migration.ts` to write the operations, then `prisma db migrate`. The rows stay. + +Make your other schema edits afterwards and plan them as usual. `db migrate` checks the database against the migration's end contract, so a rename migration that leaves out other edits from the same schema change fails there. **Postgres, managed with `db update`.** Rename the table by hand, then run `prisma db update`: ```sql -ALTER TABLE "userProfile" RENAME TO "UserProfile"; +ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"; ``` -Inside a named schema, qualify the old name: `ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"`. The rows stay. Until `db update` runs, `db verify --schema-only` reports the indexes named after the old table as missing; `db update` renames them, and after that `db verify --schema-only` is clean. Primary keys, unique constraints and foreign keys that you did not name with `map:` keep the names derived from the old table, and neither `db verify` nor `db update` reports it. Rename them as well, because a migration you plan later derives these names from the new table name: +For a table in another schema, write that schema: `ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"`. The rows stay. Until `db update` runs, `db verify --schema-only` reports the indexes named after the old table as missing; `db update` renames them, and after that `db verify --schema-only` is clean. Primary keys, unique constraints and foreign keys that you did not name with `map:` keep the names derived from the old table, and neither `db verify` nor `db update` reports it. Rename them as well, because a migration you plan later derives these names from the new table name: ```sql -ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"; -ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__key" TO "UserProfile__key"; -ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile__fkey" TO "UserProfile__fkey"; +ALTER TABLE "public"."UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"; +ALTER TABLE "public"."UserProfile" RENAME CONSTRAINT "userProfile__key" TO "UserProfile__key"; +ALTER TABLE "public"."UserProfile" RENAME CONSTRAINT "userProfile__fkey" TO "UserProfile__fkey"; ``` -`` is the constraint's column names joined with `_`, such as `email` or `tenantId_email`. Add one statement per unique constraint and per foreign key. Inside a named schema, write the table as `"auth"."UserProfile"`. +`` is the constraint's column names joined with `_`, such as `email` or `tenantId_email`. Add one statement per unique constraint and per foreign key. **SQLite, managed with `db update`.** SQLite refuses in one statement a rename that only changes case, so rename the table through a temporary name, then run `prisma db update`: @@ -79,7 +89,7 @@ ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"; The rows stay, and SQLite renames its automatic indexes itself. `db update` drops each index named after the old table and creates it under the new name. Dropping an index needs your consent: `db update` asks for it, or, without a terminal, takes the database name it prints with `--confirm`. After that `db verify --schema-only` is clean. -**MongoDB.** `--rename` is refused with `MIGRATION.RENAME_UNSUPPORTED`. Rename the collection by hand before you plan: +**MongoDB.** Rename the collection by hand before you plan: ```js db.userProfile.renameCollection("UserProfile") diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md index 355206c6dff6..c8a93c4a2542 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md @@ -37,25 +37,33 @@ If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma mi ```text ✘ [MIGRATION.PLANNING_FAILED] Migration planning failed why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile". -→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, state the rename when planning: prisma migration plan --rename "userProfile=UserProfile" (either side may be .), and the plan renames the table and the objects named after it instead of dropping and recreating the table; in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "UserProfile", then run db update again. +→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" }) to the migration's operations, which renames the table and the objects named after it; in a project that uses db update, rename it by hand with ALTER TABLE "public"."userProfile" RENAME TO "UserProfile", then run db update again. ``` -That is the Postgres output. On SQLite the last clause gives the two statements SQLite needs for a rename that only changes case: `in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.` +That is the Postgres output for a model in the default `public` schema. On SQLite the last clause gives the two statements SQLite needs for a rename that only changes case: `in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.` The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and ship the rename as a migration of your contract space. Applications change your tables only by running the migrations your descriptor lists in `contractSpace.migrations`: their `prisma migration plan` copies each one into `migrations//`, and `prisma db migrate` applies it. If you rename the table by hand in your own database and run `db update`, only that database changes. Every application keeps the old table. -**Postgres or SQLite.** From the extension package root, plan the rename from the package's latest migration: +**Postgres or SQLite.** Make the rename its own schema change: remove the `@@map` from the models you rename, change nothing else, and run the package's contract-space build. From the extension package root, create a migration for the change: ```bash -prisma migration plan --from --rename "userProfile=UserProfile" +prisma migration new --name rename-user-profile ``` -Repeat `--rename` once per model, and write either side as `.` for a table in a named schema. `--from` is needed because the package has no database, so it has no `db` ref. The new migration renames the table and the constraints and indexes named after it, and the rows stay. Add the new migration's `migration.json` and `ops.json` to `contractSpace.migrations` in the descriptor. In the head ref the descriptor passes as `headRef`, set `hash` to the new migration's `to` hash and keep `invariants` as they are. Applications get the rename when they upgrade the extension and run `prisma migration plan` and `prisma db migrate`, and their rows stay. +In the new `migration.ts`, spread one `renameTable` call per renamed table into the operations: -This works only if the package's migration history is in `migrations/app/`, where `migration plan` reads and writes it. If the package keeps its migration directories directly under `migrations/`, as Prisma's own extension packages do, `migration plan` cannot see that history. Without `--from` it refuses the flag with `MIGRATION.TABLE_RENAME_NO_PREVIOUS_CONTRACT`, and `--from` fails with `MIGRATION.REF_NOT_FOUND` for a directory name or `MIGRATION.SNAPSHOT_MISSING` for a hash. A deliberate rename is not supported for that layout yet, so keep the `@@map`. +```ts +override get operations() { + return [...this.renameTable({ table: 'userProfile', to: 'UserProfile' })]; +} +``` + +On Postgres, add `schema: 'auth'` when the table is not in the default schema or another schema has a table with the same name. The call renames the table and the constraints and indexes named after it, and the rows stay. Run `node migrations/app//migration.ts` to write the operations. Add the new migration's `migration.json` and `ops.json` to `contractSpace.migrations` in the descriptor. In the head ref the descriptor passes as `headRef`, set `hash` to the new migration's `to` hash and keep `invariants` as they are. Applications get the rename when they upgrade the extension and run `prisma migration plan` and `prisma db migrate`, and their rows stay. + +This works only if the package's migration history is in `migrations/app/`, where `migration new` reads and writes it. If the package keeps its migration directories directly under `migrations/`, as Prisma's own extension packages do, `migration new` does not see that history, the new migration has no start contract, and `renameTable` refuses with `MIGRATION.TABLE_RENAME_UNMATCHED`. A deliberate rename is not supported for that layout yet, so keep the `@@map`. -**MongoDB.** `--rename` is refused with `MIGRATION.RENAME_UNSUPPORTED`, so a deliberate rename is not supported for a Mongo contract space yet. Keep the `@@map`. +**MongoDB.** A Mongo contract space has no rename operation, so a deliberate rename is not supported there yet. Keep the `@@map`. `contract infer` follows the same rule: a table whose name already equals the model name (`"UserProfile"`, `"User"`) infers to a model with no `@@map` and verifies clean, where the previous output pointed the model at a lowercase table that did not exist. A snake_case table still infers with `@@map("user_profile")`. There is nothing to detect for this: the inferred text for such a table is the same as before, it is now correct. From a4edc9ec1f7b0583f9c14afde44accaac38354ec Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:13:02 +0200 Subject: [PATCH 52/55] chore(migration): drop leftover rename-intent wording An internal error message still named applyTableRenameIntents, a SQLite planner comment still said table renames run first in the plan, and the Postgres schema helper's doc still described the planner re-deriving its tree after rename intents. None of these apply now that renames come from this.renameTable. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../9-family/src/core/migrations/apply-table-rename.ts | 4 ++-- .../src/core/migrations/postgres-contract-to-schema.ts | 6 +++--- .../3-targets/sqlite/src/core/migrations/planner.ts | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts index 4f6b0a4ce4b1..54e0e7da6f15 100644 --- a/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts +++ b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts @@ -199,7 +199,7 @@ function renameTablesInNamespace( if (untouched) return namespace; if (!isMaterializedSqlNamespace(namespace)) { throw new InternalError( - `applyTableRenameIntents: namespace "${namespace.id}" is not a materialized SQL namespace`, + `applyTableRename: namespace "${namespace.id}" is not a materialized SQL namespace`, ); } const renamedTables: SqlNamespaceEntries = { @@ -229,7 +229,7 @@ function renameTablesInContract( for (const [id, namespace] of Object.entries(namespaces)) { if (!isMaterializedSqlNamespace(namespace)) { throw new InternalError( - `applyTableRenameIntents: namespace "${id}" is not a materialized SQL namespace`, + `applyTableRename: namespace "${id}" is not a materialized SQL namespace`, ); } materialized[id] = namespace; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts index d51e273069f2..63f74433e355 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts @@ -19,9 +19,9 @@ export function postgresRenderDefault(def: ColumnDefault, column: StorageColumn) /** * The Postgres schema tree a contract describes, as the planner's "from" side: - * the target descriptor's `migrations.contractToSchema` hook and the planner's - * own re-derivation after applying rename intents both go through here so the - * two trees are built with the same expander, default renderer and resolver. + * the target descriptor's `migrations.contractToSchema` hook and the trees + * `renameTable` compares to find its companion renames both go through here, + * so they are built with the same expander, default renderer and resolver. */ export function postgresContractToSchema( contract: Contract | null, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index f5a604fd896d..7c3ad096337b 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -195,8 +195,7 @@ export class SqliteMigrationPlanner }); // Codec-emitted calls already conform to `OpFactoryCall` — render + // toOp + importRequirements ride directly through the same emit path - // as structural ops, no `RawSqlCall` wrap. The table renames run first: - // every later operation addresses the renamed table by its new name. + // as structural ops, no `RawSqlCall` wrap. const calls = [...replacedIndexes.calls, ...result.value.calls, ...fieldEventOps]; const destination: SqliteMigrationDestinationInfo = { From bdb2ddecd23f057c05a078ac5ecbca092a2a8cac Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:37:29 +0200 Subject: [PATCH 53/55] docs(projects): slice 2 spec renames only objects the migration leaves unchanged Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/rename-table-operation/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md index 33720b148e5b..5d047c9248aa 100644 --- a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md +++ b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md @@ -16,7 +16,7 @@ this.renameTable({ table: 'userProfile', to: 'UserProfile' }) **A rename is stated in a hand-written migration, never inferred and never stated on the command line.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The documented long-term design is a planner hint in the contract source, `@hint(was: ...)` (Data Contract and Migration System subsystem docs, ADR 001); it is not implemented and is a follow-up outside this project. Until it exists, the way to state a rename is the one the migration system already offers for anything the planner cannot infer: a hand-written migration. -**`this.renameTable` emits every rename the table needs.** The migration facade method reads the migration's start and end contracts and emits the table rename followed by a rename for each object on that table whose name is derived from the table name: unnamed primary keys, unique constraints and foreign keys on Postgres; indexes and check constraints whose derived prefix comes from the table name; default-named indexes on SQLite, dropped and recreated because SQLite cannot rename an index. A constraint takes the end contract's explicit name only when it is otherwise unchanged; otherwise it takes the name derived from the new table name. Explicitly named objects and foreign keys on other tables keep their names. On Postgres the method also carries the table's row-level security settings and policies to the new name where the contract refers to them by table name. If the start contract has no such table, or the end contract has no table under the new name, the method refuses with `MIGRATION.TABLE_RENAME_UNMATCHED`. +**`this.renameTable` emits every rename the table needs.** The migration facade method reads the migration's start and end contracts and emits the table rename followed by a rename for each object on that table whose name is derived from the table name: unnamed primary keys, unique constraints and foreign keys on Postgres; indexes and check constraints whose derived prefix comes from the table name; default-named indexes on SQLite, dropped and recreated because SQLite cannot rename an index. Only objects the end contract leaves otherwise unchanged are renamed; such an object takes the end contract's explicit name if it has one, otherwise the name derived from the new table name. An object the end contract also changes keeps the name the database has, so an author who writes that change by hand refers to it by that name. Amended after review: an earlier rule renamed changed constraints to the derived name, which only made sense while a planned drop followed. Explicitly named objects and foreign keys on other tables keep their names. On Postgres the method also carries the table's row-level security settings and policies to the new name where the contract refers to them by table name. If the start contract has no such table, or the end contract has no table under the new name, the method refuses with `MIGRATION.TABLE_RENAME_UNMATCHED`. **The rename is its own schema change.** `migrate` verifies the database against the migration's end contract, so a hand-written migration that renames a table but omits other edits made in the same change fails loudly at `migrate`. The guide tells users to rename first, then make other edits and plan them. @@ -35,7 +35,7 @@ this.renameTable({ table: 'userProfile', to: 'UserProfile' }) ## Tests, all red before their change - Operation, both targets: rendered SQL, prechecks, postchecks, TypeScript rendering round-trip. -- Facade, both targets: `this.renameTable` emits the table rename plus the companion renames for a table with an unnamed primary key, a unique constraint, a foreign key, an index and a check; a constraint that changed keeps the derived name; an explicitly named object is left alone; an unknown table refuses. +- Facade, both targets: `this.renameTable` emits the table rename plus the companion renames for a table with an unnamed primary key, a unique constraint, a foreign key, an index and a check; a constraint the end contract also changes keeps its current name; an explicitly named object is left alone; an unknown table refuses. - Guard, both targets: the three remedies and the target's by-hand statements. - Journeys under `test/integration/test/cli-journeys/`: Postgres and SQLite, `migration new` plus a hand-written `this.renameTable` on a table with rows, a unique constraint, a foreign key and an index (and row-level security and a policy on Postgres); after `migrate` the rows and objects are present, a plan with no schema change is empty, `db verify --schema-only` is clean, and a follow-up migration removing those objects applies. The SQLite `db update` by-hand path journey stays. From e8292e9bdb567b44d7a90da9760c3141612bf0fa Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:39:41 +0200 Subject: [PATCH 54/55] fix(migration): renameTable leaves a constraint the end contract changes under its current name On Postgres, this.renameTable renamed an unnamed primary key, unique constraint or foreign key even when the end contract changed it, to the name derived from the new table. That rule came from the removed --rename planner path, where the diff dropped the changed constraint right after under that derived name. In a hand-written migration no such drop follows, so an author who also changed the constraint had to drop it under a derived name they could not see. Constraint companions now follow the rule indexes and checks already follow: only a constraint the end contract leaves unchanged is renamed, to the end contract's explicit name if it has one, otherwise to the name derived from the new table. A constraint the end contract changes is not renamed and keeps its database name. The doc comment that gave the old reason is rewritten, and so is its stale wording about stated renames. The two facade tests for a changed foreign key and a changed primary key now expect the table rename alone. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/table-rename-calls.ts | 2 +- .../table-rename-constraint-renames.ts | 7 ++++--- .../test/migrations/rename-table-facade.test.ts | 16 +++++----------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts index ca9aaf04f5a9..68ee25ad4d79 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts @@ -44,7 +44,7 @@ function renamedTableNode( } /** - * The calls a migration's `renameTable` emits: the table rename, then a rename of each primary key, unique constraint and foreign key the start contract left unnamed, and of each wire-named index and check whose prefix derives from the table name. A constraint takes the end contract's explicit name only when the end contract leaves it otherwise unchanged. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + * The calls a migration's `renameTable` emits: the table rename, then a rename of each primary key, unique constraint and foreign key the start contract left unnamed, and of each wire-named index and check whose prefix derives from the table name. Only objects the end contract leaves otherwise unchanged are renamed; an unchanged constraint takes the end contract's explicit name if it has one. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. */ export function postgresTableRenameCalls(input: { readonly startContract: Contract | null; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts index 109e6466e2f4..995aabd78966 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts @@ -11,7 +11,7 @@ export interface TableRenameConstraintInput { readonly schemaName: string; readonly from: string; readonly to: string; - /** The renamed table as the previous contract describes it once every stated rename is applied. */ + /** The renamed table as the start contract describes it, under its new name. */ readonly previous: PostgresTableSchemaNode; readonly next: PostgresTableSchemaNode; } @@ -25,7 +25,7 @@ function unchangedIn( } /** - * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the previous contract left unnamed carries a name the planner derived from the old table name. When the next contract keeps the same constraint, it is renamed to the name the next contract gives it explicitly, or otherwise to the name the planner now derives from the new table name. When the constraint changed, it is renamed to the derived name, which is the name the diff's drop of it uses. A constraint the previous contract named keeps its name. Indexes and checks are not handled here: their wire names pair by content hash in the ordinary rename passes. + * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the start contract left unnamed carries a name derived from the old table name. When the end contract keeps the same constraint unchanged, it is renamed to the name the end contract gives it explicitly, or otherwise to the name derived from the new table name. A constraint the end contract changes is not renamed, so it keeps its name in the database. A constraint the start contract named keeps its name. Indexes and checks are not handled here: their wire names pair by content hash in the index and check rename passes. */ export function constraintRenamesForTableRename( input: TableRenameConstraintInput, @@ -37,7 +37,8 @@ export function constraintRenamesForTableRename( unchanged: { readonly name?: string } | undefined, derivedName: string, ): readonly RenameConstraintCall[] => { - const newName = unchanged?.name ?? derivedName; + if (unchanged === undefined) return []; + const newName = unchanged.name ?? derivedName; return oldName === newName ? [] : [new RenameConstraintCall(schemaName, to, kind, oldName, newName)]; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts index 695ba735f070..06e64d6b4b0b 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts @@ -1,5 +1,5 @@ /** - * `this.renameTable` in a hand-written Postgres migration. It reads the migration's start and end contracts and emits the table rename, then a rename of each object on the table whose name the planner derived from the old table name: unnamed primary keys, unique constraints and foreign keys, and wire-named indexes and checks. A constraint the end contract changes takes the name derived from the new table name. Explicitly named objects keep their names. A table missing from either contract is refused. + * `this.renameTable` in a hand-written Postgres migration. It reads the migration's start and end contracts and emits the table rename, then a rename of each object on the table whose name the planner derived from the old table name: unnamed primary keys, unique constraints and foreign keys, and wire-named indexes and checks. Only objects the end contract leaves otherwise unchanged are renamed; a constraint the end contract also changes keeps its name. Explicitly named objects keep their names. A table missing from either contract is refused. */ import { type Contract, coreHash, profileHash } from '@internal/contract/types'; @@ -195,7 +195,7 @@ describe('PostgresMigration.renameTable', () => { ]); }); - it('renames a constraint the end contract changes to the name derived from the new table', async () => { + it('leaves a foreign key the end contract points at another table under its current name', async () => { const memberTable = () => ({ member: new StorageTable({ columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, @@ -228,22 +228,16 @@ describe('PostgresMigration.renameTable', () => { ), ); - expect(ops.map((op) => op.label)).toEqual([ - 'Rename table "userProfile" to "UserProfile"', - 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', - ]); + expect(ops.map((op) => op.label)).toEqual(['Rename table "userProfile" to "UserProfile"']); }); - it('renames a primary key whose columns the end contract changes to the name derived from the new table', async () => { + it('leaves a primary key whose columns the end contract changes under its current name', async () => { expect( await renameLabels( { primaryKey: { columns: ['id'] } }, { primaryKey: { columns: ['id', 'email'], name: 'profile_pk' } }, ), - ).toEqual([ - 'Rename table "userProfile" to "UserProfile"', - 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', - ]); + ).toEqual(['Rename table "userProfile" to "UserProfile"']); }); it('leaves explicitly named objects alone', async () => { From 6d3d0870342213661f79408d076eb05a6e16670b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 17:39:44 +0200 Subject: [PATCH 55/55] refactor(migration): remove leftovers of the multi-rename planner path The Postgres planner kept fromContract and previousSchema as plain aliases of options.fromContract and options.schema, and the SQLite planner kept a fromContract alias; they held the renamed contract and tree when the planners applied renames. Both planners read the options directly again, as on main. applyTableRename passed a one-element array of renames through the namespace walk. The walk now takes the single rename. TABLE_RENAME_UNMATCHED_CODE is no longer exported from the family's control entry; its only consumer outside its module is the family's own test, which imports the module by relative path. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/apply-table-rename.ts | 32 ++++++++++--------- .../2-sql/9-family/src/exports/control.ts | 5 +-- .../postgres/src/core/migrations/planner.ts | 15 ++++----- .../sqlite/src/core/migrations/planner.ts | 4 +-- 4 files changed, 25 insertions(+), 31 deletions(-) diff --git a/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts index 54e0e7da6f15..74ad2397aee8 100644 --- a/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts +++ b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts @@ -184,17 +184,19 @@ function withEntries(namespace: SqlNamespaceBase, entries: SqlNamespaceEntries): return Object.freeze(copy); } -function renameTablesInNamespace( +function renameTableInNamespace( namespace: SqlNamespace, - renames: readonly ResolvedTableRename[], + rename: ResolvedTableRename, renameTableReferences: RenameTableReferences | undefined, ): SqlNamespace { - const own = renames.filter((rename) => rename.namespaceId === namespace.id); - const tables = Object.entries(namespace.entries.table ?? {}).map(([name, table]) => { - const renamedTo = own.find((rename) => rename.from === name)?.to; - const withForeignKeys = renames.reduce(renameForeignKeys, table); - return [renamedTo ?? name, withForeignKeys] as const; - }); + const ownsTable = rename.namespaceId === namespace.id; + const tables = Object.entries(namespace.entries.table ?? {}).map( + ([name, table]) => + [ + ownsTable && name === rename.from ? rename.to : name, + renameForeignKeys(table, rename), + ] as const, + ); const untouched = tables.every(([name, table]) => namespace.entries.table?.[name] === table); if (untouched) return namespace; if (!isMaterializedSqlNamespace(namespace)) { @@ -208,21 +210,21 @@ function renameTablesInNamespace( }; return withEntries( namespace, - renameTableReferences === undefined + renameTableReferences === undefined || !ownsTable ? renamedTables - : own.reduce(renameTableReferences, renamedTables), + : renameTableReferences(renamedTables, rename), ); } -function renameTablesInContract( +function renameTableInContract( contract: Contract, - renames: readonly ResolvedTableRename[], + rename: ResolvedTableRename, renameTableReferences: RenameTableReferences | undefined, ): Contract { const namespaces = Object.fromEntries( Object.entries(contract.storage.namespaces).map(([id, namespace]) => [ id, - renameTablesInNamespace(namespace, renames, renameTableReferences), + renameTableInNamespace(namespace, rename, renameTableReferences), ]), ); const materialized: Record = {}; @@ -258,9 +260,9 @@ export function applyTableRename( const resolved = resolveTableRename(input.rename, input.startContract, input.endContract); if (!resolved.ok) return resolved; return ok({ - contract: renameTablesInContract( + contract: renameTableInContract( input.startContract, - [resolved.value], + resolved.value, input.renameTableReferences, ), rename: resolved.value, diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 063679ad7921..e150e5470173 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -26,10 +26,7 @@ export type { ResolvedTableRename, TableRename, } from '../core/migrations/apply-table-rename'; -export { - applyTableRename, - TABLE_RENAME_UNMATCHED_CODE, -} from '../core/migrations/apply-table-rename'; +export { applyTableRename } from '../core/migrations/apply-table-rename'; export type { ContractToSchemaIROptions, DefaultRenderer, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index afd08d122533..07bfbd79cfdf 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -205,18 +205,15 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return policyResult; } - const fromContract = options.fromContract; - const previousSchema = options.schema; - // The one combined tree diff drives the whole plan: relational findings // become structural DDL via `planIssues`, policy findings become RLS ops // via `planPostgresSchemaDiff`. Verify runs its own full-tree node diff // (`diffSchema`) over the same schema and rejects on a // surviving failure. - PostgresDatabaseSchemaNode.assert(previousSchema); + PostgresDatabaseSchemaNode.assert(options.schema); const { issues: rawIssues } = buildPostgresPlanDiff({ contract: options.contract, - actualSchema: previousSchema, + actualSchema: options.schema, frameworkComponents: options.frameworkComponents, }); const policyDiffIssues = rawIssues.filter((issue) => isPolicyDiffIssue(issue)); @@ -263,7 +260,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // sibling-space scoping, matching the retired coordinate walk exactly. const namespaceIssues = verifyPostgresNamespacePresence({ contract: options.contract, - schema: previousSchema, + schema: options.schema, }); const schemaIssues = [...namespaceIssues, ...gated]; @@ -288,7 +285,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // per-schema namespace node — never the whole tree root, and never a flat // merge of every namespace (which would collide same-named tables across // schemas). Probing more than one namespace at once is future work. - const relationalSchema = relationalNamespaceNode(previousSchema, schemaName); + const relationalSchema = relationalNamespaceNode(options.schema, schemaName); // Input-side control-policy partition. `external` / `observed` subjects // — and non-creation issues for `tolerated` subjects — are dropped from @@ -344,7 +341,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // from/to comparisons (unsafe type change, nullable tightening) are // inapplicable there — reconciliation falls through to // `mapNodeIssueToCall`'s direct destructive handlers. - fromContract, + fromContract: options.fromContract, schemaName, codecHooks, storageTypes, @@ -377,7 +374,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // byte-stable. The hook fires only at the application emitter — // extension-space planning never reaches this helper. const fieldEventOps = planFieldEventOperations({ - priorContract: fromContract, + priorContract: options.fromContract, newContract: options.contract, codecHooks, }); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 7c3ad096337b..f7a22319cbe1 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -136,8 +136,6 @@ export class SqliteMigrationPlanner const policyResult = this.ensureAdditivePolicy(options.policy); if (policyResult) return policyResult; - const fromContract = options.fromContract; - const { expected, actual, issues: diffIssues } = this.collectSchemaIssues(options); const replacedIndexes = pairIndexReplacements(diffIssues, indexNameCaseChange); const disallowedIndexCalls = replacedIndexes.calls.filter( @@ -189,7 +187,7 @@ export class SqliteMigrationPlanner // Hook fires only at the application emitter — extension-space planning // (M2 R2) never reaches this helper. const fieldEventOps = planFieldEventOperations({ - priorContract: fromContract, + priorContract: options.fromContract, newContract: options.contract, codecHooks, });