History

Fix various correctness issues

Griffin Evans committed 5 months ago

Commit 25e37f21 parent 19af3772 files · +477 −2Browse files

Unified · Split

2 files
  1. apps/local/src/db/v1-v2-migration.test.ts +463 −1
  2. apps/local/src/db/v1-v2-migration.ts +14 −1
apps/local/src/db/v1-v2-migration.test.ts+463−1
@@ -2,17 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; · 1 unmodified line
22 import { drizzle } from "drizzle-orm/libsql";
33 import { migrate } from "drizzle-orm/libsql/migrator";
44 import { Buffer } from "node:buffer";
5+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
56 import {
67 copyFileSync,
78 existsSync,
89 mkdirSync,
910 mkdtempSync,
1011 readFileSync,
12+ readdirSync,
1113 rmSync,
14+ statSync,
1215 writeFileSync,
1316 } from "node:fs";
1417 import { tmpdir } from "node:os";
15−import { join } from "node:path";
18+import { basename, dirname, join } from "node:path";
1619 import { Schema } from "effect";
1720
1821 import { collectTables } from "@executor-js/api/server";
@@ -23,6 +26,21 @@ import { migrateLocalV1ToV2IfNeeded } from "./v1-v2-migration"; · 4 unmodified lines
2326
2427 const AuthFile = Schema.Record(Schema.String, Schema.String);
2528 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+);
2644 const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
2745 // Preserves every journal field (`when`, `breakpoints`, …) — drizzle's
2846 // 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
821839
822840 const copySqliteFileSetForTest = (source: string, target: string) => {
823841 for (const suffix of ["", "-wal", "-shm"] as const) {
842+ rmSync(`${target}${suffix}`, { force: true });
824843 if (existsSync(`${source}${suffix}`)) copyFileSync(`${source}${suffix}`, `${target}${suffix}`);
825844 }
826845 };
827846
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+
8281134 describe("local v1 -> v2 migration", () => {
8291135 it("moves a scoped v1 DB to a v2 DB and re-keys file auth.json", async () => {
8301136 const scopeId = "executor-workspace-abcd1234";
@@ -940,6 +1246,93 @@ describe("local v1 -> v2 migration", () => { · 109 unmodified lines
9401246 expect(auth[itemId]).toBe("sk_test_123");
9411247 });
9421248
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+
9431336 it("completes a built journal recovery before opening the canonical DB", async () => {
9441337 const scopeId = "executor-workspace-abcd1234";
9451338 const dataDir = join(workDir, "data");
@@ -969,6 +1362,9 @@ describe("local v1 -> v2 migration", () => { · 23 unmodified lines
9691362 normalizedSource: `${dbPath}.source-recovery`,
9701363 staging: stagingPath,
9711364 backup: backupPath,
1365+ authPath: join(workDir, "unused-auth.json"),
1366+ authBackup: null,
1367+ authExisted: false,
9721368 nonce: "recovery",
9731369 phase: "built",
9741370 },
@@ -999,6 +1395,72 @@ describe("local v1 -> v2 migration", () => { · 24 unmodified lines
9991395 expect(legacySources.rows).toEqual([{ scope_id: scopeId, id: "stripe_api" }]);
10001396 });
10011397
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+
10021464 it("preserves distinct local v1 scopes as distinct v2 tenants", async () => {
10031465 const firstScopeId = "executor-workspace-abcd1234";
10041466 const secondScopeId = "executor-fork-ef567890";
apps/local/src/db/v1-v2-migration.ts+14−1
@@ -1169,6 +1169,7 @@ const cleanupAuthBackup = (journal: MigrationJournal): void => { · 1168 unmodified lines
11691169 };
11701170
11711171 const pauseMigrationForTest = async (point: string): Promise<void> => {
1172+ if (process.env.NODE_ENV !== "test") return;
11721173 if (process.env.EXECUTOR_V1_V2_MIGRATION_PAUSE_AT !== point) return;
11731174 const marker = process.env.EXECUTOR_V1_V2_MIGRATION_PAUSE_FILE;
11741175 if (!marker) return;
@@ -1217,12 +1218,23 @@ const checkpointTruncate = async (client: Client, path: string): Promise<void> = · 42 unmodified lines
12171218 const checkpointed = Number(row["checkpointed"] ?? 0);
12181219 if (busy !== 0 || log !== checkpointed) {
12191220 throw new LocalV1V2MigrationError({
1220− message: `Could not fully checkpoint staged SQLite WAL before installing ${path}`,
1221+ message: `Could not fully checkpoint SQLite WAL for ${path}`,
12211222 cause: row,
12221223 });
12231224 }
12241225 };
12251226
1227+const checkpointSqliteFileSetIfBaseExists = async (path: string): Promise<void> => {
1228+ if (!fs.existsSync(path)) return;
1229+ const client = await openLocalLibsql(path);
1230+ try {
1231+ await checkpointTruncate(client, path);
1232+ } finally {
1233+ client.close();
1234+ }
1235+ fsyncSqliteFileSet(path);
1236+};
1237+
12261238 const verifyStagingDatabase = async (
12271239 path: string,
12281240 expected: MigrationExpectedCounts,
@@ -1276,6 +1288,7 @@ const completeJournaledFlip = async ( · 47 unmodified lines
12761288 // staging. Once the staging base is gone, the install rename already happened
12771289 // and `source` may hold the v2 DB; recovery must never move/delete it again.
12781290 if (fs.existsSync(journal.staging)) {
1291+ await checkpointSqliteFileSetIfBaseExists(journal.source);
12791292 await writeMigrationJournal(journalPath, { ...journal, phase: "canonical-moved" });
12801293 await moveExistingSqliteFileSet(journal.source, journal.backup);
12811294 await pauseMigrationForTest("canonical-moved");

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t