History

readMigrationJournal now distinguishes missing, valid, and unreadable journals

Griffin Evans committed 4 months ago

Commit 52eea2e1 parent 30f87651 file · +79 −17Browse files

Unified · Split

apps/local/src/db/v1-v2-migration.ts+79−17
@@ -1122,14 +1122,61 @@ const removeMigrationJournal = (path: string): void => { · 1121 unmodified lines
1122 fsyncDirectory(dirname(path));1122 fsyncDirectory(dirname(path));
1123};1123};
1124 1124
1125const readMigrationJournal = (path: string): MigrationJournal | null => {1125type MigrationJournalReadResult =
1126 if (!fs.existsSync(path)) return null;1126 | { readonly kind: "missing" }
1127 | { readonly kind: "valid"; readonly journal: MigrationJournal }
1128 | { readonly kind: "unreadable"; readonly cause: unknown };
1129
1130const isRecord = (value: unknown): value is Record<string, unknown> =>
1131 typeof value === "object" && value !== null;
1132
1133const isMigrationJournalPhase = (value: unknown): value is MigrationJournalPhase =>
1134 value === "building" || value === "built" || value === "canonical-moved" || value === "committed";
1135
1136const isKeychainSecretBackup = (value: unknown): value is KeychainSecretBackup => {
1137 if (!isRecord(value)) return false;
1138 return (
1139 typeof value.id === "string" &&
1140 (typeof value.backupId === "string" || value.backupId === null) &&
1141 typeof value.existed === "boolean"
1142 );
1143};
1144
1145const isMigrationJournal = (value: unknown): value is MigrationJournal => {
1146 if (!isRecord(value)) return false;
1147 const keychainBackups = value.keychainBackups;
1148 return (
1149 value.version === 1 &&
1150 typeof value.source === "string" &&
1151 typeof value.normalizedSource === "string" &&
1152 typeof value.staging === "string" &&
1153 typeof value.backup === "string" &&
1154 typeof value.authPath === "string" &&
1155 (typeof value.authBackup === "string" || value.authBackup === null) &&
1156 typeof value.authExisted === "boolean" &&
1157 (keychainBackups === undefined ||
1158 (Array.isArray(keychainBackups) && keychainBackups.every(isKeychainSecretBackup))) &&
1159 typeof value.nonce === "string" &&
1160 isMigrationJournalPhase(value.phase)
1161 );
1162};
1163
1164const readMigrationJournal = (path: string): MigrationJournalReadResult => {
1165 if (!fs.existsSync(path)) return { kind: "missing" };
1127 try {1166 try {
1128 const parsed = JSON.parse(fs.readFileSync(path, "utf-8")) as MigrationJournal;1167 const parsed = JSON.parse(fs.readFileSync(path, "utf-8"));
1129 if (parsed.version !== 1) return null;1168 if (!isMigrationJournal(parsed)) {
1130 return parsed;1169 return {
1131 } catch {1170 kind: "unreadable",
1132 return null;1171 cause: new LocalV1V2MigrationError({
1172 message: `Migration journal has an unrecognized shape: ${path}`,
1173 cause: parsed,
1174 }),
1175 };
1176 }
1177 return { kind: "valid", journal: parsed };
1178 } catch (cause) {
1179 return { kind: "unreadable", cause };
1133 }1180 }
1134};1181};
1135 1182
@@ -1371,29 +1418,40 @@ const completeJournaledFlip = async ( · 235 unmodified lines
1371 removeMigrationJournal(journalPath);1418 removeMigrationJournal(journalPath);
1372};1419};
1373 1420
1374const recoverV1V2Migration = async (sqlitePath: string): Promise<void> => {1421type MigrationRecoveryResult = "missing" | "recovered" | "unreadable";
1422
1423const recoverV1V2Migration = async (sqlitePath: string): Promise<MigrationRecoveryResult> => {
1375 const journalPath = migrationJournalPath(sqlitePath);1424 const journalPath = migrationJournalPath(sqlitePath);
1376 const journal = readMigrationJournal(journalPath);1425 const journalRead = readMigrationJournal(journalPath);
1377 if (!journal) return;1426 if (journalRead.kind === "missing") return "missing";
1427 if (journalRead.kind === "unreadable") {
1428 if (fs.existsSync(sqlitePath)) return "unreadable";
1429 throw new LocalV1V2MigrationError({
1430 message: `Cannot recover local v1→v2 migration because the journal is unreadable and the canonical database is missing: ${journalPath}`,
1431 cause: journalRead.cause,
1432 });
1433 }
1378 1434
1435 const { journal } = journalRead;
1379 if (journal.phase === "building") {1436 if (journal.phase === "building") {
1380 removeSqliteFileSet(journal.normalizedSource);1437 removeSqliteFileSet(journal.normalizedSource);
1381 removeSqliteFileSet(journal.staging);1438 removeSqliteFileSet(journal.staging);
1382 await restoreAuthFromJournal(journal);1439 await restoreAuthFromJournal(journal);
1383 await cleanupSecretBackups(journal);1440 await cleanupSecretBackups(journal);
1384 removeMigrationJournal(journalPath);1441 removeMigrationJournal(journalPath);
1385 return;1442 return "recovered";
1386 }1443 }
1387 1444
1388 if (journal.phase === "built" || journal.phase === "canonical-moved") {1445 if (journal.phase === "built" || journal.phase === "canonical-moved") {
1389 await completeJournaledFlip(journalPath, journal);1446 await completeJournaledFlip(journalPath, journal);
1390 return;1447 return "recovered";
1391 }1448 }
1392 1449
1393 removeSqliteFileSet(journal.normalizedSource);1450 removeSqliteFileSet(journal.normalizedSource);
1394 removeSqliteFileSet(journal.staging);1451 removeSqliteFileSet(journal.staging);
1395 await cleanupSecretBackups(journal);1452 await cleanupSecretBackups(journal);
1396 removeMigrationJournal(journalPath);1453 removeMigrationJournal(journalPath);
1454 return "recovered";
1397};1455};
1398 1456
1399// No migration-internal process lock: this runs only inside1457// No migration-internal process lock: this runs only inside
@@ -1406,9 +1464,7 @@ export const migrateLocalV1ToV2IfNeeded = async ( · 6 unmodified lines
1406): Promise<LocalV1V2MigrationResult> => {1464): Promise<LocalV1V2MigrationResult> => {
1407 const journalPath = migrationJournalPath(options.sqlitePath);1465 const journalPath = migrationJournalPath(options.sqlitePath);
1408 1466
1409 if (fs.existsSync(journalPath)) {1467 const recovery = await recoverV1V2Migration(options.sqlitePath);
1410 await recoverV1V2Migration(options.sqlitePath);
1411 }
1412 1468
1413 if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] };1469 if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] };
1414 1470
@@ -1418,8 +1474,14 @@ export const migrateLocalV1ToV2IfNeeded = async ( · 3 unmodified lines
1418 // actual v1 database falls through to the heavy copy/stage/flip path below.1474 // actual v1 database falls through to the heavy copy/stage/flip path below.
1419 const probe: Client = await openLocalLibsql(options.sqlitePath);1475 const probe: Client = await openLocalLibsql(options.sqlitePath);
1420 try {1476 try {
1421 if (await hasV1GateStamp(probe)) return { migrated: false, warnings: [] };1477 if (await hasV1GateStamp(probe)) {
1422 if (!(await isLocalV1Database(probe))) return { migrated: false, warnings: [] };1478 if (recovery === "unreadable") removeMigrationJournal(journalPath);
1479 return { migrated: false, warnings: [] };
1480 }
1481 if (!(await isLocalV1Database(probe))) {
1482 if (recovery === "unreadable") removeMigrationJournal(journalPath);
1483 return { migrated: false, warnings: [] };
1484 }
1423 } finally {1485 } finally {
1424 probe.close();1486 probe.close();
1425 }1487 }