1
0
forked from boos/BoosAPI

fix(db): tolerate runtime schema drift on startup (#113)

This commit is contained in:
Cita
2026-03-15 19:07:46 +08:00
committed by GitHub
parent cb405e785d
commit 6237eb631f
4 changed files with 126 additions and 2 deletions
+14
View File
@@ -41,4 +41,18 @@ describe('sqlite default path resolution', () => {
expect(sqlitePath).toBe(resolve(process.env.DATA_DIR, 'hub.db')); 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');
});
}); });
+7 -1
View File
@@ -75,6 +75,12 @@ function isVitestRuntime(): boolean {
return runtimeArgs.some((value) => value.includes('vitest')); 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 { function resolveVitestSqlitePath(): string | null {
if (!isVitestRuntime()) { if (!isVitestRuntime()) {
return null; return null;
@@ -82,7 +88,7 @@ function resolveVitestSqlitePath(): string | null {
if ((process.env.DB_URL || '').trim()) { if ((process.env.DB_URL || '').trim()) {
return null; return null;
} }
if ((process.env.DATA_DIR || '').trim()) { if ((process.env.DATA_DIR || '').trim() && !isDefaultRepoDataDir(process.env.DATA_DIR)) {
return null; return null;
} }
@@ -65,6 +65,27 @@ describe('runtime schema bootstrap', () => {
expect(executedSql.every((sqlText) => classifyLegacyCompatMutation(sqlText) === 'legacy')).toBe(true); 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 () => { it('ignores duplicate mysql index and column errors when replaying additive schema statements', async () => {
const executedSql: string[] = []; const executedSql: string[] = [];
const duplicateColumnSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements( const duplicateColumnSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
+84 -1
View File
@@ -153,6 +153,86 @@ function readSchemaContract(): SchemaContract {
return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as 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> { async function createPostgresClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
const clientOptions: pg.ClientConfig = { connectionString }; const clientOptions: pg.ClientConfig = { connectionString };
if (ssl) { if (ssl) {
@@ -267,7 +347,8 @@ function buildExternalUpgradeStatements(
currentContract: SchemaContract, currentContract: SchemaContract,
liveContract: SchemaContract, liveContract: SchemaContract,
): string[] { ): string[] {
return splitSqlStatements(generateUpgradeSql(dialect, currentContract, liveContract)); const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract);
return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline));
} }
export async function ensureRuntimeDatabaseSchema( export async function ensureRuntimeDatabaseSchema(
@@ -300,6 +381,8 @@ export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnect
} }
export const __runtimeSchemaBootstrapTestUtils = { export const __runtimeSchemaBootstrapTestUtils = {
buildCompatibleRuntimeBaseline,
cloneContract,
splitSqlStatements, splitSqlStatements,
buildExternalUpgradeStatements, buildExternalUpgradeStatements,
}; };