| @@ -2,17 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; · 1 unmodified line |
| 2 | import { drizzle } from "drizzle-orm/libsql"; | 2 | import { drizzle } from "drizzle-orm/libsql"; |
| 3 | import { migrate } from "drizzle-orm/libsql/migrator"; | 3 | import { migrate } from "drizzle-orm/libsql/migrator"; |
| 4 | import { Buffer } from "node:buffer"; | 4 | import { Buffer } from "node:buffer"; |
| | 5 | import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; |
| 5 | import { | 6 | import { |
| 6 | copyFileSync, | 7 | copyFileSync, |
| 7 | existsSync, | 8 | existsSync, |
| 8 | mkdirSync, | 9 | mkdirSync, |
| 9 | mkdtempSync, | 10 | mkdtempSync, |
| 10 | readFileSync, | 11 | readFileSync, |
| | 12 | readdirSync, |
| 11 | rmSync, | 13 | rmSync, |
| | 14 | statSync, |
| 12 | writeFileSync, | 15 | writeFileSync, |
| 13 | } from "node:fs"; | 16 | } from "node:fs"; |
| 14 | import { tmpdir } from "node:os"; | 17 | import { tmpdir } from "node:os"; |
| 15 | import { join } from "node:path"; | 18 | import { basename, dirname, join } from "node:path"; |
| 16 | import { Schema } from "effect"; | 19 | import { Schema } from "effect"; |
| 17 | | 20 | |
| 18 | import { collectTables } from "@executor-js/api/server"; | 21 | import { collectTables } from "@executor-js/api/server"; |
| @@ -23,6 +26,21 @@ import { migrateLocalV1ToV2IfNeeded } from "./v1-v2-migration"; · 4 unmodified lines |
| 23 | | 26 | |
| 24 | const AuthFile = Schema.Record(Schema.String, Schema.String); | 27 | const AuthFile = Schema.Record(Schema.String, Schema.String); |
| 25 | const decodeAuthFile = Schema.decodeUnknownSync(Schema.fromJsonString(AuthFile)); | 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 | const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | 44 | const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 27 | // Preserves every journal field (`when`, `breakpoints`, …) — drizzle's | 45 | // Preserves every journal field (`when`, `breakpoints`, …) — drizzle's |
| 28 | // migrator needs them, so only `entries` is typed for the truncation filter. | 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 | const copySqliteFileSetForTest = (source: string, target: string) => { | 840 | const copySqliteFileSetForTest = (source: string, target: string) => { |
| 823 | for (const suffix of ["", "-wal", "-shm"] as const) { | 841 | for (const suffix of ["", "-wal", "-shm"] as const) { |
| | 842 | rmSync(`${target}${suffix}`, { force: true }); |
| 824 | if (existsSync(`${source}${suffix}`)) copyFileSync(`${source}${suffix}`, `${target}${suffix}`); | 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 | describe("local v1 -> v2 migration", () => { | 1134 | describe("local v1 -> v2 migration", () => { |
| 829 | it("moves a scoped v1 DB to a v2 DB and re-keys file auth.json", async () => { | 1135 | it("moves a scoped v1 DB to a v2 DB and re-keys file auth.json", async () => { |
| 830 | const scopeId = "executor-workspace-abcd1234"; | 1136 | const scopeId = "executor-workspace-abcd1234"; |
| @@ -940,6 +1246,93 @@ describe("local v1 -> v2 migration", () => { · 109 unmodified lines |
| 940 | expect(auth[itemId]).toBe("sk_test_123"); | 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 | it("completes a built journal recovery before opening the canonical DB", async () => { | 1336 | it("completes a built journal recovery before opening the canonical DB", async () => { |
| 944 | const scopeId = "executor-workspace-abcd1234"; | 1337 | const scopeId = "executor-workspace-abcd1234"; |
| 945 | const dataDir = join(workDir, "data"); | 1338 | const dataDir = join(workDir, "data"); |
| @@ -969,6 +1362,9 @@ describe("local v1 -> v2 migration", () => { · 23 unmodified lines |
| 969 | normalizedSource: `${dbPath}.source-recovery`, | 1362 | normalizedSource: `${dbPath}.source-recovery`, |
| 970 | staging: stagingPath, | 1363 | staging: stagingPath, |
| 971 | backup: backupPath, | 1364 | backup: backupPath, |
| | 1365 | authPath: join(workDir, "unused-auth.json"), |
| | 1366 | authBackup: null, |
| | 1367 | authExisted: false, |
| 972 | nonce: "recovery", | 1368 | nonce: "recovery", |
| 973 | phase: "built", | 1369 | phase: "built", |
| 974 | }, | 1370 | }, |
| @@ -999,6 +1395,72 @@ describe("local v1 -> v2 migration", () => { · 24 unmodified lines |
| 999 | expect(legacySources.rows).toEqual([{ scope_id: scopeId, id: "stripe_api" }]); | 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 | it("preserves distinct local v1 scopes as distinct v2 tenants", async () => { | 1464 | it("preserves distinct local v1 scopes as distinct v2 tenants", async () => { |
| 1003 | const firstScopeId = "executor-workspace-abcd1234"; | 1465 | const firstScopeId = "executor-workspace-abcd1234"; |
| 1004 | const secondScopeId = "executor-fork-ef567890"; | 1466 | const secondScopeId = "executor-fork-ef567890"; |