Unified · Split
| @@ -1122,14 +1122,61 @@ const removeMigrationJournal = (path: string): void => { · 1121 unmodified lines | ||
| 1122 | 1122 | fsyncDirectory(dirname(path)); |
| 1123 | 1123 | }; |
| 1124 | 1124 | |
| 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" }; | |
| 1127 | 1166 | 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 }; | |
| 1133 | 1180 | } |
| 1134 | 1181 | }; |
| 1135 | 1182 | |
| @@ -1371,29 +1418,40 @@ const completeJournaledFlip = async ( · 235 unmodified lines | ||
| 1371 | 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 | 1424 | 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 | + } | |
| 1378 | 1434 | |
| 1435 | + const { journal } = journalRead; | |
| 1379 | 1436 | if (journal.phase === "building") { |
| 1380 | 1437 | removeSqliteFileSet(journal.normalizedSource); |
| 1381 | 1438 | removeSqliteFileSet(journal.staging); |
| 1382 | 1439 | await restoreAuthFromJournal(journal); |
| 1383 | 1440 | await cleanupSecretBackups(journal); |
| 1384 | 1441 | removeMigrationJournal(journalPath); |
| 1385 | − return; | |
| 1442 | + return "recovered"; | |
| 1386 | 1443 | } |
| 1387 | 1444 | |
| 1388 | 1445 | if (journal.phase === "built" || journal.phase === "canonical-moved") { |
| 1389 | 1446 | await completeJournaledFlip(journalPath, journal); |
| 1390 | − return; | |
| 1447 | + return "recovered"; | |
| 1391 | 1448 | } |
| 1392 | 1449 | |
| 1393 | 1450 | removeSqliteFileSet(journal.normalizedSource); |
| 1394 | 1451 | removeSqliteFileSet(journal.staging); |
| 1395 | 1452 | await cleanupSecretBackups(journal); |
| 1396 | 1453 | removeMigrationJournal(journalPath); |
| 1454 | + return "recovered"; | |
| 1397 | 1455 | }; |
| 1398 | 1456 | |
| 1399 | 1457 | // No migration-internal process lock: this runs only inside |
| @@ -1406,9 +1464,7 @@ export const migrateLocalV1ToV2IfNeeded = async ( · 6 unmodified lines | ||
| 1406 | 1464 | ): Promise<LocalV1V2MigrationResult> => { |
| 1407 | 1465 | const journalPath = migrationJournalPath(options.sqlitePath); |
| 1408 | 1466 | |
| 1409 | − if (fs.existsSync(journalPath)) { | |
| 1410 | − await recoverV1V2Migration(options.sqlitePath); | |
| 1411 | − } | |
| 1467 | + const recovery = await recoverV1V2Migration(options.sqlitePath); | |
| 1412 | 1468 | |
| 1413 | 1469 | if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] }; |
| 1414 | 1470 | |
| @@ -1418,8 +1474,14 @@ export const migrateLocalV1ToV2IfNeeded = async ( · 3 unmodified lines | ||
| 1418 | 1474 | // actual v1 database falls through to the heavy copy/stage/flip path below. |
| 1419 | 1475 | const probe: Client = await openLocalLibsql(options.sqlitePath); |
| 1420 | 1476 | 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 | + } | |
| 1423 | 1485 | } finally { |
| 1424 | 1486 | probe.close(); |
| 1425 | 1487 | } |