diff --git a/src/server/db/index.default-path.test.ts b/src/server/db/index.default-path.test.ts index b9825b1..bac23b8 100644 --- a/src/server/db/index.default-path.test.ts +++ b/src/server/db/index.default-path.test.ts @@ -41,4 +41,18 @@ describe('sqlite default path resolution', () => { expect(sqlitePath).toBe(resolve(process.env.DATA_DIR, 'hub.db')); }); + + it('ignores the default repo DATA_DIR under vitest and still isolates sqlite', async () => { + process.env.DATA_DIR = './data'; + delete process.env.DB_URL; + vi.resetModules(); + + dbModule = await import('./index.js'); + const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath(); + const sharedRepoPath = resolve('./data/hub.db'); + + expect(sqlitePath).not.toBe(sharedRepoPath); + expect(sqlitePath).toContain(tmpdir()); + expect(sqlitePath).toContain('metapi-vitest'); + }); }); diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 9c65dc5..bb27a1b 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -75,6 +75,12 @@ function isVitestRuntime(): boolean { return runtimeArgs.some((value) => value.includes('vitest')); } +function isDefaultRepoDataDir(value: string | undefined): boolean { + const trimmed = (value || '').trim(); + if (!trimmed) return false; + return resolve(trimmed) === resolve('./data'); +} + function resolveVitestSqlitePath(): string | null { if (!isVitestRuntime()) { return null; @@ -82,7 +88,7 @@ function resolveVitestSqlitePath(): string | null { if ((process.env.DB_URL || '').trim()) { return null; } - if ((process.env.DATA_DIR || '').trim()) { + if ((process.env.DATA_DIR || '').trim() && !isDefaultRepoDataDir(process.env.DATA_DIR)) { return null; } diff --git a/src/server/db/runtimeSchemaBootstrap.test.ts b/src/server/db/runtimeSchemaBootstrap.test.ts index d10cac9..ec31d52 100644 --- a/src/server/db/runtimeSchemaBootstrap.test.ts +++ b/src/server/db/runtimeSchemaBootstrap.test.ts @@ -65,6 +65,27 @@ describe('runtime schema bootstrap', () => { expect(executedSql.every((sqlText) => classifyLegacyCompatMutation(sqlText) === 'legacy')).toBe(true); }); + it('tolerates non-additive live-schema drift and still emits additive runtime patch statements', () => { + const driftedLiveContract = __runtimeSchemaBootstrapTestUtils.cloneContract(currentContract); + + delete driftedLiveContract.tables.model_availability?.columns.is_manual; + if (driftedLiveContract.tables.sites?.columns.status) { + driftedLiveContract.tables.sites.columns.status.defaultValue = null; + } + driftedLiveContract.indexes = driftedLiveContract.indexes.filter((index) => index.name !== 'accounts_site_id_idx'); + driftedLiveContract.uniques = driftedLiveContract.uniques.filter((unique) => unique.name !== 'proxy_files_public_id_unique'); + + const statements = __runtimeSchemaBootstrapTestUtils.buildExternalUpgradeStatements( + 'mysql', + currentContract, + driftedLiveContract, + ); + + expect(statements.some((sqlText) => sqlText.includes('ALTER TABLE `model_availability` ADD COLUMN `is_manual`'))).toBe(true); + expect(statements.some((sqlText) => sqlText.includes('CREATE INDEX `accounts_site_id_idx`'))).toBe(true); + expect(statements.some((sqlText) => sqlText.includes('CREATE UNIQUE INDEX `proxy_files_public_id_unique`'))).toBe(true); + }); + it('ignores duplicate mysql index and column errors when replaying additive schema statements', async () => { const executedSql: string[] = []; const duplicateColumnSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements( diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 715ed02..1838527 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -153,6 +153,86 @@ function readSchemaContract(): SchemaContract { return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as SchemaContract; } +function cloneContract(contract: SchemaContract): SchemaContract { + return JSON.parse(JSON.stringify(contract)) as SchemaContract; +} + +function serializeColumn(column: SchemaContract['tables'][string]['columns'][string]): string { + return [ + column.logicalType, + column.notNull ? 'not-null' : 'nullable', + column.defaultValue ?? 'default:null', + column.primaryKey ? 'pk' : 'non-pk', + ].join('|'); +} + +function serializeIndex(index: SchemaContract['indexes'][number]): string { + return [index.table, index.columns.join(','), index.unique ? 'unique' : 'non-unique'].join('|'); +} + +function serializeUnique(unique: SchemaContract['uniques'][number]): string { + return [unique.table, unique.columns.join(',')].join('|'); +} + +function serializeForeignKey(foreignKey: SchemaContract['foreignKeys'][number]): string { + return [ + foreignKey.table, + foreignKey.columns.join(','), + foreignKey.referencedTable, + foreignKey.referencedColumns.join(','), + foreignKey.onDelete ?? 'null', + ].join('|'); +} + +function buildCompatibleRuntimeBaseline( + currentContract: SchemaContract, + liveContract: SchemaContract, +): SchemaContract { + const baseline: SchemaContract = { + tables: {}, + indexes: [], + uniques: [], + foreignKeys: [], + }; + + for (const [tableName, liveTable] of Object.entries(liveContract.tables)) { + const currentTable = currentContract.tables[tableName]; + if (!currentTable) { + continue; + } + + const compatibleColumns = Object.fromEntries( + Object.entries(liveTable.columns) + .filter(([columnName, liveColumn]) => { + const currentColumn = currentTable.columns[columnName]; + return currentColumn && serializeColumn(currentColumn) === serializeColumn(liveColumn); + }), + ); + + baseline.tables[tableName] = { columns: compatibleColumns }; + } + + const currentIndexes = new Map(currentContract.indexes.map((index) => [index.name, index])); + baseline.indexes = liveContract.indexes + .filter((index) => { + const currentIndex = currentIndexes.get(index.name); + return currentIndex && serializeIndex(currentIndex) === serializeIndex(index); + }); + + const currentUniques = new Map(currentContract.uniques.map((unique) => [unique.name, unique])); + baseline.uniques = liveContract.uniques + .filter((unique) => { + const currentUnique = currentUniques.get(unique.name); + return currentUnique && serializeUnique(currentUnique) === serializeUnique(unique); + }); + + const currentForeignKeys = new Set(currentContract.foreignKeys.map(serializeForeignKey)); + baseline.foreignKeys = liveContract.foreignKeys + .filter((foreignKey) => currentForeignKeys.has(serializeForeignKey(foreignKey))); + + return baseline; +} + async function createPostgresClient(connectionString: string, ssl: boolean): Promise { const clientOptions: pg.ClientConfig = { connectionString }; if (ssl) { @@ -267,7 +347,8 @@ function buildExternalUpgradeStatements( currentContract: SchemaContract, liveContract: SchemaContract, ): string[] { - return splitSqlStatements(generateUpgradeSql(dialect, currentContract, liveContract)); + const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract); + return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline)); } export async function ensureRuntimeDatabaseSchema( @@ -300,6 +381,8 @@ export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnect } export const __runtimeSchemaBootstrapTestUtils = { + buildCompatibleRuntimeBaseline, + cloneContract, splitSqlStatements, buildExternalUpgradeStatements, };