fix(core): Advance Postgres IDENTITY sequences after entity import (#29762)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Charlie Kolb 2026-05-07 17:00:04 +02:00 committed by GitHub
parent 1a270f2f35
commit ca33060e0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 102 additions and 0 deletions

View File

@ -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();
});
});
});

View File

@ -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<void> {
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];