1
0
forked from boos/BoosAPI

feat: Implement Drizzle SQLite migration utility with schema verification and error recovery mechanisms.

This commit is contained in:
cita
2026-03-16 22:11:13 +08:00
parent 0a06f15e13
commit 5f26ad976a
2 changed files with 87 additions and 6 deletions
+59
View File
@@ -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));
+28 -6
View File
@@ -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,