| @@ -3,7 +3,7 @@ · 2 unmodified lines |
| 3 | import type { Client } from "@libsql/client"; | 3 | import type { Client } from "@libsql/client"; |
| 4 | import { drizzle } from "drizzle-orm/libsql"; | 4 | import { drizzle } from "drizzle-orm/libsql"; |
| 5 | import { migrate } from "drizzle-orm/libsql/migrator"; | 5 | import { migrate } from "drizzle-orm/libsql/migrator"; |
| 6 | import { Effect } from "effect"; | 6 | import { Data, Effect } from "effect"; |
| 7 | import { createId } from "@executor-js/fumadb/cuid"; | 7 | import { createId } from "@executor-js/fumadb/cuid"; |
| 8 | import { createHash, randomBytes } from "node:crypto"; | 8 | import { createHash, randomBytes } from "node:crypto"; |
| 9 | import * as fs from "node:fs"; | 9 | import * as fs from "node:fs"; |
| @@ -103,11 +103,28 @@ export interface LocalV1V2MigrationOptions { · 93 unmodified lines |
| 103 | readonly oauthMetadataTimeoutMs?: number; | 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 | const FILE_PROVIDER = "file"; | 111 | const FILE_PROVIDER = "file"; |
| 107 | const KEYCHAIN_PROVIDER = "keychain"; | 112 | const KEYCHAIN_PROVIDER = "keychain"; |
| 108 | | 113 | |
| 109 | const fileSetSuffixes = ["", "-wal", "-shm"] as const; | 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 | const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; | 128 | const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; |
| 112 | | 129 | |
| 113 | const tableExists = async (client: Client, table: string): Promise<boolean> => | 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 | const localOwnerForScope = | 442 | const localOwnerForScope = |
| 426 | (tenantId: string) => | 443 | (_tenantId: string) => |
| 427 | (scopeId: string): OwnerKeys | null => { | 444 | (scopeId: string): OwnerKeys | null => { |
| 428 | const cloud = parseScope(scopeId); | 445 | const cloud = parseScope(scopeId); |
| 429 | if (cloud) return cloud; | 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 | const readV1Snapshot = async (client: Client, tenantId: string): Promise<LocalV1Snapshot> => { | 453 | const readV1Snapshot = async (client: Client, tenantId: string): Promise<LocalV1Snapshot> => { |
| @@ -654,15 +674,15 @@ const providerGet = async ( · 220 unmodified lines |
| 654 | return null; | 674 | return null; |
| 655 | }; | 675 | }; |
| 656 | | 676 | |
| 657 | const collectSecretValues = async ( | 677 | interface CollectedSecretValues { |
| 658 | plan: MigrationPlan, | | |
| 659 | ): Promise<{ | | |
| 660 | readonly fileValues: Record<string, string>; | 678 | readonly fileValues: Record<string, string>; |
| 661 | readonly keychainValues: ReadonlyArray<{ readonly id: string; readonly value: string }>; | 679 | readonly keychainValues: ReadonlyArray<{ readonly id: string; readonly value: string }>; |
| 662 | readonly idOverrides: ReadonlyMap<string, string>; | 680 | readonly idOverrides: ReadonlyMap<string, string>; |
| 663 | readonly oauthClientIdValues: ReadonlyMap<string, string>; | 681 | readonly oauthClientIdValues: ReadonlyMap<string, string>; |
| 664 | readonly warnings: readonly string[]; | 682 | readonly warnings: readonly string[]; |
| 665 | }> => { | 683 | } |
| | 684 | |
| | 685 | const collectSecretValues = async (plan: MigrationPlan): Promise<CollectedSecretValues> => { |
| 666 | const authPath = resolveFileAuthPath(); | 686 | const authPath = resolveFileAuthPath(); |
| 667 | const fileValues = flatAuthEntries(readAuthFile(authPath)); | 687 | const fileValues = flatAuthEntries(readAuthFile(authPath)); |
| 668 | const keychainValues: { id: string; value: string }[] = []; | 688 | const keychainValues: { id: string; value: string }[] = []; |
| @@ -765,6 +785,7 @@ const insertPlan = async ( · 96 unmodified lines |
| 765 | oauthClientIdValues: ReadonlyMap<string, string>, | 785 | oauthClientIdValues: ReadonlyMap<string, string>, |
| 766 | oauthAuthorizationUrls: ReadonlyMap<string, string>, | 786 | oauthAuthorizationUrls: ReadonlyMap<string, string>, |
| 767 | tenantId: string, | 787 | tenantId: string, |
| | 788 | secretValues: CollectedSecretValues, |
| 768 | ): Promise<void> => { | 789 | ): Promise<void> => { |
| 769 | const now = timestamp(); | 790 | const now = timestamp(); |
| 770 | const ownerForScope = localOwnerForScope(tenantId); | 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 | await client.execute("COMMIT"); | 994 | await client.execute("COMMIT"); |
| 960 | } catch (cause) { | 995 | } catch (cause) { |
| 961 | await client.execute("ROLLBACK"); | 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 | const moveSqliteFileSet = async (source: string, target: string): Promise<void> => { | 1053 | const moveSqliteFileSet = async (source: string, target: string): Promise<void> => { |
| 988 | await renameWithRetry(source, target); | 1054 | await renameWithRetry(source, target); |
| | 1055 | fsyncDirectory(dirname(target)); |
| 989 | for (const suffix of ["-wal", "-shm"] as const) { | 1056 | for (const suffix of ["-wal", "-shm"] as const) { |
| 990 | if (fs.existsSync(`${source}${suffix}`)) | 1057 | if (fs.existsSync(`${source}${suffix}`)) { |
| 991 | await renameWithRetry(`${source}${suffix}`, `${target}${suffix}`); | 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 | const removeSqliteFileSet = (path: string): void => { | 1095 | const removeSqliteFileSet = (path: string): void => { |
| 996 | for (const suffix of fileSetSuffixes) fs.rmSync(`${path}${suffix}`, { force: true }); | 1096 | for (const suffix of fileSetSuffixes) fs.rmSync(`${path}${suffix}`, { force: true }); |
| 997 | }; | 1097 | }; |
| 998 | | 1098 | |
| 999 | const backupPathFor = (sqlitePath: string): string => | 1099 | const backupPathFor = (sqlitePath: string, nonce: string): string => |
| 1000 | `${sqlitePath}.v1-v2-${Date.now()}-${randomBytes(4).toString("hex")}`; | 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 | const writeMigratedSecrets = async (input: { | 1131 | const writeMigratedSecrets = async (input: { |
| 1003 | readonly fileValues: Record<string, string>; | 1132 | readonly fileValues: Record<string, string>; |
| @@ -1010,69 +1139,281 @@ const writeMigratedSecrets = async (input: { · 6 unmodified lines |
| 1010 | writeFlatAuthFile(resolveFileAuthPath(), input.fileValues); | 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 | export const migrateLocalV1ToV2IfNeeded = async ( | 1307 | export const migrateLocalV1ToV2IfNeeded = async ( |
| 1014 | options: LocalV1V2MigrationOptions, | 1308 | options: LocalV1V2MigrationOptions, |
| 1015 | ): Promise<LocalV1V2MigrationResult> => { | 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 | try { | 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 | // Ledger short-circuit: once a database has been through this gate the | 1329 | // Ledger short-circuit: once a database has been through this gate the |
| 1021 | // boot data-migration registry stamps it, and the stamp — not schema | 1330 | // boot data-migration registry stamps it, and the stamp — not schema |
| 1022 | // shape — decides. Probing by shape on every boot is what made a v2 | 1331 | // shape — decides. Probing by shape on every boot is what made a v2 |
| 1023 | // database with residual v1-looking state re-enter the migration. | 1332 | // database with residual v1-looking state re-enter the migration. |
| 1024 | if (await hasV1GateStamp(reader)) return { migrated: false, warnings: [] }; | 1333 | if (await hasV1GateStamp(reader)) { |
| 1025 | if (!(await isLocalV1Database(reader))) return { migrated: false, warnings: [] }; | 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 | const replayWarnings: string[] = []; | 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 | await replayLegacyV1Migrations(reader, replayWarnings); | 1363 | await replayLegacyV1Migrations(reader, replayWarnings); |
| 1028 | const snapshot = await readV1Snapshot(reader, options.tenantId); | 1364 | const snapshot = await readV1Snapshot(reader, options.tenantId); |
| | 1365 | reader.close(); |
| | 1366 | reader = null; |
| | 1367 | |
| 1029 | const plan = planMigration(snapshot.input); | 1368 | const plan = planMigration(snapshot.input); |
| 1030 | const secretValues = await collectSecretValues(plan); | 1369 | const secretValues = await collectSecretValues(plan); |
| 1031 | const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, { | 1370 | const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, { |
| 1032 | fetch: options.oauthMetadataFetch ?? fetch, | 1371 | fetch: options.oauthMetadataFetch ?? fetch, |
| 1033 | timeoutMs: options.oauthMetadataTimeoutMs, | 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; | 1375 | target = await createSqliteFumaDb({ |
| 1041 | try { | 1376 | tables: options.tables, |
| 1042 | target = await createSqliteFumaDb({ | 1377 | namespace: options.namespace, |
| 1043 | tables: options.tables, | 1378 | path: stagingPath, |
| 1044 | namespace: options.namespace, | 1379 | }); |
| 1045 | path: options.sqlitePath, | 1380 | await insertPlan( |
| 1046 | }); | 1381 | target.client, |
| 1047 | await insertPlan( | 1382 | snapshot, |
| 1048 | target.client, | 1383 | plan, |
| 1049 | snapshot, | 1384 | secretValues.idOverrides, |
| 1050 | plan, | 1385 | secretValues.oauthClientIdValues, |
| 1051 | secretValues.idOverrides, | 1386 | oauthAuthorizationUrls, |
| 1052 | secretValues.oauthClientIdValues, | 1387 | options.tenantId, |
| 1053 | oauthAuthorizationUrls, | 1388 | secretValues, |
| 1054 | options.tenantId, | 1389 | ); |
| 1055 | ); | 1390 | await target.client.execute("PRAGMA wal_checkpoint(TRUNCATE)"); |
| 1056 | await target.close(); | 1391 | await target.close(); |
| 1057 | target = null; | 1392 | target = null; |
| 1058 | await writeMigratedSecrets(secretValues); | 1393 | |
| 1059 | return { | 1394 | await verifyStagingDatabase(stagingPath, expectedCountsFor(snapshot, plan)); |
| 1060 | migrated: true, | 1395 | writeMigrationJournal(journalPath, { ...journal, phase: "built" }); |
| 1061 | backupPath, | 1396 | flipStarted = true; |
| 1062 | report: plan.report, | 1397 | await completeJournaledFlip(journalPath, { ...journal, phase: "built" }); |
| 1063 | warnings: [...replayWarnings, ...plan.report.warnings, ...secretValues.warnings], | 1398 | |
| 1064 | }; | 1399 | normalizedSourcePath = null; |
| 1065 | } catch (cause) { | 1400 | stagingPath = null; |
| 1066 | if (target) await target.close(); | 1401 | return { |
| 1067 | removeSqliteFileSet(options.sqlitePath); | 1402 | migrated: true, |
| 1068 | if (fs.existsSync(backupPath)) await moveSqliteFileSet(backupPath, options.sqlitePath); | 1403 | backupPath, |
| 1069 | throw cause; | 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 | } finally { | 1416 | } finally { |
| 1072 | try { | 1417 | releaseMigrationLock(lock); |
| 1073 | reader.close(); | | |
| 1074 | } catch { | | |
| 1075 | // already closed after the snapshot was read | | |
| 1076 | } | | |
| 1077 | } | 1418 | } |
| 1078 | }; | 1419 | }; |