@@ -8,6 +8,7 @@ import type {
88import type { SqlType } from "../sql-type.js" ;
99import { sqlTypeEquals } from "../sql-type.js" ;
1010import { applyStatus } from "./status.js" ;
11+ import { PrimaryKeyChangeError } from "../errors.js" ;
1112import { detectColumnRenames , detectTableRenames } from "./rename-heuristic.js" ;
1213import { viewSqlEquals } from "../view-sql-compare.js" ;
1314import { viewReplaceIsLegal } from "../view-column-types.js" ;
@@ -58,6 +59,16 @@ export interface DiffArgs {
5859 unmanagedNames ?: string [ ] ;
5960 /** Dialect; CHECK-constraint evolution on existing tables is emitted for postgres only. */
6061 dialect ?: Dialect ;
62+ /**
63+ * #258 — refuse (throw {@link PrimaryKeyChangeError}) when an existing table's live
64+ * PRIMARY KEY differs from the metadata identity. There is no primary-key change kind
65+ * in the emitter, so such a move would silently degrade into add-column + drop-column
66+ * and leave the table with no PK, breaking referencing FKs at apply time. Set by the
67+ * migration-generation path (snapshot/plan.ts); left unset by the read-only drift/verify
68+ * path so `meta verify` keeps reporting drift rather than throwing. Off by default —
69+ * existing callers are byte-identical.
70+ */
71+ refusePrimaryKeyChange ?: boolean ;
6172}
6273
6374const ALLOWED : ChangeStatus = { state : "allowed" } ;
@@ -266,10 +277,51 @@ export async function diff(
266277 delete ( c as Aug ) . _columns ;
267278 }
268279
280+ // #258: refuse a primary-key MOVE at generation time. There is no primary-key change
281+ // kind, so a table whose live PK differs from the metadata identity would degrade into
282+ // an add-column + drop-column and lose the constraint (breaking referencing FKs at
283+ // apply). Runs after rename detection so a PK column that was merely RENAMED (PK
284+ // preserved by the engine) is not mistaken for a move. Gated by refusePrimaryKeyChange
285+ // so only migration generation refuses; the read-only drift/verify path is unchanged.
286+ if ( args . refusePrimaryKeyChange === true ) {
287+ for ( const [ id , expectedTable ] of expectedTables ) {
288+ const actualTable = actualTables . get ( id ) ;
289+ if ( actualTable === undefined ) continue ; // create-table: PK is inline, not a move
290+ assertPrimaryKeyUnchanged ( expectedTable , actualTable , changes ) ;
291+ }
292+ }
293+
269294 applyStatus ( changes , args . allow ?? { } ) ;
270295 return { changes, blocked : changes . filter ( ( c ) => c . status . state === "blocked" ) } ;
271296}
272297
298+ /**
299+ * #258 — throw {@link PrimaryKeyChangeError} when a table's live PRIMARY KEY differs from
300+ * the metadata identity. Live PK column names are first mapped through any detected
301+ * `rename-column` for this table, so a renamed PK column (the engine preserves the PK
302+ * through a `RENAME COLUMN`) is not treated as a move. A genuine move — a PK column added
303+ * or dropped, or the key repointed to different columns — has no expressible migration and
304+ * is refused.
305+ */
306+ function assertPrimaryKeyUnchanged (
307+ expected : TableDescriptor ,
308+ actual : TableDescriptor ,
309+ changes : Change [ ] ,
310+ ) : void {
311+ const wantId = tableIdentity ( expected ) ;
312+ const renamed = new Map < string , string > ( ) ;
313+ for ( const c of changes ) {
314+ if ( c . kind === "rename-column" && tableIdentity ( { name : c . table , ...schemaSpread ( c . schema ) } ) === wantId ) {
315+ renamed . set ( c . from , c . to ) ;
316+ }
317+ }
318+ const livePk = actual . primaryKey . map ( ( col ) => renamed . get ( col ) ?? col ) ;
319+ const wantPk = expected . primaryKey ;
320+ const unchanged = livePk . length === wantPk . length && livePk . every ( ( col , i ) => col === wantPk [ i ] ) ;
321+ if ( unchanged ) return ;
322+ throw new PrimaryKeyChangeError ( expected . name , actual . primaryKey , expected . primaryKey , expected . schema ) ;
323+ }
324+
273325function isDiffArgs ( x : DiffArgs | SchemaSnapshot ) : x is DiffArgs {
274326 return "expected" in x && "actual" in x ;
275327}
0 commit comments