forked from boos/BoosAPI
feat: Implement Drizzle SQLite migration utility with schema verification and error recovery mechanisms.
This commit is contained in:
@@ -208,6 +208,64 @@ describe('sqlite migrate bootstrap', () => {
|
|||||||
sqlite.close();
|
sqlite.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recovers duplicate-column errors when the failed SQL contains quoted literals', async () => {
|
||||||
|
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-quoted-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const migrateModule = await import('./migrate.js');
|
||||||
|
const { __migrateTestUtils } = migrateModule;
|
||||||
|
|
||||||
|
const sqlite = new Database(':memory:');
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE account_tokens (
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
value_status text DEFAULT 'ready' NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-quoted-'));
|
||||||
|
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
tag: '0012_account_token_value_status',
|
||||||
|
when: 1773665311013,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(tempMigrationsDir, '0012_account_token_value_status.sql'),
|
||||||
|
"ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateColumnError = new Error(
|
||||||
|
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n' duplicate column name: value_status",
|
||||||
|
);
|
||||||
|
|
||||||
|
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||||
|
sqlite,
|
||||||
|
tempMigrationsDir,
|
||||||
|
duplicateColumnError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recovered).toBe(true);
|
||||||
|
|
||||||
|
const applied = sqlite
|
||||||
|
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||||
|
.all() as Array<{ hash: string; created_at: number }>;
|
||||||
|
|
||||||
|
expect(applied).toHaveLength(1);
|
||||||
|
expect(Number(applied[0].created_at)).toBe(1773665311013);
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('recovers duplicate-column errors inside multi-statement migrations by replaying the full migration', async () => {
|
it('recovers duplicate-column errors inside multi-statement migrations by replaying the full migration', async () => {
|
||||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-multi-'));
|
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-multi-'));
|
||||||
process.env.DATA_DIR = dataDir;
|
process.env.DATA_DIR = dataDir;
|
||||||
@@ -285,6 +343,7 @@ describe('sqlite migrate bootstrap', () => {
|
|||||||
'0009_model_availability_is_manual',
|
'0009_model_availability_is_manual',
|
||||||
'0010_proxy_logs_downstream_api_key',
|
'0010_proxy_logs_downstream_api_key',
|
||||||
'0011_downstream_api_key_metadata',
|
'0011_downstream_api_key_metadata',
|
||||||
|
'0012_account_token_value_status',
|
||||||
]);
|
]);
|
||||||
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
|
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ type RecoveryMigration = RecoveryMigrationRecord & {
|
|||||||
statements: string[];
|
statements: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERIFIED_BOOTSTRAP_TAG = '0011_downstream_api_key_metadata';
|
const VERIFIED_BOOTSTRAP_TAG = '0012_account_token_value_status';
|
||||||
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
||||||
{ table: 'sites' },
|
{ table: 'sites' },
|
||||||
{ table: 'settings' },
|
{ table: 'settings' },
|
||||||
@@ -205,6 +205,28 @@ function findMatchingMigrationByStatement(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findMatchingMigrationByErrorMessage(
|
||||||
|
migrationsFolder: string,
|
||||||
|
error: unknown,
|
||||||
|
): RecoveryMigrationRecord | null {
|
||||||
|
const normalizedErrorMessage = normalizeSqlForMatch(normalizeSchemaErrorMessage(error));
|
||||||
|
const migrations = readRecoveryMigrations(migrationsFolder);
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (!migration.statements.some((statement) => normalizedErrorMessage.includes(normalizeSqlForMatch(statement)))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: migration.tag,
|
||||||
|
createdAt: migration.createdAt,
|
||||||
|
hash: migration.hash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function readRecoveryMigrations(migrationsFolder: string): RecoveryMigration[] {
|
function readRecoveryMigrations(migrationsFolder: string): RecoveryMigration[] {
|
||||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||||
@@ -347,11 +369,10 @@ function tryRecoverDuplicateColumnMigrationError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const failedSqlText = extractFailedSqlFromError(error);
|
const failedSqlText = extractFailedSqlFromError(error);
|
||||||
if (!failedSqlText) {
|
const matchedMigration = failedSqlText
|
||||||
return false;
|
? findMatchingMigrationByStatement(migrationsFolder, failedSqlText)
|
||||||
}
|
?? findMatchingMigrationByErrorMessage(migrationsFolder, error)
|
||||||
|
: findMatchingMigrationByErrorMessage(migrationsFolder, error);
|
||||||
const matchedMigration = findMatchingMigrationByStatement(migrationsFolder, failedSqlText);
|
|
||||||
if (!matchedMigration) {
|
if (!matchedMigration) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -369,6 +390,7 @@ export const __migrateTestUtils = {
|
|||||||
extractFailedSqlFromError,
|
extractFailedSqlFromError,
|
||||||
findMatchingSingleStatementMigration,
|
findMatchingSingleStatementMigration,
|
||||||
findMatchingMigrationByStatement,
|
findMatchingMigrationByStatement,
|
||||||
|
findMatchingMigrationByErrorMessage,
|
||||||
readRecoveryMigrations,
|
readRecoveryMigrations,
|
||||||
markMigrationRecordIfMissing,
|
markMigrationRecordIfMissing,
|
||||||
recoverMigrationSequence,
|
recoverMigrationSequence,
|
||||||
|
|||||||
Reference in New Issue
Block a user