Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
4c62051
docs(projects): slice 2 spec, rename-table migration operation driven…
wmadden-electric Sep 17, 2026
4cf4990
test: red tests for the rename-table migration operation and --rename…
wmadden-electric Sep 17, 2026
638fd7f
feat(sql): renameTable migration operation for Postgres and SQLite
wmadden-electric Sep 17, 2026
0d3f07a
feat(migration): --rename-table intents on migration plan and migrati…
wmadden-electric Sep 17, 2026
0c65ae6
feat(migration): point the table-name case guard at --rename-table; S…
wmadden-electric Sep 17, 2026
2466aac
test: index-signature access in the rename journeys
wmadden-electric Sep 17, 2026
2387119
docs(projects): slice 2 spec, migration plan feeds the planner and mi…
wmadden-electric Sep 17, 2026
8ade679
test(cli): load a fresh command tree in the rename-table flag test
wmadden-electric Sep 17, 2026
19301ea
test: keep the SQLite rename journey on one import root
wmadden-electric Sep 17, 2026
8eda7ec
fix(mongo): refuse stated renames instead of planning a collection drop
wmadden-electric Sep 17, 2026
8bc8b67
docs(migration): state what renames do to the planner schema input
wmadden-electric Sep 17, 2026
c370ee4
fix(cli): name the table in duplicate --rename-table errors
wmadden-electric Sep 17, 2026
db756b1
fix(migration): read --rename-table the same way on migration plan an…
wmadden-electric Sep 17, 2026
ac02139
fix(sql): refuse two rename intents for one table, and code the no-hi…
wmadden-electric Sep 17, 2026
24f3b91
fix(sql): keep the by-hand rename remedy in the table-name case guard
wmadden-electric Sep 17, 2026
b35a9c7
fix(migration): fail a table rename when the old and new tables both …
wmadden-electric Sep 17, 2026
b37a029
fix(postgres): move RLS markers and policies with a renamed table
wmadden-electric Sep 17, 2026
f15a66b
fix(migration): rename the constraints and indexes a renamed table na…
wmadden-electric Sep 17, 2026
cc61637
fix(sqlite): do not skip a table recreate that removes the last uniqu…
wmadden-electric Sep 17, 2026
eaabf06
test: rename a table that has constraints, indexes and RLS, then chan…
wmadden-electric Sep 17, 2026
285f57f
docs(upgrade): record the RenameCheckConstraintCall rename for extens…
wmadden-electric Sep 17, 2026
94d447b
fix(migration): scaffold the same renames migration plan plans in mig…
wmadden-electric Sep 17, 2026
a16128f
test: migration new renames a table with constraints, indexes and RLS
wmadden-electric Sep 17, 2026
603ea74
refactor(cli): name the rename flag --rename, free of SQL vocabulary
wmadden-electric Sep 17, 2026
8ff5411
docs: put two doc blocks back above the declarations they describe
wmadden-electric Sep 17, 2026
937b6b6
fix(postgres): rename an unnamed constraint to the explicit name the …
wmadden-electric Sep 17, 2026
95e5893
fix(sqlite): check unique constraints by column set, not by count, af…
wmadden-electric Sep 17, 2026
820e50d
docs(upgrade): detect factory-name checks for renameCheckConstraint
wmadden-electric Sep 17, 2026
c214825
fix(sql): print the code once in migration new rename refusals
wmadden-electric Sep 17, 2026
32270de
fix(mongo): give the rename refusal conflict its code in meta
wmadden-electric Sep 17, 2026
5b35b6d
test: read the case guard remedy from the action labels in journey R1
wmadden-electric Sep 17, 2026
a24e11c
docs(projects): slice 2 spec records the neutral --rename flag and co…
wmadden-electric Sep 17, 2026
8e1214c
docs(migration): describe what a stated rename plans, and list --rena…
wmadden-electric Sep 17, 2026
10957c0
docs(upgrade): describe stated renames and by-hand renames in the ver…
wmadden-electric Sep 17, 2026
cc4eb66
docs(projects): slice 2 spec and design notes match the three guard r…
wmadden-electric Sep 17, 2026
eb13562
fix(migration): by-hand table renames that work on SQLite, and db upd…
wmadden-electric Sep 17, 2026
12b19b7
test: rename a SQLite table by hand in a db update project
wmadden-electric Sep 17, 2026
c4498cb
docs(projects): slice 2 spec, the by-hand rename is followed by db up…
wmadden-electric Sep 17, 2026
1d83f88
docs(upgrade): extension authors ship a table rename as a contract-sp…
wmadden-electric Sep 17, 2026
937bdca
docs(projects): record the extension migrations layout mismatch as a …
wmadden-electric Sep 17, 2026
3baae7d
fix(migration): qualify the guard's suggested --rename when the table…
wmadden-electric Sep 17, 2026
a474cfe
fix(migration): migration new --rename leaves out renames the control…
wmadden-electric Sep 17, 2026
f8a9aa5
fix(migration): rename a changed constraint after the new table, not …
wmadden-electric Sep 17, 2026
68c8291
fix(migration): SQLite compares identifier case only for ASCII letters
wmadden-electric Sep 17, 2026
943d2d7
fix(migration): SQLite drops an index before creating one whose name …
wmadden-electric Sep 17, 2026
ededa68
docs(projects): slice 2 states renames in hand-written migrations; pl…
wmadden-electric Sep 17, 2026
ee45f19
feat(migration): this.renameTable emits every rename the table needs
wmadden-electric Sep 17, 2026
60536b0
refactor(migration): remove the --rename flag
wmadden-electric Sep 17, 2026
c31c5ed
feat(migration): the case guard points at migration new and this.rena…
wmadden-electric Sep 17, 2026
b1c2f51
test: rename journeys state the rename with migration new and this.re…
wmadden-electric Sep 17, 2026
6a0b34e
docs(upgrade): verbatim table name fragments state a rename in a hand…
wmadden-electric Sep 17, 2026
a4edc9e
chore(migration): drop leftover rename-intent wording
wmadden-electric Sep 17, 2026
bdb2dde
docs(projects): slice 2 spec renames only objects the migration leave…
wmadden-electric Sep 17, 2026
e8292e9
fix(migration): renameTable leaves a constraint the end contract chan…
wmadden-electric Sep 17, 2026
6d3d087
refactor(migration): remove leftovers of the multi-rename planner path
wmadden-electric Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1300,7 +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 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`. 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: "<namespace>"` 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 "<schema>"."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

`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

Expand Down
3 changes: 2 additions & 1 deletion packages/2-sql/9-family/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ type SqlFamilyErrorCode =
| 'CONTRACT.PACK_CONTRIBUTION_INVALID'
| 'CONTRACT.TABLE_AMBIGUOUS'
| 'CONTRACT.TYPE_UNKNOWN'
| 'MIGRATION.MARKER_CAS_FAILURE';
| 'MIGRATION.MARKER_CAS_FAILURE'
| 'MIGRATION.TABLE_RENAME_UNMATCHED';

export function sqlFamilyError(
code: SqlFamilyErrorCode,
Expand Down
270 changes: 270 additions & 0 deletions packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
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<SqlStorage> | null;
readonly endContract: Contract<SqlStorage>;
readonly rename: TableRename;
readonly renameTableReferences: RenameTableReferences | undefined;
}

export interface AppliedTableRename {
/** The start contract with the table under its new name. */
readonly contract: Contract<SqlStorage>;
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<SqlStorage>, 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<SqlStorage>, 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<SqlStorage>,
endContract: Contract<SqlStorage>,
): Result<ResolvedTableRename, StructuredError> {
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 renameTableInNamespace(
namespace: SqlNamespace,
rename: ResolvedTableRename,
renameTableReferences: RenameTableReferences | undefined,
): SqlNamespace {
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)) {
throw new InternalError(
`applyTableRename: namespace "${namespace.id}" is not a materialized SQL namespace`,
);
}
const renamedTables: SqlNamespaceEntries = {
...namespace.entries,
table: Object.fromEntries(tables),
};
return withEntries(
namespace,
renameTableReferences === undefined || !ownsTable
? renamedTables
: renameTableReferences(renamedTables, rename),
);
}

function renameTableInContract(
contract: Contract<SqlStorage>,
rename: ResolvedTableRename,
renameTableReferences: RenameTableReferences | undefined,
): Contract<SqlStorage> {
const namespaces = Object.fromEntries(
Object.entries(contract.storage.namespaces).map(([id, namespace]) => [
id,
renameTableInNamespace(namespace, rename, renameTableReferences),
]),
);
const materialized: Record<string, SqlNamespaceBase> = {};
for (const [id, namespace] of Object.entries(namespaces)) {
if (!isMaterializedSqlNamespace(namespace)) {
throw new InternalError(
`applyTableRename: 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<AppliedTableRename, StructuredError> {
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: renameTableInContract(
input.startContract,
resolved.value,
input.renameTableReferences,
),
rename: resolved.value,
});
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +12,20 @@ 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;
}

/** 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;
Expand All @@ -22,6 +39,27 @@ function plannedTable(node: TableNameCaseGuardTable, namespaceId: string): Plann
return { namespaceId, tableName: node.name };
}

function renameNeedsNamespace(
drop: PlannedTable,
dropped: readonly PlannedTable[],
contract: Contract<SqlStorage>,
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
Expand All @@ -37,6 +75,10 @@ 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[];
readonly renameTableCall: (rename: TableRenameInMigration) => string;
readonly contract: Contract<SqlStorage>;
readonly defaultNamespaceId: string;
}): SqlPlannerConflict[] {
const dropped: PlannedTable[] = [];
const created: PlannedTable[] = [];
Expand All @@ -58,10 +100,17 @@ export function detectTableNameCaseChanges(input: {
candidate.tableName === lowerFirst(create.tableName),
);
if (drop === undefined) continue;
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. 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: 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',
Expand Down
Loading
Loading