Unified · Split
| @@ -1122,14 +1122,61 @@ const removeMigrationJournal = (path: string): void => { · 1121 unmodified lines | |||
| 1122 | fsyncDirectory(dirname(path)); | 1122 | fsyncDirectory(dirname(path)); |
| 1123 | }; | 1123 | }; |
| 1124 | 1124 | ||
| 1125 | const readMigrationJournal = (path: string): MigrationJournal | null => { | 1125 | type 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 | |||
| 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" }; | ||
| 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 | ||
| 1374 | const recoverV1V2Migration = async (sqlitePath: string): Promise<void> => { | 1421 | type MigrationRecoveryResult = "missing" | "recovered" | "unreadable"; |
| 1422 | |||
| 1423 | const 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 inside | 1457 | // 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 | } |