| @@ -3,7 +3,7 @@ · 2 unmodified lines |
| 3 | 3 | import type { Client } from "@libsql/client"; |
| 4 | 4 | import { drizzle } from "drizzle-orm/libsql"; |
| 5 | 5 | import { migrate } from "drizzle-orm/libsql/migrator"; |
| 6 | | −import { Effect } from "effect"; |
| 6 | +import { Data, Effect } from "effect"; |
| 7 | 7 | import { createId } from "@executor-js/fumadb/cuid"; |
| 8 | 8 | import { createHash, randomBytes } from "node:crypto"; |
| 9 | 9 | import * as fs from "node:fs"; |
| @@ -103,11 +103,28 @@ export interface LocalV1V2MigrationOptions { · 93 unmodified lines |
| 103 | 103 | readonly oauthMetadataTimeoutMs?: number; |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | +class LocalV1V2MigrationError extends Data.TaggedError("LocalV1V2MigrationError")<{ |
| 107 | + readonly message: string; |
| 108 | + readonly cause?: unknown; |
| 109 | +}> {} |
| 110 | + |
| 106 | 111 | const FILE_PROVIDER = "file"; |
| 107 | 112 | const KEYCHAIN_PROVIDER = "keychain"; |
| 108 | 113 | |
| 109 | 114 | const fileSetSuffixes = ["", "-wal", "-shm"] as const; |
| 110 | 115 | |
| 116 | +type MigrationJournalPhase = "building" | "built" | "canonical-moved" | "committed"; |
| 117 | + |
| 118 | +interface MigrationJournal { |
| 119 | + readonly version: 1; |
| 120 | + readonly source: string; |
| 121 | + readonly normalizedSource: string; |
| 122 | + readonly staging: string; |
| 123 | + readonly backup: string; |
| 124 | + readonly nonce: string; |
| 125 | + readonly phase: MigrationJournalPhase; |
| 126 | +} |
| 127 | + |
| 111 | 128 | const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; |
| 112 | 129 | |
| 113 | 130 | const tableExists = async (client: Client, table: string): Promise<boolean> => |
| @@ -423,11 +440,14 @@ const discoverMcpOAuthResourceOverrides = async ( · 309 unmodified lines |
| 423 | 440 | }; |
| 424 | 441 | |
| 425 | 442 | const localOwnerForScope = |
| 426 | | − (tenantId: string) => |
| 443 | + (_tenantId: string) => |
| 427 | 444 | (scopeId: string): OwnerKeys | null => { |
| 428 | 445 | const cloud = parseScope(scopeId); |
| 429 | 446 | if (cloud) return cloud; |
| 430 | | − return { owner: "org", subject: "", tenant: tenantId }; |
| 447 | + // Local v1 scope ids are already workspace/tenant partitions. Preserve |
| 448 | + // that boundary instead of collapsing every historical local workspace into |
| 449 | + // the tenant for whichever cwd happens to boot the v2 migration first. |
| 450 | + return { owner: "org", subject: "", tenant: scopeId }; |
| 431 | 451 | }; |
| 432 | 452 | |
| 433 | 453 | const readV1Snapshot = async (client: Client, tenantId: string): Promise<LocalV1Snapshot> => { |
| @@ -654,15 +674,15 @@ const providerGet = async ( · 220 unmodified lines |
| 654 | 674 | return null; |
| 655 | 675 | }; |
| 656 | 676 | |
| 657 | | −const collectSecretValues = async ( |
| 658 | | − plan: MigrationPlan, |
| 659 | | −): Promise<{ |
| 677 | +interface CollectedSecretValues { |
| 660 | 678 | readonly fileValues: Record<string, string>; |
| 661 | 679 | readonly keychainValues: ReadonlyArray<{ readonly id: string; readonly value: string }>; |
| 662 | 680 | readonly idOverrides: ReadonlyMap<string, string>; |
| 663 | 681 | readonly oauthClientIdValues: ReadonlyMap<string, string>; |
| 664 | 682 | readonly warnings: readonly string[]; |
| 665 | | −}> => { |
| 683 | +} |
| 684 | + |
| 685 | +const collectSecretValues = async (plan: MigrationPlan): Promise<CollectedSecretValues> => { |
| 666 | 686 | const authPath = resolveFileAuthPath(); |
| 667 | 687 | const fileValues = flatAuthEntries(readAuthFile(authPath)); |
| 668 | 688 | const keychainValues: { id: string; value: string }[] = []; |
| @@ -765,6 +785,7 @@ const insertPlan = async ( · 96 unmodified lines |
| 765 | 785 | oauthClientIdValues: ReadonlyMap<string, string>, |
| 766 | 786 | oauthAuthorizationUrls: ReadonlyMap<string, string>, |
| 767 | 787 | tenantId: string, |
| 788 | + secretValues: CollectedSecretValues, |
| 768 | 789 | ): Promise<void> => { |
| 769 | 790 | const now = timestamp(); |
| 770 | 791 | const ownerForScope = localOwnerForScope(tenantId); |
| @@ -956,6 +977,20 @@ const insertPlan = async ( · 185 unmodified lines |
| 956 | 977 | ); |
| 957 | 978 | } |
| 958 | 979 | |
| 980 | + // Secrets live outside this SQLite transaction (file/keychain providers), so |
| 981 | + // they cannot be made atomically durable with the DB. Write them before the |
| 982 | + // v1→v2 stamp and keep the writes idempotent; if the process dies before |
| 983 | + // COMMIT, the next boot replays the same secret writes harmlessly. |
| 984 | + await writeMigratedSecrets(secretValues); |
| 985 | + await executeSql( |
| 986 | + client, |
| 987 | + "CREATE TABLE IF NOT EXISTS data_migration (name TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)", |
| 988 | + ); |
| 989 | + await executeSql(client, "INSERT INTO data_migration (name, time_completed) VALUES (?, ?)", [ |
| 990 | + LOCAL_V1_V2_LEDGER_NAME, |
| 991 | + Date.now(), |
| 992 | + ]); |
| 993 | + |
| 959 | 994 | await client.execute("COMMIT"); |
| 960 | 995 | } catch (cause) { |
| 961 | 996 | await client.execute("ROLLBACK"); |
| @@ -984,20 +1019,114 @@ const renameWithRetry = async (source: string, target: string): Promise<void> => · 22 unmodified lines |
| 984 | 1019 | } |
| 985 | 1020 | }; |
| 986 | 1021 | |
| 1022 | +const fsyncFileIfExists = (path: string): void => { |
| 1023 | + if (!fs.existsSync(path)) return; |
| 1024 | + const fd = fs.openSync(path, "r"); |
| 1025 | + try { |
| 1026 | + fs.fsyncSync(fd); |
| 1027 | + } finally { |
| 1028 | + fs.closeSync(fd); |
| 1029 | + } |
| 1030 | +}; |
| 1031 | + |
| 1032 | +const fsyncDirectory = (path: string): void => { |
| 1033 | + try { |
| 1034 | + const fd = fs.openSync(path, "r"); |
| 1035 | + try { |
| 1036 | + fs.fsyncSync(fd); |
| 1037 | + } finally { |
| 1038 | + fs.closeSync(fd); |
| 1039 | + } |
| 1040 | + } catch { |
| 1041 | + // Some platforms/filesystems do not allow fsync on directories. The rename |
| 1042 | + // still happened; this is best-effort durability hardening. |
| 1043 | + } |
| 1044 | +}; |
| 1045 | + |
| 1046 | +const fsyncSqliteFileSet = (path: string): void => { |
| 1047 | + for (const suffix of fileSetSuffixes) fsyncFileIfExists(`${path}${suffix}`); |
| 1048 | +}; |
| 1049 | + |
| 1050 | +const fileSetAnyExists = (path: string): boolean => |
| 1051 | + fileSetSuffixes.some((suffix) => fs.existsSync(`${path}${suffix}`)); |
| 1052 | + |
| 987 | 1053 | const moveSqliteFileSet = async (source: string, target: string): Promise<void> => { |
| 988 | 1054 | await renameWithRetry(source, target); |
| 1055 | + fsyncDirectory(dirname(target)); |
| 989 | 1056 | for (const suffix of ["-wal", "-shm"] as const) { |
| 990 | | − if (fs.existsSync(`${source}${suffix}`)) |
| 1057 | + if (fs.existsSync(`${source}${suffix}`)) { |
| 991 | 1058 | await renameWithRetry(`${source}${suffix}`, `${target}${suffix}`); |
| 1059 | + fsyncDirectory(dirname(target)); |
| 1060 | + } else { |
| 1061 | + fs.rmSync(`${target}${suffix}`, { force: true }); |
| 1062 | + } |
| 1063 | + } |
| 1064 | +}; |
| 1065 | + |
| 1066 | +const moveExistingSqliteFileSet = async (source: string, target: string): Promise<void> => { |
| 1067 | + for (const suffix of fileSetSuffixes) { |
| 1068 | + const from = `${source}${suffix}`; |
| 1069 | + if (!fs.existsSync(from)) continue; |
| 1070 | + const to = `${target}${suffix}`; |
| 1071 | + if (fs.existsSync(to)) { |
| 1072 | + fs.rmSync(from, { force: true }); |
| 1073 | + } else { |
| 1074 | + await renameWithRetry(from, to); |
| 1075 | + fsyncDirectory(dirname(to)); |
| 1076 | + } |
| 992 | 1077 | } |
| 993 | 1078 | }; |
| 994 | 1079 | |
| 1080 | +const copySqliteFileSet = (source: string, target: string): void => { |
| 1081 | + removeSqliteFileSet(target); |
| 1082 | + for (const suffix of fileSetSuffixes) { |
| 1083 | + const from = `${source}${suffix}`; |
| 1084 | + const to = `${target}${suffix}`; |
| 1085 | + if (fs.existsSync(from)) { |
| 1086 | + fs.copyFileSync(from, to); |
| 1087 | + fsyncFileIfExists(to); |
| 1088 | + } else { |
| 1089 | + fs.rmSync(to, { force: true }); |
| 1090 | + } |
| 1091 | + } |
| 1092 | + fsyncDirectory(dirname(target)); |
| 1093 | +}; |
| 1094 | + |
| 995 | 1095 | const removeSqliteFileSet = (path: string): void => { |
| 996 | 1096 | for (const suffix of fileSetSuffixes) fs.rmSync(`${path}${suffix}`, { force: true }); |
| 997 | 1097 | }; |
| 998 | 1098 | |
| 999 | | −const backupPathFor = (sqlitePath: string): string => |
| 1000 | | − `${sqlitePath}.v1-v2-${Date.now()}-${randomBytes(4).toString("hex")}`; |
| 1099 | +const backupPathFor = (sqlitePath: string, nonce: string): string => |
| 1100 | + `${sqlitePath}.v1-v2-${Date.now()}-${nonce}`; |
| 1101 | + |
| 1102 | +const stagingPathFor = (sqlitePath: string, nonce: string): string => |
| 1103 | + `${sqlitePath}.building-${nonce}`; |
| 1104 | + |
| 1105 | +const normalizedSourcePathFor = (sqlitePath: string, nonce: string): string => |
| 1106 | + `${sqlitePath}.source-${nonce}`; |
| 1107 | + |
| 1108 | +const migrationJournalPath = (sqlitePath: string): string => `${sqlitePath}.v1-v2-migration.json`; |
| 1109 | + |
| 1110 | +const writeMigrationJournal = (path: string, journal: MigrationJournal): void => { |
| 1111 | + fs.mkdirSync(dirname(path), { recursive: true }); |
| 1112 | + const tmp = `${path}.tmp-${journal.nonce}`; |
| 1113 | + fs.writeFileSync(tmp, `${JSON.stringify(journal, null, 2)}\n`, { mode: 0o600 }); |
| 1114 | + fsyncFileIfExists(tmp); |
| 1115 | + fs.renameSync(tmp, path); |
| 1116 | + fsyncDirectory(dirname(path)); |
| 1117 | +}; |
| 1118 | + |
| 1119 | +const removeMigrationJournal = (path: string): void => { |
| 1120 | + fs.rmSync(path, { force: true }); |
| 1121 | + fsyncDirectory(dirname(path)); |
| 1122 | +}; |
| 1123 | + |
| 1124 | +const readMigrationJournal = (path: string): MigrationJournal | null => { |
| 1125 | + if (!fs.existsSync(path)) return null; |
| 1126 | + const parsed = JSON.parse(fs.readFileSync(path, "utf-8")) as MigrationJournal; |
| 1127 | + if (parsed.version !== 1) return null; |
| 1128 | + return parsed; |
| 1129 | +}; |
| 1001 | 1130 | |
| 1002 | 1131 | const writeMigratedSecrets = async (input: { |
| 1003 | 1132 | readonly fileValues: Record<string, string>; |
| @@ -1010,69 +1139,281 @@ const writeMigratedSecrets = async (input: { · 6 unmodified lines |
| 1010 | 1139 | writeFlatAuthFile(resolveFileAuthPath(), input.fileValues); |
| 1011 | 1140 | }; |
| 1012 | 1141 | |
| 1142 | +interface MigrationExpectedCounts { |
| 1143 | + readonly integrations: number; |
| 1144 | + readonly connections: number; |
| 1145 | + readonly oauthClients: number; |
| 1146 | + readonly tools: number; |
| 1147 | + readonly definitions: number; |
| 1148 | + readonly policies: number; |
| 1149 | +} |
| 1150 | + |
| 1151 | +const expectedCountsFor = ( |
| 1152 | + snapshot: LocalV1Snapshot, |
| 1153 | + plan: MigrationPlan, |
| 1154 | +): MigrationExpectedCounts => { |
| 1155 | + const connectionTargets = plan.connections.map((connection) => ({ |
| 1156 | + sourceScopeId: connection.sourceScopeId, |
| 1157 | + sourceId: connection.sourceId, |
| 1158 | + })); |
| 1159 | + const targetCountFor = (scopeId: string, sourceId: string): number => |
| 1160 | + connectionTargets.filter( |
| 1161 | + (target) => target.sourceScopeId === scopeId && target.sourceId === sourceId, |
| 1162 | + ).length; |
| 1163 | + return { |
| 1164 | + integrations: plan.integrations.length, |
| 1165 | + connections: plan.connections.length, |
| 1166 | + oauthClients: plan.oauthClients.length, |
| 1167 | + tools: snapshot.tools.reduce((sum, row) => sum + targetCountFor(row.scopeId, row.sourceId), 0), |
| 1168 | + definitions: snapshot.definitions.reduce( |
| 1169 | + (sum, row) => sum + targetCountFor(row.scopeId, row.sourceId), |
| 1170 | + 0, |
| 1171 | + ), |
| 1172 | + policies: plan.policies.length, |
| 1173 | + }; |
| 1174 | +}; |
| 1175 | + |
| 1176 | +const scalarNumber = async (client: Client, sql: string): Promise<number> => { |
| 1177 | + const result = await client.execute(sql); |
| 1178 | + const value = result.rows[0]?.["n"]; |
| 1179 | + return typeof value === "number" ? value : Number(value ?? 0); |
| 1180 | +}; |
| 1181 | + |
| 1182 | +const verifyStagingDatabase = async ( |
| 1183 | + path: string, |
| 1184 | + expected: MigrationExpectedCounts, |
| 1185 | +): Promise<void> => { |
| 1186 | + const client = await openLocalLibsql(path); |
| 1187 | + try { |
| 1188 | + const integrity = await client.execute("PRAGMA integrity_check"); |
| 1189 | + if (integrity.rows[0]?.["integrity_check"] !== "ok") { |
| 1190 | + throw new LocalV1V2MigrationError({ |
| 1191 | + message: `Staged v2 database failed integrity_check: ${JSON.stringify(integrity.rows)}`, |
| 1192 | + cause: integrity.rows, |
| 1193 | + }); |
| 1194 | + } |
| 1195 | + const stamped = await queryFirst<{ name: string }>( |
| 1196 | + client, |
| 1197 | + "SELECT name FROM data_migration WHERE name = ?", |
| 1198 | + [LOCAL_V1_V2_LEDGER_NAME], |
| 1199 | + ); |
| 1200 | + if (!stamped) { |
| 1201 | + throw new LocalV1V2MigrationError({ |
| 1202 | + message: "Staged v2 database is missing the v1→v2 stamp", |
| 1203 | + }); |
| 1204 | + } |
| 1205 | + |
| 1206 | + const actual = { |
| 1207 | + integrations: await scalarNumber(client, "SELECT COUNT(*) AS n FROM integration"), |
| 1208 | + connections: await scalarNumber(client, "SELECT COUNT(*) AS n FROM connection"), |
| 1209 | + oauthClients: await scalarNumber(client, "SELECT COUNT(*) AS n FROM oauth_client"), |
| 1210 | + tools: await scalarNumber(client, "SELECT COUNT(*) AS n FROM tool"), |
| 1211 | + definitions: await scalarNumber(client, "SELECT COUNT(*) AS n FROM definition"), |
| 1212 | + policies: await scalarNumber(client, "SELECT COUNT(*) AS n FROM tool_policy"), |
| 1213 | + }; |
| 1214 | + if (JSON.stringify(actual) !== JSON.stringify(expected)) { |
| 1215 | + throw new LocalV1V2MigrationError({ |
| 1216 | + message: `Staged v2 database row-count mismatch: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, |
| 1217 | + cause: { expected, actual }, |
| 1218 | + }); |
| 1219 | + } |
| 1220 | + await client.execute("PRAGMA wal_checkpoint(TRUNCATE)"); |
| 1221 | + } finally { |
| 1222 | + client.close(); |
| 1223 | + } |
| 1224 | + fsyncSqliteFileSet(path); |
| 1225 | +}; |
| 1226 | + |
| 1227 | +const completeJournaledFlip = async ( |
| 1228 | + journalPath: string, |
| 1229 | + journal: MigrationJournal, |
| 1230 | +): Promise<void> => { |
| 1231 | + if (fileSetAnyExists(journal.source)) { |
| 1232 | + writeMigrationJournal(journalPath, { ...journal, phase: "canonical-moved" }); |
| 1233 | + await moveExistingSqliteFileSet(journal.source, journal.backup); |
| 1234 | + } |
| 1235 | + if (!fs.existsSync(journal.source) && fs.existsSync(journal.staging)) { |
| 1236 | + await moveSqliteFileSet(journal.staging, journal.source); |
| 1237 | + } |
| 1238 | + writeMigrationJournal(journalPath, { ...journal, phase: "committed" }); |
| 1239 | + removeSqliteFileSet(journal.normalizedSource); |
| 1240 | + removeSqliteFileSet(journal.staging); |
| 1241 | + removeMigrationJournal(journalPath); |
| 1242 | +}; |
| 1243 | + |
| 1244 | +const recoverV1V2Migration = async (sqlitePath: string): Promise<void> => { |
| 1245 | + const journalPath = migrationJournalPath(sqlitePath); |
| 1246 | + const journal = readMigrationJournal(journalPath); |
| 1247 | + if (!journal) return; |
| 1248 | + |
| 1249 | + if (journal.phase === "building") { |
| 1250 | + removeSqliteFileSet(journal.normalizedSource); |
| 1251 | + removeSqliteFileSet(journal.staging); |
| 1252 | + removeMigrationJournal(journalPath); |
| 1253 | + return; |
| 1254 | + } |
| 1255 | + |
| 1256 | + if (journal.phase === "built" || journal.phase === "canonical-moved") { |
| 1257 | + await completeJournaledFlip(journalPath, journal); |
| 1258 | + return; |
| 1259 | + } |
| 1260 | + |
| 1261 | + removeSqliteFileSet(journal.normalizedSource); |
| 1262 | + removeSqliteFileSet(journal.staging); |
| 1263 | + removeMigrationJournal(journalPath); |
| 1264 | +}; |
| 1265 | + |
| 1266 | +const isPidAlive = (pid: number): boolean => { |
| 1267 | + if (!Number.isInteger(pid) || pid <= 0) return false; |
| 1268 | + try { |
| 1269 | + process.kill(pid, 0); |
| 1270 | + return true; |
| 1271 | + } catch { |
| 1272 | + return false; |
| 1273 | + } |
| 1274 | +}; |
| 1275 | + |
| 1276 | +interface MigrationLock { |
| 1277 | + readonly path: string; |
| 1278 | +} |
| 1279 | + |
| 1280 | +const acquireMigrationLock = (sqlitePath: string): MigrationLock => { |
| 1281 | + const lockPath = `${sqlitePath}.v1-v2.lock`; |
| 1282 | + fs.mkdirSync(dirname(lockPath), { recursive: true }); |
| 1283 | + const payload = `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`; |
| 1284 | + try { |
| 1285 | + fs.writeFileSync(lockPath, payload, { flag: "wx", mode: 0o600 }); |
| 1286 | + return { path: lockPath }; |
| 1287 | + } catch (cause) { |
| 1288 | + if (!fs.existsSync(lockPath)) throw cause; |
| 1289 | + try { |
| 1290 | + const existing = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as { pid?: unknown }; |
| 1291 | + if (typeof existing.pid === "number" && !isPidAlive(existing.pid)) { |
| 1292 | + fs.rmSync(lockPath, { force: true }); |
| 1293 | + fs.writeFileSync(lockPath, payload, { flag: "wx", mode: 0o600 }); |
| 1294 | + return { path: lockPath }; |
| 1295 | + } |
| 1296 | + } catch {} |
| 1297 | + throw new LocalV1V2MigrationError({ |
| 1298 | + message: "Another local v1→v2 SQLite migration is already in progress.", |
| 1299 | + }); |
| 1300 | + } |
| 1301 | +}; |
| 1302 | + |
| 1303 | +const releaseMigrationLock = (lock: MigrationLock): void => { |
| 1304 | + fs.rmSync(lock.path, { force: true }); |
| 1305 | +}; |
| 1306 | + |
| 1013 | 1307 | export const migrateLocalV1ToV2IfNeeded = async ( |
| 1014 | 1308 | options: LocalV1V2MigrationOptions, |
| 1015 | 1309 | ): Promise<LocalV1V2MigrationResult> => { |
| 1016 | | − if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] }; |
| 1310 | + const lock = acquireMigrationLock(options.sqlitePath); |
| 1311 | + let normalizedSourcePath: string | null = null; |
| 1312 | + let stagingPath: string | null = null; |
| 1313 | + let target: Awaited<ReturnType<typeof createSqliteFumaDb>> | null = null; |
| 1314 | + let reader: Client | null = null; |
| 1315 | + let flipStarted = false; |
| 1017 | 1316 | |
| 1018 | | − const reader = await openLocalLibsql(options.sqlitePath); |
| 1019 | 1317 | try { |
| 1318 | + await recoverV1V2Migration(options.sqlitePath); |
| 1319 | + if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] }; |
| 1320 | + |
| 1321 | + const nonce = randomBytes(4).toString("hex"); |
| 1322 | + normalizedSourcePath = normalizedSourcePathFor(options.sqlitePath, nonce); |
| 1323 | + stagingPath = stagingPathFor(options.sqlitePath, nonce); |
| 1324 | + const backupPath = backupPathFor(options.sqlitePath, nonce); |
| 1325 | + const journalPath = migrationJournalPath(options.sqlitePath); |
| 1326 | + |
| 1327 | + copySqliteFileSet(options.sqlitePath, normalizedSourcePath); |
| 1328 | + reader = await openLocalLibsql(normalizedSourcePath); |
| 1020 | 1329 | // Ledger short-circuit: once a database has been through this gate the |
| 1021 | 1330 | // boot data-migration registry stamps it, and the stamp — not schema |
| 1022 | 1331 | // shape — decides. Probing by shape on every boot is what made a v2 |
| 1023 | 1332 | // database with residual v1-looking state re-enter the migration. |
| 1024 | | − if (await hasV1GateStamp(reader)) return { migrated: false, warnings: [] }; |
| 1025 | | − if (!(await isLocalV1Database(reader))) return { migrated: false, warnings: [] }; |
| 1333 | + if (await hasV1GateStamp(reader)) { |
| 1334 | + reader.close(); |
| 1335 | + reader = null; |
| 1336 | + removeSqliteFileSet(normalizedSourcePath); |
| 1337 | + normalizedSourcePath = null; |
| 1338 | + return { migrated: false, warnings: [] }; |
| 1339 | + } |
| 1340 | + if (!(await isLocalV1Database(reader))) { |
| 1341 | + reader.close(); |
| 1342 | + reader = null; |
| 1343 | + removeSqliteFileSet(normalizedSourcePath); |
| 1344 | + normalizedSourcePath = null; |
| 1345 | + return { migrated: false, warnings: [] }; |
| 1346 | + } |
| 1347 | + |
| 1348 | + const journal: MigrationJournal = { |
| 1349 | + version: 1, |
| 1350 | + source: options.sqlitePath, |
| 1351 | + normalizedSource: normalizedSourcePath, |
| 1352 | + staging: stagingPath, |
| 1353 | + backup: backupPath, |
| 1354 | + nonce, |
| 1355 | + phase: "building", |
| 1356 | + }; |
| 1357 | + writeMigrationJournal(journalPath, journal); |
| 1358 | + |
| 1026 | 1359 | const replayWarnings: string[] = []; |
| 1360 | + // Legacy replay is intentionally confined to the normalized source copy; |
| 1361 | + // the canonical data.db remains a complete v1 database until the final |
| 1362 | + // journaled flip installs the staged v2 database. |
| 1027 | 1363 | await replayLegacyV1Migrations(reader, replayWarnings); |
| 1028 | 1364 | const snapshot = await readV1Snapshot(reader, options.tenantId); |
| 1365 | + reader.close(); |
| 1366 | + reader = null; |
| 1367 | + |
| 1029 | 1368 | const plan = planMigration(snapshot.input); |
| 1030 | 1369 | const secretValues = await collectSecretValues(plan); |
| 1031 | 1370 | const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, { |
| 1032 | 1371 | fetch: options.oauthMetadataFetch ?? fetch, |
| 1033 | 1372 | timeoutMs: options.oauthMetadataTimeoutMs, |
| 1034 | 1373 | }); |
| 1035 | | − const backupPath = backupPathFor(options.sqlitePath); |
| 1036 | | − |
| 1037 | | − reader.close(); |
| 1038 | | − await moveSqliteFileSet(options.sqlitePath, backupPath); |
| 1039 | 1374 | |
| 1040 | | − let target: Awaited<ReturnType<typeof createSqliteFumaDb>> | null = null; |
| 1041 | | − try { |
| 1042 | | − target = await createSqliteFumaDb({ |
| 1043 | | − tables: options.tables, |
| 1044 | | − namespace: options.namespace, |
| 1045 | | − path: options.sqlitePath, |
| 1046 | | − }); |
| 1047 | | − await insertPlan( |
| 1048 | | − target.client, |
| 1049 | | − snapshot, |
| 1050 | | − plan, |
| 1051 | | − secretValues.idOverrides, |
| 1052 | | − secretValues.oauthClientIdValues, |
| 1053 | | − oauthAuthorizationUrls, |
| 1054 | | − options.tenantId, |
| 1055 | | − ); |
| 1056 | | − await target.close(); |
| 1057 | | − target = null; |
| 1058 | | − await writeMigratedSecrets(secretValues); |
| 1059 | | − return { |
| 1060 | | − migrated: true, |
| 1061 | | − backupPath, |
| 1062 | | − report: plan.report, |
| 1063 | | − warnings: [...replayWarnings, ...plan.report.warnings, ...secretValues.warnings], |
| 1064 | | − }; |
| 1065 | | − } catch (cause) { |
| 1066 | | − if (target) await target.close(); |
| 1067 | | − removeSqliteFileSet(options.sqlitePath); |
| 1068 | | − if (fs.existsSync(backupPath)) await moveSqliteFileSet(backupPath, options.sqlitePath); |
| 1069 | | − throw cause; |
| 1375 | + target = await createSqliteFumaDb({ |
| 1376 | + tables: options.tables, |
| 1377 | + namespace: options.namespace, |
| 1378 | + path: stagingPath, |
| 1379 | + }); |
| 1380 | + await insertPlan( |
| 1381 | + target.client, |
| 1382 | + snapshot, |
| 1383 | + plan, |
| 1384 | + secretValues.idOverrides, |
| 1385 | + secretValues.oauthClientIdValues, |
| 1386 | + oauthAuthorizationUrls, |
| 1387 | + options.tenantId, |
| 1388 | + secretValues, |
| 1389 | + ); |
| 1390 | + await target.client.execute("PRAGMA wal_checkpoint(TRUNCATE)"); |
| 1391 | + await target.close(); |
| 1392 | + target = null; |
| 1393 | + |
| 1394 | + await verifyStagingDatabase(stagingPath, expectedCountsFor(snapshot, plan)); |
| 1395 | + writeMigrationJournal(journalPath, { ...journal, phase: "built" }); |
| 1396 | + flipStarted = true; |
| 1397 | + await completeJournaledFlip(journalPath, { ...journal, phase: "built" }); |
| 1398 | + |
| 1399 | + normalizedSourcePath = null; |
| 1400 | + stagingPath = null; |
| 1401 | + return { |
| 1402 | + migrated: true, |
| 1403 | + backupPath, |
| 1404 | + report: plan.report, |
| 1405 | + warnings: [...replayWarnings, ...plan.report.warnings, ...secretValues.warnings], |
| 1406 | + }; |
| 1407 | + } catch (cause) { |
| 1408 | + if (target) await target.close(); |
| 1409 | + if (reader) reader.close(); |
| 1410 | + if (!flipStarted) { |
| 1411 | + if (normalizedSourcePath) removeSqliteFileSet(normalizedSourcePath); |
| 1412 | + if (stagingPath) removeSqliteFileSet(stagingPath); |
| 1413 | + removeMigrationJournal(migrationJournalPath(options.sqlitePath)); |
| 1070 | 1414 | } |
| 1415 | + throw cause; |
| 1071 | 1416 | } finally { |
| 1072 | | − try { |
| 1073 | | − reader.close(); |
| 1074 | | − } catch { |
| 1075 | | − // already closed after the snapshot was read |
| 1076 | | − } |
| 1417 | + releaseMigrationLock(lock); |
| 1077 | 1418 | } |
| 1078 | 1419 | }; |