forked from boos/BoosAPI
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 Database from 'better-sqlite3';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -67,4 +67,62 @@ describe('sqlite migrate bootstrap', () => {
|
|||||||
|
|
||||||
verified.close();
|
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;
|
hash: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RecoveryMigrationRecord = MigrationRecord & {
|
||||||
|
tag: string;
|
||||||
|
};
|
||||||
|
|
||||||
const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences';
|
const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences';
|
||||||
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
||||||
{ table: 'sites' },
|
{ table: 'sites' },
|
||||||
@@ -108,6 +112,134 @@ function readVerifiedMigrationRecords(migrationsFolder: string): MigrationRecord
|
|||||||
return [];
|
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 {
|
function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean {
|
||||||
if (hasRecordedDrizzleMigrations(sqlite)) return false;
|
if (hasRecordedDrizzleMigrations(sqlite)) return false;
|
||||||
if (!hasVerifiedLegacySchema(sqlite)) return false;
|
if (!hasVerifiedLegacySchema(sqlite)) return false;
|
||||||
@@ -144,8 +276,17 @@ export function runSqliteMigrations(): void {
|
|||||||
|
|
||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
|
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();
|
sqlite.close();
|
||||||
console.log('Migration complete.');
|
console.log('Migration complete.');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user