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
2import { drizzle } from "drizzle-orm/libsql";2import { drizzle } from "drizzle-orm/libsql";
3import { migrate } from "drizzle-orm/libsql/migrator";3import { migrate } from "drizzle-orm/libsql/migrator";
4import { Buffer } from "node:buffer";4import { Buffer } from "node:buffer";
5import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5import {6import {
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";
14import { tmpdir } from "node:os";17import { tmpdir } from "node:os";
15import { join } from "node:path";18import { basename, dirname, join } from "node:path";
16import { Schema } from "effect";19import { Schema } from "effect";
17 20
18import { collectTables } from "@executor-js/api/server";21import { collectTables } from "@executor-js/api/server";
@@ -23,6 +26,21 @@ import { migrateLocalV1ToV2IfNeeded } from "./v1-v2-migration"; · 4 unmodified lines
23 26
24const AuthFile = Schema.Record(Schema.String, Schema.String);27const AuthFile = Schema.Record(Schema.String, Schema.String);
25const decodeAuthFile = Schema.decodeUnknownSync(Schema.fromJsonString(AuthFile));28const decodeAuthFile = Schema.decodeUnknownSync(Schema.fromJsonString(AuthFile));
29const 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});
41const decodeMigrationJournalForTest = Schema.decodeUnknownSync(
42 Schema.fromJsonString(MigrationJournalForTest),
43);
26const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));44const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
27// Preserves every journal field (`when`, `breakpoints`, …) — drizzle's45// 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
822const copySqliteFileSetForTest = (source: string, target: string) => {840const 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
847const 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
856const killRestartPausePhases = [
857 "building",
858 "staging-built",
859 "built",
860 "canonical-moved",
861 "staging-consumed",
862] as const;
863
864type KillRestartPausePhase = (typeof killRestartPausePhases)[number];
865
866interface ChildOutput {
867 stdout: string;
868 stderr: string;
869}
870
871const 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
912const 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
970const 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
976const 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
984const 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
995const sqliteWalSize = (path: string): number =>
996 existsSync(`${path}-wal`) ? statSync(`${path}-wal`).size : 0;
997
998const assertSqliteWalEmptyOrAbsent = (path: string): void => {
999 expect(sqliteWalSize(path)).toBe(0);
1000};
1001
1002const 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
1019const 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
1027type MigrationJournalForTestValue = ReturnType<typeof readMigrationJournalForTest>;
1028
1029interface KilledMigrationAssertionInput {
1030 readonly journal: MigrationJournalForTestValue;
1031 readonly scopeId: string;
1032 readonly walMarker: string;
1033}
1034
1035const 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
1073const 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
1093const 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
1107const 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
828describe("local v1 -> v2 migration", () => {1134describe("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";
apps/local/src/db/v1-v2-migration.ts+14−1
@@ -1169,6 +1169,7 @@ const cleanupAuthBackup = (journal: MigrationJournal): void => { · 1168 unmodified lines
1169};1169};
1170 1170
1171const pauseMigrationForTest = async (point: string): Promise<void> => {1171const pauseMigrationForTest = async (point: string): Promise<void> => {
1172 if (process.env.NODE_ENV !== "test") return;
1172 if (process.env.EXECUTOR_V1_V2_MIGRATION_PAUSE_AT !== point) return;1173 if (process.env.EXECUTOR_V1_V2_MIGRATION_PAUSE_AT !== point) return;
1173 const marker = process.env.EXECUTOR_V1_V2_MIGRATION_PAUSE_FILE;1174 const marker = process.env.EXECUTOR_V1_V2_MIGRATION_PAUSE_FILE;
1174 if (!marker) return;1175 if (!marker) return;
@@ -1217,12 +1218,23 @@ const checkpointTruncate = async (client: Client, path: string): Promise<void> = · 42 unmodified lines
1217 const checkpointed = Number(row["checkpointed"] ?? 0);1218 const checkpointed = Number(row["checkpointed"] ?? 0);
1218 if (busy !== 0 || log !== checkpointed) {1219 if (busy !== 0 || log !== checkpointed) {
1219 throw new LocalV1V2MigrationError({1220 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}`,
1221 cause: row,1222 cause: row,
1222 });1223 });
1223 }1224 }
1224};1225};
1225 1226
1227const 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
1226const verifyStagingDatabase = async (1238const verifyStagingDatabase = async (
1227 path: string,1239 path: string,
1228 expected: MigrationExpectedCounts,1240 expected: MigrationExpectedCounts,
@@ -1276,6 +1288,7 @@ const completeJournaledFlip = async ( · 47 unmodified lines
1276 // staging. Once the staging base is gone, the install rename already happened1288 // staging. Once the staging base is gone, the install rename already happened
1277 // and `source` may hold the v2 DB; recovery must never move/delete it again.1289 // and `source` may hold the v2 DB; recovery must never move/delete it again.
1278 if (fs.existsSync(journal.staging)) {1290 if (fs.existsSync(journal.staging)) {
1291 await checkpointSqliteFileSetIfBaseExists(journal.source);
1279 await writeMigrationJournal(journalPath, { ...journal, phase: "canonical-moved" });1292 await writeMigrationJournal(journalPath, { ...journal, phase: "canonical-moved" });
1280 await moveExistingSqliteFileSet(journal.source, journal.backup);1293 await moveExistingSqliteFileSet(journal.source, journal.backup);
1281 await pauseMigrationForTest("canonical-moved");1294 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