feat: add recovery for duplicate-column migration errors and enhance migration utilities
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -67,4 +67,62 @@ describe('sqlite migrate bootstrap', () => {
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('recovers from duplicate-column errors for single-statement migrations', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-'));
|
||||
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,
|
||||
token_group text
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0007_account_token_group',
|
||||
when: 1772500000000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0007_account_token_group.sql'),
|
||||
'ALTER TABLE `account_tokens` ADD `token_group` text;\n',
|
||||
);
|
||||
|
||||
const duplicateColumnError = new Error(
|
||||
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group",
|
||||
);
|
||||
|
||||
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(1772500000000);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
+143
-2
@@ -26,6 +26,10 @@ type MigrationRecord = {
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type RecoveryMigrationRecord = MigrationRecord & {
|
||||
tag: string;
|
||||
};
|
||||
|
||||
const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences';
|
||||
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
||||
{ table: 'sites' },
|
||||
@@ -108,6 +112,134 @@ function readVerifiedMigrationRecords(migrationsFolder: string): MigrationRecord
|
||||
return [];
|
||||
}
|
||||
|
||||
function splitMigrationStatements(sqlText: string): string[] {
|
||||
return sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function normalizeSqlForMatch(sqlText: string): string {
|
||||
return sqlText
|
||||
.replace(/[\n\r\t]+/g, ' ')
|
||||
.replace(/["`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/;+$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function extractFailedSqlFromError(error: unknown): string | null {
|
||||
const message = normalizeSchemaErrorMessage(error);
|
||||
const matched = message.match(/Failed to run the query '([\s\S]*?)'/i);
|
||||
const sqlText = matched?.[1]?.trim();
|
||||
return sqlText && sqlText.length > 0 ? sqlText : null;
|
||||
}
|
||||
|
||||
function findMatchingSingleStatementMigration(
|
||||
migrationsFolder: string,
|
||||
failedSqlText: string,
|
||||
): RecoveryMigrationRecord | null {
|
||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||
const normalizedFailedSql = normalizeSqlForMatch(failedSqlText);
|
||||
|
||||
for (const entry of journal.entries ?? []) {
|
||||
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
|
||||
const statements = splitMigrationStatements(migrationSql);
|
||||
if (statements.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeSqlForMatch(statements[0]) !== normalizedFailedSql) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: entry.tag,
|
||||
createdAt: Number(entry.when),
|
||||
hash: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureDrizzleMigrationsTable(sqlite: Database.Database): void {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function markMigrationRecordIfMissing(sqlite: Database.Database, record: MigrationRecord): boolean {
|
||||
ensureDrizzleMigrationsTable(sqlite);
|
||||
const existing = sqlite
|
||||
.prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1')
|
||||
.get(record.hash);
|
||||
if (existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite
|
||||
.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)')
|
||||
.run(record.hash, record.createdAt);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
}
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column name');
|
||||
}
|
||||
|
||||
function tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite: Database.Database,
|
||||
migrationsFolder: string,
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const failedSqlText = extractFailedSqlFromError(error);
|
||||
if (!failedSqlText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchedMigration = findMatchingSingleStatementMigration(migrationsFolder, failedSqlText);
|
||||
if (!matchedMigration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inserted = markMigrationRecordIfMissing(sqlite, matchedMigration);
|
||||
if (inserted) {
|
||||
console.warn(`[db] Recovered duplicate-column migration by marking ${matchedMigration.tag} as applied.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const __migrateTestUtils = {
|
||||
splitMigrationStatements,
|
||||
normalizeSqlForMatch,
|
||||
extractFailedSqlFromError,
|
||||
findMatchingSingleStatementMigration,
|
||||
markMigrationRecordIfMissing,
|
||||
tryRecoverDuplicateColumnMigrationError,
|
||||
};
|
||||
|
||||
function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean {
|
||||
if (hasRecordedDrizzleMigrations(sqlite)) return false;
|
||||
if (!hasVerifiedLegacySchema(sqlite)) return false;
|
||||
@@ -144,8 +276,17 @@ export function runSqliteMigrations(): void {
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
|
||||
const db = drizzle(sqlite);
|
||||
migrate(db, { migrationsFolder });
|
||||
|
||||
try {
|
||||
migrate(drizzle(sqlite), { migrationsFolder });
|
||||
} catch (error) {
|
||||
if (!tryRecoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error)) {
|
||||
sqlite.close();
|
||||
throw error;
|
||||
}
|
||||
migrate(drizzle(sqlite), { migrationsFolder });
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
console.log('Migration complete.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user