forked from boos/BoosAPI
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'));
|
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'));
|
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(
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user