fix(db): tolerate runtime schema drift on startup (#113)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<RuntimeSchemaClient> {
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user