diff --git a/src/server/db/migrate.test.ts b/src/server/db/migrate.test.ts index 419257a..68ec2ef 100644 --- a/src/server/db/migrate.test.ts +++ b/src/server/db/migrate.test.ts @@ -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(); + }); }); diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 1237f4a..31aac4e 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -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.'); }