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
11221122 fsyncDirectory(dirname(path));
11231123 };
11241124
1125−const readMigrationJournal = (path: string): MigrationJournal | null => {
1126− if (!fs.existsSync(path)) return null;
1125+type MigrationJournalReadResult =
1126+ | { readonly kind: "missing" }
1127+ | { readonly kind: "valid"; readonly journal: MigrationJournal }
1128+ | { readonly kind: "unreadable"; readonly cause: unknown };
1129+
1130+const isRecord = (value: unknown): value is Record<string, unknown> =>
1131+ typeof value === "object" && value !== null;
1132+
1133+const isMigrationJournalPhase = (value: unknown): value is MigrationJournalPhase =>
1134+ value === "building" || value === "built" || value === "canonical-moved" || value === "committed";
1135+
1136+const 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+
1145+const 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+
1164+const readMigrationJournal = (path: string): MigrationJournalReadResult => {
1165+ if (!fs.existsSync(path)) return { kind: "missing" };
11271166 try {
1128− const parsed = JSON.parse(fs.readFileSync(path, "utf-8")) as MigrationJournal;
1129− if (parsed.version !== 1) return null;
1130− return parsed;
1131− } catch {
1132− return null;
1167+ const parsed = JSON.parse(fs.readFileSync(path, "utf-8"));
1168+ if (!isMigrationJournal(parsed)) {
1169+ return {
1170+ kind: "unreadable",
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 };
11331180 }
11341181 };
11351182
@@ -1371,29 +1418,40 @@ const completeJournaledFlip = async ( · 235 unmodified lines
13711418 removeMigrationJournal(journalPath);
13721419 };
13731420
1374−const recoverV1V2Migration = async (sqlitePath: string): Promise<void> => {
1421+type MigrationRecoveryResult = "missing" | "recovered" | "unreadable";
1422+
1423+const recoverV1V2Migration = async (sqlitePath: string): Promise<MigrationRecoveryResult> => {
13751424 const journalPath = migrationJournalPath(sqlitePath);
1376− const journal = readMigrationJournal(journalPath);
1377− if (!journal) return;
1425+ const journalRead = readMigrationJournal(journalPath);
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+ }
13781434
1435+ const { journal } = journalRead;
13791436 if (journal.phase === "building") {
13801437 removeSqliteFileSet(journal.normalizedSource);
13811438 removeSqliteFileSet(journal.staging);
13821439 await restoreAuthFromJournal(journal);
13831440 await cleanupSecretBackups(journal);
13841441 removeMigrationJournal(journalPath);
1385− return;
1442+ return "recovered";
13861443 }
13871444
13881445 if (journal.phase === "built" || journal.phase === "canonical-moved") {
13891446 await completeJournaledFlip(journalPath, journal);
1390− return;
1447+ return "recovered";
13911448 }
13921449
13931450 removeSqliteFileSet(journal.normalizedSource);
13941451 removeSqliteFileSet(journal.staging);
13951452 await cleanupSecretBackups(journal);
13961453 removeMigrationJournal(journalPath);
1454+ return "recovered";
13971455 };
13981456
13991457 // No migration-internal process lock: this runs only inside
@@ -1406,9 +1464,7 @@ export const migrateLocalV1ToV2IfNeeded = async ( · 6 unmodified lines
14061464 ): Promise<LocalV1V2MigrationResult> => {
14071465 const journalPath = migrationJournalPath(options.sqlitePath);
14081466
1409− if (fs.existsSync(journalPath)) {
1410− await recoverV1V2Migration(options.sqlitePath);
1411− }
1467+ const recovery = await recoverV1V2Migration(options.sqlitePath);
14121468
14131469 if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] };
14141470
@@ -1418,8 +1474,14 @@ export const migrateLocalV1ToV2IfNeeded = async ( · 3 unmodified lines
14181474 // actual v1 database falls through to the heavy copy/stage/flip path below.
14191475 const probe: Client = await openLocalLibsql(options.sqlitePath);
14201476 try {
1421− if (await hasV1GateStamp(probe)) return { migrated: false, warnings: [] };
1422− if (!(await isLocalV1Database(probe))) return { migrated: false, warnings: [] };
1477+ if (await hasV1GateStamp(probe)) {
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+ }
14231485 } finally {
14241486 probe.close();
14251487 }