| @@ -2,17 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; · 1 unmodified line |
| 2 | 2 | import { drizzle } from "drizzle-orm/libsql"; |
| 3 | 3 | import { migrate } from "drizzle-orm/libsql/migrator"; |
| 4 | 4 | import { Buffer } from "node:buffer"; |
| 5 | +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; |
| 5 | 6 | import { |
| 6 | 7 | copyFileSync, |
| 7 | 8 | existsSync, |
| 8 | 9 | mkdirSync, |
| 9 | 10 | mkdtempSync, |
| 10 | 11 | readFileSync, |
| 12 | + readdirSync, |
| 11 | 13 | rmSync, |
| 14 | + statSync, |
| 12 | 15 | writeFileSync, |
| 13 | 16 | } from "node:fs"; |
| 14 | 17 | import { tmpdir } from "node:os"; |
| 15 | | −import { join } from "node:path"; |
| 18 | +import { basename, dirname, join } from "node:path"; |
| 16 | 19 | import { Schema } from "effect"; |
| 17 | 20 | |
| 18 | 21 | import { collectTables } from "@executor-js/api/server"; |
| @@ -23,6 +26,21 @@ import { migrateLocalV1ToV2IfNeeded } from "./v1-v2-migration"; · 4 unmodified lines |
| 23 | 26 | |
| 24 | 27 | const AuthFile = Schema.Record(Schema.String, Schema.String); |
| 25 | 28 | const decodeAuthFile = Schema.decodeUnknownSync(Schema.fromJsonString(AuthFile)); |
| 29 | +const MigrationJournalForTest = Schema.Struct({ |
| 30 | + version: Schema.Literal(1), |
| 31 | + source: Schema.String, |
| 32 | + normalizedSource: Schema.String, |
| 33 | + staging: Schema.String, |
| 34 | + backup: Schema.String, |
| 35 | + authPath: Schema.String, |
| 36 | + authBackup: Schema.Union([Schema.String, Schema.Null]), |
| 37 | + authExisted: Schema.Boolean, |
| 38 | + nonce: Schema.String, |
| 39 | + phase: Schema.Literals(["building", "built", "canonical-moved", "committed"]), |
| 40 | +}); |
| 41 | +const decodeMigrationJournalForTest = Schema.decodeUnknownSync( |
| 42 | + Schema.fromJsonString(MigrationJournalForTest), |
| 43 | +); |
| 26 | 44 | const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 27 | 45 | // Preserves every journal field (`when`, `breakpoints`, …) — drizzle's |
| 28 | 46 | // migrator needs them, so only `entries` is typed for the truncation filter. |
| @@ -821,10 +839,298 @@ const seedDuplicateStripeScope = async (dbPath: string, scopeId: string) => { · 792 unmodified lines |
| 821 | 839 | |
| 822 | 840 | const copySqliteFileSetForTest = (source: string, target: string) => { |
| 823 | 841 | for (const suffix of ["", "-wal", "-shm"] as const) { |
| 842 | + rmSync(`${target}${suffix}`, { force: true }); |
| 824 | 843 | if (existsSync(`${source}${suffix}`)) copyFileSync(`${source}${suffix}`, `${target}${suffix}`); |
| 825 | 844 | } |
| 826 | 845 | }; |
| 827 | 846 | |
| 847 | +const waitForChildExit = (child: ChildProcessWithoutNullStreams): Promise<void> => |
| 848 | + new Promise((resolve) => { |
| 849 | + if (child.exitCode !== null || child.signalCode !== null) { |
| 850 | + resolve(); |
| 851 | + return; |
| 852 | + } |
| 853 | + child.once("exit", () => resolve()); |
| 854 | + }); |
| 855 | + |
| 856 | +const killRestartPausePhases = [ |
| 857 | + "building", |
| 858 | + "staging-built", |
| 859 | + "built", |
| 860 | + "canonical-moved", |
| 861 | + "staging-consumed", |
| 862 | +] as const; |
| 863 | + |
| 864 | +type KillRestartPausePhase = (typeof killRestartPausePhases)[number]; |
| 865 | + |
| 866 | +interface ChildOutput { |
| 867 | + stdout: string; |
| 868 | + stderr: string; |
| 869 | +} |
| 870 | + |
| 871 | +const waitForPauseMarker = async ( |
| 872 | + marker: string, |
| 873 | + child: ChildProcessWithoutNullStreams, |
| 874 | + output: ChildOutput, |
| 875 | +): Promise<{ |
| 876 | + readonly markerFound: boolean; |
| 877 | + readonly exitCode: number | null; |
| 878 | + readonly signalCode: NodeJS.Signals | null; |
| 879 | + readonly stdout: string; |
| 880 | + readonly stderr: string; |
| 881 | +}> => { |
| 882 | + for (let attempt = 0; attempt < 200; attempt++) { |
| 883 | + if (existsSync(marker)) { |
| 884 | + return { |
| 885 | + markerFound: true, |
| 886 | + exitCode: child.exitCode, |
| 887 | + signalCode: child.signalCode, |
| 888 | + stdout: output.stdout, |
| 889 | + stderr: output.stderr, |
| 890 | + }; |
| 891 | + } |
| 892 | + if (child.exitCode !== null || child.signalCode !== null) { |
| 893 | + return { |
| 894 | + markerFound: false, |
| 895 | + exitCode: child.exitCode, |
| 896 | + signalCode: child.signalCode, |
| 897 | + stdout: output.stdout, |
| 898 | + stderr: output.stderr, |
| 899 | + }; |
| 900 | + } |
| 901 | + await new Promise((resolve) => setTimeout(resolve, 25)); |
| 902 | + } |
| 903 | + return { |
| 904 | + markerFound: false, |
| 905 | + exitCode: child.exitCode, |
| 906 | + signalCode: child.signalCode, |
| 907 | + stdout: output.stdout, |
| 908 | + stderr: output.stderr, |
| 909 | + }; |
| 910 | +}; |
| 911 | + |
| 912 | +const killMigrationAtPause = async (input: { |
| 913 | + readonly dbPath: string; |
| 914 | + readonly tenantId: string; |
| 915 | + readonly pauseAt: KillRestartPausePhase; |
| 916 | +}): Promise<void> => { |
| 917 | + const marker = join(workDir, `pause-${input.pauseAt}`); |
| 918 | + const code = ` |
| 919 | + import { collectTables } from "@executor-js/api/server"; |
| 920 | + import { migrateLocalV1ToV2IfNeeded } from "./src/db/v1-v2-migration.ts"; |
| 921 | + await migrateLocalV1ToV2IfNeeded({ |
| 922 | + sqlitePath: ${JSON.stringify(input.dbPath)}, |
| 923 | + tables: collectTables(), |
| 924 | + namespace: "executor_local", |
| 925 | + tenantId: ${JSON.stringify(input.tenantId)}, |
| 926 | + }); |
| 927 | + `; |
| 928 | + const child = spawn(process.execPath, ["-e", code], { |
| 929 | + cwd: join(import.meta.dirname, "../.."), |
| 930 | + env: { |
| 931 | + ...process.env, |
| 932 | + NODE_ENV: "test", |
| 933 | + EXECUTOR_V1_V2_MIGRATION_PAUSE_AT: input.pauseAt, |
| 934 | + EXECUTOR_V1_V2_MIGRATION_PAUSE_FILE: marker, |
| 935 | + }, |
| 936 | + stdio: "pipe", |
| 937 | + }); |
| 938 | + child.unref(); |
| 939 | + const output: ChildOutput = { stdout: "", stderr: "" }; |
| 940 | + child.stdout.setEncoding("utf-8"); |
| 941 | + child.stderr.setEncoding("utf-8"); |
| 942 | + child.stdout.on("data", (chunk) => { |
| 943 | + output.stdout += chunk; |
| 944 | + }); |
| 945 | + child.stderr.on("data", (chunk) => { |
| 946 | + output.stderr += chunk; |
| 947 | + }); |
| 948 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: subprocess kill harness must clean up marker/child even when assertions fail |
| 949 | + try { |
| 950 | + const paused = await waitForPauseMarker(marker, child, output); |
| 951 | + if (!paused.markerFound) { |
| 952 | + child.kill("SIGKILL"); |
| 953 | + await waitForChildExit(child); |
| 954 | + } |
| 955 | + expect(paused).toMatchObject({ markerFound: true, exitCode: null, signalCode: null }); |
| 956 | + child.kill("SIGKILL"); |
| 957 | + await waitForChildExit(child); |
| 958 | + expect(child.signalCode).toBe("SIGKILL"); |
| 959 | + } finally { |
| 960 | + if (child.exitCode === null && child.signalCode === null) { |
| 961 | + child.kill("SIGKILL"); |
| 962 | + await waitForChildExit(child); |
| 963 | + } |
| 964 | + child.stdout.destroy(); |
| 965 | + child.stderr.destroy(); |
| 966 | + rmSync(marker, { force: true }); |
| 967 | + } |
| 968 | +}; |
| 969 | + |
| 970 | +const readMigrationJournalForTest = (dbPath: string) => { |
| 971 | + const journalPath = `${dbPath}.v1-v2-migration.json`; |
| 972 | + expect(existsSync(journalPath)).toBe(true); |
| 973 | + return decodeMigrationJournalForTest(readFileSync(journalPath, "utf-8")); |
| 974 | +}; |
| 975 | + |
| 976 | +const assertV1SourceRows = async (input: { readonly path: string; readonly scopeId: string }) => { |
| 977 | + expect(existsSync(input.path)).toBe(true); |
| 978 | + const client = await openLocalLibsql(input.path); |
| 979 | + const sources = await client.execute("SELECT scope_id, id FROM source"); |
| 980 | + client.close(); |
| 981 | + expect(sources.rows).toEqual([{ scope_id: input.scopeId, id: "stripe_api" }]); |
| 982 | +}; |
| 983 | + |
| 984 | +const assertV2IntegrationRows = async (input: { |
| 985 | + readonly path: string; |
| 986 | + readonly tenantId: string; |
| 987 | +}) => { |
| 988 | + expect(existsSync(input.path)).toBe(true); |
| 989 | + const client = await openLocalLibsql(input.path); |
| 990 | + const integrations = await client.execute("SELECT tenant, slug FROM integration"); |
| 991 | + client.close(); |
| 992 | + expect(integrations.rows).toEqual([{ tenant: input.tenantId, slug: "stripe_api" }]); |
| 993 | +}; |
| 994 | + |
| 995 | +const sqliteWalSize = (path: string): number => |
| 996 | + existsSync(`${path}-wal`) ? statSync(`${path}-wal`).size : 0; |
| 997 | + |
| 998 | +const assertSqliteWalEmptyOrAbsent = (path: string): void => { |
| 999 | + expect(sqliteWalSize(path)).toBe(0); |
| 1000 | +}; |
| 1001 | + |
| 1002 | +const writeLiveWalMarker = async (path: string, marker: string): Promise<void> => { |
| 1003 | + const client = await openLocalLibsql(path); |
| 1004 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test DB adapter boundary: close the SQLite client even when marker setup fails |
| 1005 | + try { |
| 1006 | + await client.execute("PRAGMA journal_mode=WAL"); |
| 1007 | + await client.execute("PRAGMA wal_autocheckpoint=0"); |
| 1008 | + await client.execute("CREATE TABLE IF NOT EXISTS wal_marker (id text PRIMARY KEY, value text)"); |
| 1009 | + await client.execute({ |
| 1010 | + sql: "INSERT INTO wal_marker (id, value) VALUES (?, ?)", |
| 1011 | + args: [marker, "x".repeat(4096)], |
| 1012 | + }); |
| 1013 | + } finally { |
| 1014 | + client.close(); |
| 1015 | + } |
| 1016 | + expect(sqliteWalSize(path)).toBeGreaterThan(0); |
| 1017 | +}; |
| 1018 | + |
| 1019 | +const assertWalMarkerRows = async (input: { readonly path: string; readonly marker: string }) => { |
| 1020 | + expect(existsSync(input.path)).toBe(true); |
| 1021 | + const client = await openLocalLibsql(input.path); |
| 1022 | + const markers = await client.execute("SELECT id FROM wal_marker"); |
| 1023 | + client.close(); |
| 1024 | + expect(markers.rows).toEqual([{ id: input.marker }]); |
| 1025 | +}; |
| 1026 | + |
| 1027 | +type MigrationJournalForTestValue = ReturnType<typeof readMigrationJournalForTest>; |
| 1028 | + |
| 1029 | +interface KilledMigrationAssertionInput { |
| 1030 | + readonly journal: MigrationJournalForTestValue; |
| 1031 | + readonly scopeId: string; |
| 1032 | + readonly walMarker: string; |
| 1033 | +} |
| 1034 | + |
| 1035 | +const killedMigrationStateAssertions = { |
| 1036 | + building: async ({ journal, scopeId, walMarker }: KilledMigrationAssertionInput) => { |
| 1037 | + expect(journal.phase).toBe("building"); |
| 1038 | + expect(existsSync(journal.normalizedSource)).toBe(false); |
| 1039 | + expect(existsSync(journal.staging)).toBe(false); |
| 1040 | + await assertV1SourceRows({ path: journal.source, scopeId }); |
| 1041 | + await assertWalMarkerRows({ path: journal.source, marker: walMarker }); |
| 1042 | + }, |
| 1043 | + "staging-built": async ({ journal, scopeId, walMarker }: KilledMigrationAssertionInput) => { |
| 1044 | + expect(journal.phase).toBe("building"); |
| 1045 | + await assertV1SourceRows({ path: journal.source, scopeId }); |
| 1046 | + await assertWalMarkerRows({ path: journal.source, marker: walMarker }); |
| 1047 | + await assertV2IntegrationRows({ path: journal.staging, tenantId: scopeId }); |
| 1048 | + }, |
| 1049 | + built: async ({ journal, scopeId, walMarker }: KilledMigrationAssertionInput) => { |
| 1050 | + expect(journal.phase).toBe("built"); |
| 1051 | + await assertV1SourceRows({ path: journal.source, scopeId }); |
| 1052 | + await assertWalMarkerRows({ path: journal.source, marker: walMarker }); |
| 1053 | + await assertV2IntegrationRows({ path: journal.staging, tenantId: scopeId }); |
| 1054 | + }, |
| 1055 | + "canonical-moved": async ({ journal, scopeId, walMarker }: KilledMigrationAssertionInput) => { |
| 1056 | + expect(journal.phase).toBe("canonical-moved"); |
| 1057 | + expect(existsSync(journal.source)).toBe(false); |
| 1058 | + assertSqliteWalEmptyOrAbsent(journal.backup); |
| 1059 | + await assertV1SourceRows({ path: journal.backup, scopeId }); |
| 1060 | + await assertWalMarkerRows({ path: journal.backup, marker: walMarker }); |
| 1061 | + await assertV2IntegrationRows({ path: journal.staging, tenantId: scopeId }); |
| 1062 | + }, |
| 1063 | + "staging-consumed": async ({ journal, scopeId, walMarker }: KilledMigrationAssertionInput) => { |
| 1064 | + expect(journal.phase).toBe("canonical-moved"); |
| 1065 | + expect(existsSync(journal.staging)).toBe(false); |
| 1066 | + assertSqliteWalEmptyOrAbsent(journal.backup); |
| 1067 | + await assertV1SourceRows({ path: journal.backup, scopeId }); |
| 1068 | + await assertWalMarkerRows({ path: journal.backup, marker: walMarker }); |
| 1069 | + await assertV2IntegrationRows({ path: journal.source, tenantId: scopeId }); |
| 1070 | + }, |
| 1071 | +} satisfies Record<KillRestartPausePhase, (input: KilledMigrationAssertionInput) => Promise<void>>; |
| 1072 | + |
| 1073 | +const assertKilledMigrationState = async (input: { |
| 1074 | + readonly dbPath: string; |
| 1075 | + readonly scopeId: string; |
| 1076 | + readonly pauseAt: KillRestartPausePhase; |
| 1077 | + readonly walMarker: string; |
| 1078 | +}) => { |
| 1079 | + const journal = readMigrationJournalForTest(input.dbPath); |
| 1080 | + const authBackup = journal.authBackup ?? ""; |
| 1081 | + expect(journal.source).toBe(input.dbPath); |
| 1082 | + expect(journal.authExisted).toBe(true); |
| 1083 | + expect(authBackup).not.toBe(""); |
| 1084 | + expect(existsSync(authBackup)).toBe(true); |
| 1085 | + expect(existsSync(`${input.dbPath}.v1-v2.lock`)).toBe(true); |
| 1086 | + await killedMigrationStateAssertions[input.pauseAt]({ |
| 1087 | + journal, |
| 1088 | + scopeId: input.scopeId, |
| 1089 | + walMarker: input.walMarker, |
| 1090 | + }); |
| 1091 | +}; |
| 1092 | + |
| 1093 | +const sqliteBackupPathsForTest = (dbPath: string): readonly string[] => { |
| 1094 | + const prefix = `${basename(dbPath)}.v1-v2-`; |
| 1095 | + return readdirSync(dirname(dbPath)) |
| 1096 | + .filter( |
| 1097 | + (entry) => |
| 1098 | + entry.startsWith(prefix) && |
| 1099 | + !entry.endsWith(".json") && |
| 1100 | + !entry.endsWith(".lock") && |
| 1101 | + !entry.endsWith("-wal") && |
| 1102 | + !entry.endsWith("-shm"), |
| 1103 | + ) |
| 1104 | + .map((entry) => join(dirname(dbPath), entry)); |
| 1105 | +}; |
| 1106 | + |
| 1107 | +const assertMigratedStripeDbAndSecret = async (input: { |
| 1108 | + readonly dbPath: string; |
| 1109 | + readonly scopeId: string; |
| 1110 | + readonly secret: string; |
| 1111 | + readonly walMarker: string; |
| 1112 | +}) => { |
| 1113 | + const migrated = await openLocalLibsql(input.dbPath); |
| 1114 | + const integrations = await migrated.execute("SELECT tenant, slug FROM integration"); |
| 1115 | + migrated.close(); |
| 1116 | + expect(integrations.rows).toEqual([{ tenant: input.scopeId, slug: "stripe_api" }]); |
| 1117 | + |
| 1118 | + const backupPaths = sqliteBackupPathsForTest(input.dbPath); |
| 1119 | + expect(backupPaths.length).toBe(1); |
| 1120 | + assertSqliteWalEmptyOrAbsent(backupPaths[0]!); |
| 1121 | + const backup = await openLocalLibsql(backupPaths[0]!); |
| 1122 | + const legacySources = await backup.execute("SELECT scope_id, id FROM source"); |
| 1123 | + const walMarkers = await backup.execute("SELECT id FROM wal_marker"); |
| 1124 | + backup.close(); |
| 1125 | + expect(legacySources.rows).toEqual([{ scope_id: input.scopeId, id: "stripe_api" }]); |
| 1126 | + expect(walMarkers.rows).toEqual([{ id: input.walMarker }]); |
| 1127 | + |
| 1128 | + const auth = decodeAuthFile( |
| 1129 | + readFileSync(join(process.env.XDG_DATA_HOME!, "executor", "auth.json"), "utf-8"), |
| 1130 | + ); |
| 1131 | + expect(auth[migratedItemId(input.scopeId, "stripe-key")]).toBe(input.secret); |
| 1132 | +}; |
| 1133 | + |
| 828 | 1134 | describe("local v1 -> v2 migration", () => { |
| 829 | 1135 | it("moves a scoped v1 DB to a v2 DB and re-keys file auth.json", async () => { |
| 830 | 1136 | const scopeId = "executor-workspace-abcd1234"; |
| @@ -940,6 +1246,93 @@ describe("local v1 -> v2 migration", () => { · 109 unmodified lines |
| 940 | 1246 | expect(auth[itemId]).toBe("sk_test_123"); |
| 941 | 1247 | }); |
| 942 | 1248 | |
| 1249 | + it("restores auth.json from the journal when recovering an incomplete build", async () => { |
| 1250 | + const scopeId = "executor-workspace-abcd1234"; |
| 1251 | + const dataDir = join(workDir, "data"); |
| 1252 | + const dbPath = join(dataDir, "data.db"); |
| 1253 | + const authDir = join(process.env.XDG_DATA_HOME!, "executor"); |
| 1254 | + const authPath = join(authDir, "auth.json"); |
| 1255 | + const authBackup = `${authPath}.v1-v2-recovery`; |
| 1256 | + const journalPath = `${dbPath}.v1-v2-migration.json`; |
| 1257 | + mkdirSync(dataDir, { recursive: true }); |
| 1258 | + mkdirSync(authDir, { recursive: true }); |
| 1259 | + |
| 1260 | + await seedV1Db(dbPath, scopeId); |
| 1261 | + writeFileSync( |
| 1262 | + authPath, |
| 1263 | + JSON.stringify({ [scopeId]: { "stripe-key": "sk_test_recovered" } }, null, 2), |
| 1264 | + ); |
| 1265 | + copyFileSync(authPath, authBackup); |
| 1266 | + writeFileSync(authPath, "{}\n"); |
| 1267 | + |
| 1268 | + writeFileSync( |
| 1269 | + journalPath, |
| 1270 | + `${JSON.stringify( |
| 1271 | + { |
| 1272 | + version: 1, |
| 1273 | + source: dbPath, |
| 1274 | + normalizedSource: `${dbPath}.source-auth-recovery`, |
| 1275 | + staging: `${dbPath}.building-auth-recovery`, |
| 1276 | + backup: `${dbPath}.v1-v2-auth-recovery`, |
| 1277 | + authPath, |
| 1278 | + authBackup, |
| 1279 | + authExisted: true, |
| 1280 | + nonce: "auth-recovery", |
| 1281 | + phase: "building", |
| 1282 | + }, |
| 1283 | + null, |
| 1284 | + 2, |
| 1285 | + )}\n`, |
| 1286 | + ); |
| 1287 | + |
| 1288 | + const result = await migrateLocalV1ToV2IfNeeded({ |
| 1289 | + sqlitePath: dbPath, |
| 1290 | + tables: collectTables(), |
| 1291 | + namespace: "executor_local", |
| 1292 | + tenantId: scopeId, |
| 1293 | + }); |
| 1294 | + |
| 1295 | + expect(result.migrated).toBe(true); |
| 1296 | + expect(existsSync(journalPath)).toBe(false); |
| 1297 | + expect(existsSync(authBackup)).toBe(false); |
| 1298 | + |
| 1299 | + const auth = decodeAuthFile(readFileSync(authPath, "utf-8")); |
| 1300 | + expect(auth[migratedItemId(scopeId, "stripe-key")]).toBe("sk_test_recovered"); |
| 1301 | + }); |
| 1302 | + |
| 1303 | + it("recovers after SIGKILL at every migration journal boundary", async () => { |
| 1304 | + // Each iteration rewrites the process-global XDG auth.json; keep this sweep |
| 1305 | + // sequential so phase secrets cannot bleed across concurrent migrations. |
| 1306 | + for (const phase of killRestartPausePhases) { |
| 1307 | + const scopeId = `executor-${phase}`; |
| 1308 | + const secret = `sk_test_${phase}`; |
| 1309 | + const walMarker = `wal-${phase}`; |
| 1310 | + const dbPath = join(workDir, "kill-restart", `${phase}.db`); |
| 1311 | + mkdirSync(dirname(dbPath), { recursive: true }); |
| 1312 | + await seedV1Db(dbPath, scopeId); |
| 1313 | + await writeLiveWalMarker(dbPath, walMarker); |
| 1314 | + |
| 1315 | + const authDir = join(process.env.XDG_DATA_HOME!, "executor"); |
| 1316 | + mkdirSync(authDir, { recursive: true }); |
| 1317 | + writeFileSync( |
| 1318 | + join(authDir, "auth.json"), |
| 1319 | + JSON.stringify({ [scopeId]: { "stripe-key": secret } }, null, 2), |
| 1320 | + ); |
| 1321 | + |
| 1322 | + await killMigrationAtPause({ dbPath, tenantId: scopeId, pauseAt: phase }); |
| 1323 | + await assertKilledMigrationState({ dbPath, scopeId, pauseAt: phase, walMarker }); |
| 1324 | + await migrateLocalV1ToV2IfNeeded({ |
| 1325 | + sqlitePath: dbPath, |
| 1326 | + tables: collectTables(), |
| 1327 | + namespace: "executor_local", |
| 1328 | + tenantId: scopeId, |
| 1329 | + }); |
| 1330 | + await assertMigratedStripeDbAndSecret({ dbPath, scopeId, secret, walMarker }); |
| 1331 | + expect(existsSync(`${dbPath}.v1-v2-migration.json`)).toBe(false); |
| 1332 | + expect(existsSync(`${dbPath}.v1-v2.lock`)).toBe(false); |
| 1333 | + } |
| 1334 | + }); |
| 1335 | + |
| 943 | 1336 | it("completes a built journal recovery before opening the canonical DB", async () => { |
| 944 | 1337 | const scopeId = "executor-workspace-abcd1234"; |
| 945 | 1338 | const dataDir = join(workDir, "data"); |
| @@ -969,6 +1362,9 @@ describe("local v1 -> v2 migration", () => { · 23 unmodified lines |
| 969 | 1362 | normalizedSource: `${dbPath}.source-recovery`, |
| 970 | 1363 | staging: stagingPath, |
| 971 | 1364 | backup: backupPath, |
| 1365 | + authPath: join(workDir, "unused-auth.json"), |
| 1366 | + authBackup: null, |
| 1367 | + authExisted: false, |
| 972 | 1368 | nonce: "recovery", |
| 973 | 1369 | phase: "built", |
| 974 | 1370 | }, |
| @@ -999,6 +1395,72 @@ describe("local v1 -> v2 migration", () => { · 24 unmodified lines |
| 999 | 1395 | expect(legacySources.rows).toEqual([{ scope_id: scopeId, id: "stripe_api" }]); |
| 1000 | 1396 | }); |
| 1001 | 1397 | |
| 1398 | + it("does not delete installed v2 when recovering after staging was consumed", async () => { |
| 1399 | + const scopeId = "executor-workspace-abcd1234"; |
| 1400 | + const dataDir = join(workDir, "data"); |
| 1401 | + const dbPath = join(dataDir, "data.db"); |
| 1402 | + const stagingSourcePath = join(workDir, "staging-source-consumed.db"); |
| 1403 | + const stagingPath = `${dbPath}.building-consumed`; |
| 1404 | + const backupPath = `${dbPath}.v1-v2-consumed`; |
| 1405 | + const journalPath = `${dbPath}.v1-v2-migration.json`; |
| 1406 | + mkdirSync(dataDir, { recursive: true }); |
| 1407 | + |
| 1408 | + await seedV1Db(dbPath, scopeId); |
| 1409 | + copySqliteFileSetForTest(dbPath, backupPath); |
| 1410 | + |
| 1411 | + await seedV1Db(stagingSourcePath, scopeId); |
| 1412 | + await migrateLocalV1ToV2IfNeeded({ |
| 1413 | + sqlitePath: stagingSourcePath, |
| 1414 | + tables: collectTables(), |
| 1415 | + namespace: "executor_local", |
| 1416 | + tenantId: scopeId, |
| 1417 | + }); |
| 1418 | + copySqliteFileSetForTest(stagingSourcePath, dbPath); |
| 1419 | + rmSync(stagingPath, { force: true }); |
| 1420 | + |
| 1421 | + writeFileSync( |
| 1422 | + journalPath, |
| 1423 | + `${JSON.stringify( |
| 1424 | + { |
| 1425 | + version: 1, |
| 1426 | + source: dbPath, |
| 1427 | + normalizedSource: `${dbPath}.source-consumed`, |
| 1428 | + staging: stagingPath, |
| 1429 | + backup: backupPath, |
| 1430 | + authPath: join(workDir, "unused-auth-consumed.json"), |
| 1431 | + authBackup: null, |
| 1432 | + authExisted: false, |
| 1433 | + nonce: "consumed", |
| 1434 | + phase: "canonical-moved", |
| 1435 | + }, |
| 1436 | + null, |
| 1437 | + 2, |
| 1438 | + )}\n`, |
| 1439 | + ); |
| 1440 | + |
| 1441 | + const result = await migrateLocalV1ToV2IfNeeded({ |
| 1442 | + sqlitePath: dbPath, |
| 1443 | + tables: collectTables(), |
| 1444 | + namespace: "executor_local", |
| 1445 | + tenantId: scopeId, |
| 1446 | + }); |
| 1447 | + |
| 1448 | + expect(result.migrated).toBe(false); |
| 1449 | + expect(existsSync(journalPath)).toBe(false); |
| 1450 | + expect(existsSync(dbPath)).toBe(true); |
| 1451 | + expect(existsSync(backupPath)).toBe(true); |
| 1452 | + |
| 1453 | + const migrated = await openLocalLibsql(dbPath); |
| 1454 | + const integrations = await migrated.execute("SELECT tenant, slug FROM integration"); |
| 1455 | + migrated.close(); |
| 1456 | + expect(integrations.rows).toEqual([{ tenant: scopeId, slug: "stripe_api" }]); |
| 1457 | + |
| 1458 | + const backup = await openLocalLibsql(backupPath); |
| 1459 | + const legacySources = await backup.execute("SELECT scope_id, id FROM source"); |
| 1460 | + backup.close(); |
| 1461 | + expect(legacySources.rows).toEqual([{ scope_id: scopeId, id: "stripe_api" }]); |
| 1462 | + }); |
| 1463 | + |
| 1002 | 1464 | it("preserves distinct local v1 scopes as distinct v2 tenants", async () => { |
| 1003 | 1465 | const firstScopeId = "executor-workspace-abcd1234"; |
| 1004 | 1466 | const secondScopeId = "executor-fork-ef567890"; |