From ca33060e0bd30c6d077f8dd18ca8492d50c06a92 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Thu, 7 May 2026 17:00:04 +0200 Subject: [PATCH] fix(core): Advance Postgres IDENTITY sequences after entity import (#29762) Co-authored-by: Claude Opus 4.7 (1M context) --- .../services/__tests__/import.service.test.ts | 53 +++++++++++++++++++ packages/cli/src/services/import.service.ts | 49 +++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/packages/cli/src/services/__tests__/import.service.test.ts b/packages/cli/src/services/__tests__/import.service.test.ts index 962a6879089..852758bb9fc 100644 --- a/packages/cli/src/services/__tests__/import.service.test.ts +++ b/packages/cli/src/services/__tests__/import.service.test.ts @@ -1106,4 +1106,57 @@ describe('ImportService', () => { expect(mockDataTableDDLService.dropTable).not.toHaveBeenCalled(); }); }); + + describe('advanceIdentitySequences', () => { + it('should run setval for each identity column on Postgres', async () => { + // @ts-expect-error overriding for the test + mockDataSource.options = { type: 'postgres' }; + + mockEntityManager.query = jest + .fn() + // information_schema lookup for "workflow_dependency" + .mockResolvedValueOnce([{ column_name: 'id' }]) + // setval call returns nothing meaningful + .mockResolvedValueOnce(undefined) + // information_schema lookup for "insights_metadata" (has 'metaId') + .mockResolvedValueOnce([{ column_name: 'metaId' }]) + .mockResolvedValueOnce(undefined) + // information_schema lookup for "user" (no identity columns) + .mockResolvedValueOnce([]); + + await importService.advanceIdentitySequences(mockEntityManager, [ + 'workflow_dependency', + 'insights_metadata', + 'user', + ]); + + const setvalCalls = (mockEntityManager.query as jest.Mock).mock.calls.filter( + ([sql]) => typeof sql === 'string' && sql.includes('setval('), + ); + expect(setvalCalls).toHaveLength(2); + // Quoted table identifier passed through to pg_get_serial_sequence + // (regclass folds unquoted names to lowercase, so the quoted form is required). + expect(setvalCalls[0][1]).toEqual(['"workflow_dependency"', 'id']); + expect(setvalCalls[1][1]).toEqual(['"insights_metadata"', 'metaId']); + }); + + it('should be a no-op on SQLite', async () => { + // SQLite is the default in beforeEach. + mockEntityManager.query = jest.fn(); + + await importService.advanceIdentitySequences(mockEntityManager, ['workflow_dependency']); + + expect(mockEntityManager.query).not.toHaveBeenCalled(); + }); + + it('should be a no-op when no tables are provided', async () => { + // @ts-expect-error overriding for the test + mockDataSource.options = { type: 'postgres' }; + mockEntityManager.query = jest.fn(); + + await importService.advanceIdentitySequences(mockEntityManager, []); + + expect(mockEntityManager.query).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 3e9afd97378..379f60ca65b 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -500,6 +500,11 @@ export class ImportService { customEncryptionKey, ); + // Postgres IDENTITY sequences don't auto-advance when explicit ids are + // inserted, so subsequent implicit inserts would collide. Reset each + // imported table's sequence to MAX(col). + await this.advanceIdentitySequences(transactionManager, tableNames); + // After the data_table / data_table_column registry rows are imported, // recreate the dynamic backing tables (empty) so the imported tables work. await this.recreateDataTableUserTablesFromRegistry(transactionManager); @@ -768,6 +773,50 @@ export class ImportService { this.logger.info(`āœ… Recreated ${dataTables.length} data-table backing table(s)`); } + /** + * Advance Postgres IDENTITY sequences to MAX(col) for every identity column + * in the given tables. No-op on SQLite, where INTEGER PRIMARY KEY auto-bumps + * its rowid sequence on inserts with explicit ids. + */ + async advanceIdentitySequences( + transactionManager: EntityManager, + tableNames: string[], + ): Promise { + if (this.dataSource.options.type !== 'postgres') return; + if (tableNames.length === 0) return; + + this.logger.info('\nšŸ”¢ Advancing Postgres IDENTITY sequences...'); + let advanced = 0; + + for (const tableName of tableNames) { + const identityColumns: Array<{ column_name: string }> = await transactionManager.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = $1 + AND identity_generation IS NOT NULL`, + [tableName], + ); + + if (identityColumns.length === 0) continue; + + const escapedTable = this.dataSource.driver.escape(tableName); + for (const { column_name: columnName } of identityColumns) { + const escapedCol = this.dataSource.driver.escape(columnName); + await transactionManager.query( + `SELECT setval( + pg_get_serial_sequence($1, $2), + COALESCE((SELECT MAX(${escapedCol}) FROM ${escapedTable}), 1), + (SELECT MAX(${escapedCol}) FROM ${escapedTable}) IS NOT NULL + )`, + [escapedTable, columnName], + ); + advanced++; + } + } + + this.logger.info(`āœ… Advanced ${advanced} sequence(s) across ${tableNames.length} table(s)`); + } + async disableForeignKeyConstraints(transactionManager: EntityManager) { const disableCommand = this.foreignKeyCommands.disable[this.dataSource.options.type];