History

Safer migration

Griffin Evans committed 5 months ago

Commit 187ac181 parent f9cdb844 files · +707 −59Browse files

Unified · Split

4 files
  1. apps/local/src/db/data-migrations.ts +3 −4
  2. apps/local/src/db/v1-v2-ledger.test.ts +1 −1
  3. apps/local/src/db/v1-v2-migration.test.ts +309 −1
  4. apps/local/src/db/v1-v2-migration.ts +394 −53
apps/local/src/db/data-migrations.ts+3−4
@@ -19,10 +19,9 @@ import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration"; · 18 unmodified lines
1919
2020 export const localDataMigrations: readonly SqliteDataMigration[] = [
2121 // The v1→v2 gate itself runs BEFORE the executor (and this registry) can
22− // exist — see migrateLocalV1ToV2IfNeeded in executor.ts. This entry only
23− // stamps the outcome: by the time the registry runs, the database is v2
24− // (it was either migrated or inspected and found already-v2), and the
25− // stamp short-circuits every future boot's schema probing.
22+ // exist — see migrateLocalV1ToV2IfNeeded in executor.ts. Migrated v1 DBs are
23+ // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get
24+ // the same stamp here so future boots skip legacy shape probing.
2625 { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void },
2726 // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)
2827 // into the shared placements model.
apps/local/src/db/v1-v2-ledger.test.ts+1−1
@@ -245,7 +245,7 @@ describe("local v1→v2 gate × data-migration ledger", () => { · 244 unmodified lines
245245
246246 const boot1 = await bootOnce(dbPath);
247247 expect(boot1.migration.migrated).toBe(true);
248− expect(boot1.applied).toContain(LOCAL_V1_V2_LEDGER_NAME);
248+ expect(boot1.ledger).toContain(LOCAL_V1_V2_LEDGER_NAME);
249249 await expectV2WithContext7(dbPath);
250250
251251 const boot2 = await bootOnce(dbPath);
apps/local/src/db/v1-v2-migration.test.ts+309−1
@@ -2,7 +2,15 @@ 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 { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5+import {
6+ copyFileSync,
7+ existsSync,
8+ mkdirSync,
9+ mkdtempSync,
10+ readFileSync,
11+ rmSync,
12+ writeFileSync,
13+} from "node:fs";
614 import { tmpdir } from "node:os";
715 import { join } from "node:path";
816 import { Schema } from "effect";
@@ -668,6 +676,155 @@ const seedV1Db = async ( · 659 unmodified lines
668676 client.close();
669677 };
670678
679+const seedDuplicateStripeScope = async (dbPath: string, scopeId: string) => {
680+ const client = await openLocalLibsql(dbPath);
681+ const now = Date.now();
682+ const json = (value: unknown): string => JSON.stringify(value);
683+
684+ await executeSql(
685+ client,
686+ "INSERT INTO source (id, scope_id, plugin_id, kind, name) VALUES (?, ?, ?, ?, ?)",
687+ ["stripe_api", scopeId, "openapi", "openapi", "Stripe"],
688+ );
689+ await executeSql(
690+ client,
691+ "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
692+ [
693+ "openapi-source-stripe",
694+ scopeId,
695+ "openapi",
696+ "source",
697+ "stripe_api",
698+ json({
699+ config: {
700+ spec: "{}",
701+ headers: {
702+ Authorization: {
703+ kind: "binding",
704+ slot: "header:authorization",
705+ prefix: "Bearer ",
706+ },
707+ },
708+ },
709+ }),
710+ now,
711+ now,
712+ ],
713+ );
714+ await executeSql(
715+ client,
716+ "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
717+ [
718+ "provider-settings",
719+ scopeId,
720+ "onepassword",
721+ "settings",
722+ "config",
723+ json({ vaultId: "vault-456" }),
724+ now,
725+ now,
726+ ],
727+ );
728+ await executeSql(
729+ client,
730+ "INSERT INTO credential_binding (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
731+ [
732+ "stripe-auth",
733+ scopeId,
734+ "openapi",
735+ "stripe_api",
736+ scopeId,
737+ "header:authorization",
738+ "secret",
739+ null,
740+ "stripe-key",
741+ null,
742+ now,
743+ now,
744+ ],
745+ );
746+ await executeSql(
747+ client,
748+ "INSERT INTO secret (id, scope_id, name, provider, owned_by_connection_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
749+ ["stripe-key", scopeId, "Stripe key", "file", null, now],
750+ );
751+ await executeSql(
752+ client,
753+ "INSERT INTO tool_policy (id, scope_id, pattern, action, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
754+ ["policy-1", scopeId, "stripe_api.charges.create", "approve", "a0", now, now],
755+ );
756+ await executeSql(
757+ client,
758+ "INSERT INTO tool (id, scope_id, source_id, plugin_id, name, description, input_schema, output_schema, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
759+ [
760+ "stripe_api.charges.create",
761+ scopeId,
762+ "stripe_api",
763+ "openapi",
764+ "charges.create",
765+ "Create a charge",
766+ json({ type: "object" }),
767+ json({ type: "object" }),
768+ now,
769+ now,
770+ ],
771+ );
772+ await executeSql(
773+ client,
774+ "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
775+ [
776+ "openapi-operation-stripe-charge",
777+ scopeId,
778+ "openapi",
779+ "operation",
780+ "stripe_api.charges.create",
781+ json({
782+ toolId: "stripe_api.charges.create",
783+ sourceId: "stripe_api",
784+ binding: {
785+ method: "post",
786+ pathTemplate: "/v1/charges",
787+ parameters: [],
788+ },
789+ }),
790+ now,
791+ now,
792+ ],
793+ );
794+ await executeSql(
795+ client,
796+ "INSERT INTO definition (id, scope_id, source_id, plugin_id, name, schema, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
797+ [
798+ "stripe_api.Charge",
799+ scopeId,
800+ "stripe_api",
801+ "openapi",
802+ "Charge",
803+ json({ type: "object", properties: { id: { type: "string" } } }),
804+ now,
805+ ],
806+ );
807+ await executeSql(
808+ client,
809+ "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)",
810+ [
811+ `${scopeId}/onepassword`,
812+ "config",
813+ JSON.stringify({ vaultId: "vault-456" }),
814+ "blob-row-duplicate-scope",
815+ JSON.stringify([`${scopeId}/onepassword`, "config"]),
816+ ],
817+ );
818+
819+ client.close();
820+};
821+
822+const copySqliteFileSetForTest = (source: string, target: string) => {
823+ for (const suffix of ["", "-wal", "-shm"] as const) {
824+ if (existsSync(`${source}${suffix}`)) copyFileSync(`${source}${suffix}`, `${target}${suffix}`);
825+ }
826+};
827+
671828 describe("local v1 -> v2 migration", () => {
672829 it("moves a scoped v1 DB to a v2 DB and re-keys file auth.json", async () => {
673830 const scopeId = "executor-workspace-abcd1234";
@@ -783,6 +940,157 @@ describe("local v1 -> v2 migration", () => { · 109 unmodified lines
783940 expect(auth[itemId]).toBe("sk_test_123");
784941 });
785942
943+ it("completes a built journal recovery before opening the canonical DB", async () => {
944+ const scopeId = "executor-workspace-abcd1234";
945+ const dataDir = join(workDir, "data");
946+ const dbPath = join(dataDir, "data.db");
947+ const stagingSourcePath = join(workDir, "staging-source.db");
948+ const stagingPath = `${dbPath}.building-recovery`;
949+ const backupPath = `${dbPath}.v1-v2-recovery`;
950+ const journalPath = `${dbPath}.v1-v2-migration.json`;
951+ mkdirSync(dataDir, { recursive: true });
952+
953+ await seedV1Db(dbPath, scopeId);
954+ await seedV1Db(stagingSourcePath, scopeId);
955+ await migrateLocalV1ToV2IfNeeded({
956+ sqlitePath: stagingSourcePath,
957+ tables: collectTables(),
958+ namespace: "executor_local",
959+ tenantId: scopeId,
960+ });
961+ copySqliteFileSetForTest(stagingSourcePath, stagingPath);
962+
963+ writeFileSync(
964+ journalPath,
965+ `${JSON.stringify(
966+ {
967+ version: 1,
968+ source: dbPath,
969+ normalizedSource: `${dbPath}.source-recovery`,
970+ staging: stagingPath,
971+ backup: backupPath,
972+ nonce: "recovery",
973+ phase: "built",
974+ },
975+ null,
976+ 2,
977+ )}\n`,
978+ );
979+
980+ const result = await migrateLocalV1ToV2IfNeeded({
981+ sqlitePath: dbPath,
982+ tables: collectTables(),
983+ namespace: "executor_local",
984+ tenantId: scopeId,
985+ });
986+
987+ expect(result.migrated).toBe(false);
988+ expect(existsSync(journalPath)).toBe(false);
989+ expect(existsSync(backupPath)).toBe(true);
990+
991+ const migrated = await openLocalLibsql(dbPath);
992+ const integrations = await migrated.execute("SELECT tenant, slug FROM integration");
993+ migrated.close();
994+ expect(integrations.rows).toEqual([{ tenant: scopeId, slug: "stripe_api" }]);
995+
996+ const backup = await openLocalLibsql(backupPath);
997+ const legacySources = await backup.execute("SELECT scope_id, id FROM source");
998+ backup.close();
999+ expect(legacySources.rows).toEqual([{ scope_id: scopeId, id: "stripe_api" }]);
1000+ });
1001+
1002+ it("preserves distinct local v1 scopes as distinct v2 tenants", async () => {
1003+ const firstScopeId = "executor-workspace-abcd1234";
1004+ const secondScopeId = "executor-fork-ef567890";
1005+ const dataDir = join(workDir, "data");
1006+ const dbPath = join(dataDir, "data.db");
1007+ mkdirSync(dataDir, { recursive: true });
1008+ await seedV1Db(dbPath, firstScopeId);
1009+ await seedDuplicateStripeScope(dbPath, secondScopeId);
1010+
1011+ const authDir = join(process.env.XDG_DATA_HOME!, "executor");
1012+ mkdirSync(authDir, { recursive: true });
1013+ writeFileSync(
1014+ join(authDir, "auth.json"),
1015+ JSON.stringify(
1016+ {
1017+ [firstScopeId]: { "stripe-key": "sk_test_first" },
1018+ [secondScopeId]: { "stripe-key": "sk_test_second" },
1019+ },
1020+ null,
1021+ 2,
1022+ ),
1023+ );
1024+
1025+ const result = await migrateLocalV1ToV2IfNeeded({
1026+ sqlitePath: dbPath,
1027+ tables: collectTables(),
1028+ namespace: "executor_local",
1029+ tenantId: firstScopeId,
1030+ });
1031+
1032+ expect(result.migrated).toBe(true);
1033+ expect(result.report).toMatchObject({ integrations: 2, connections: 2, secretOps: 2 });
1034+
1035+ const client = await openLocalLibsql(dbPath);
1036+ const integrations = await client.execute(
1037+ "SELECT tenant, slug, plugin_id FROM integration ORDER BY tenant, slug",
1038+ );
1039+ expect(integrations.rows).toEqual([
1040+ { tenant: secondScopeId, slug: "stripe_api", plugin_id: "openapi" },
1041+ { tenant: firstScopeId, slug: "stripe_api", plugin_id: "openapi" },
1042+ ]);
1043+
1044+ const connections = await client.execute(
1045+ "SELECT tenant, owner, subject, integration, name, provider FROM connection ORDER BY tenant, integration, name",
1046+ );
1047+ expect(connections.rows).toEqual([
1048+ {
1049+ tenant: secondScopeId,
1050+ owner: "org",
1051+ subject: "",
1052+ integration: "stripe_api",
1053+ name: "stripeKey",
1054+ provider: "file",
1055+ },
1056+ {
1057+ tenant: firstScopeId,
1058+ owner: "org",
1059+ subject: "",
1060+ integration: "stripe_api",
1061+ name: "stripeKey",
1062+ provider: "file",
1063+ },
1064+ ]);
1065+
1066+ const tools = await client.execute(
1067+ "SELECT tenant, owner, subject, integration, connection, name FROM tool ORDER BY tenant, integration, connection, name",
1068+ );
1069+ expect(tools.rows).toEqual([
1070+ {
1071+ tenant: secondScopeId,
1072+ owner: "org",
1073+ subject: "",
1074+ integration: "stripe_api",
1075+ connection: "stripeKey",
1076+ name: "charges.create",
1077+ },
1078+ {
1079+ tenant: firstScopeId,
1080+ owner: "org",
1081+ subject: "",
1082+ integration: "stripe_api",
1083+ connection: "stripeKey",
1084+ name: "charges.create",
1085+ },
1086+ ]);
1087+ client.close();
1088+
1089+ const auth = decodeAuthFile(readFileSync(join(authDir, "auth.json"), "utf-8"));
1090+ expect(auth[migratedItemId(firstScopeId, "stripe-key")]).toBe("sk_test_first");
1091+ expect(auth[migratedItemId(secondScopeId, "stripe-key")]).toBe("sk_test_second");
1092+ });
1093+
7861094 it("preserves migrated tool slugs and stamps MCP tools with their upstream binding", async () => {
7871095 const scopeId = "executor-workspace-abcd1234";
7881096 const tenantId = "executor-workspace-abcd1234";
apps/local/src/db/v1-v2-migration.ts+394−53
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 import type { Client } from "@libsql/client";
44 import { drizzle } from "drizzle-orm/libsql";
55 import { migrate } from "drizzle-orm/libsql/migrator";
6−import { Effect } from "effect";
6+import { Data, Effect } from "effect";
77 import { createId } from "@executor-js/fumadb/cuid";
88 import { createHash, randomBytes } from "node:crypto";
99 import * as fs from "node:fs";
@@ -103,11 +103,28 @@ export interface LocalV1V2MigrationOptions { · 93 unmodified lines
103103 readonly oauthMetadataTimeoutMs?: number;
104104 }
105105
106+class LocalV1V2MigrationError extends Data.TaggedError("LocalV1V2MigrationError")<{
107+ readonly message: string;
108+ readonly cause?: unknown;
109+}> {}
110+
106111 const FILE_PROVIDER = "file";
107112 const KEYCHAIN_PROVIDER = "keychain";
108113
109114 const fileSetSuffixes = ["", "-wal", "-shm"] as const;
110115
116+type MigrationJournalPhase = "building" | "built" | "canonical-moved" | "committed";
117+
118+interface MigrationJournal {
119+ readonly version: 1;
120+ readonly source: string;
121+ readonly normalizedSource: string;
122+ readonly staging: string;
123+ readonly backup: string;
124+ readonly nonce: string;
125+ readonly phase: MigrationJournalPhase;
126+}
127+
111128 const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`;
112129
113130 const tableExists = async (client: Client, table: string): Promise<boolean> =>
@@ -423,11 +440,14 @@ const discoverMcpOAuthResourceOverrides = async ( · 309 unmodified lines
423440 };
424441
425442 const localOwnerForScope =
426− (tenantId: string) =>
443+ (_tenantId: string) =>
427444 (scopeId: string): OwnerKeys | null => {
428445 const cloud = parseScope(scopeId);
429446 if (cloud) return cloud;
430− return { owner: "org", subject: "", tenant: tenantId };
447+ // Local v1 scope ids are already workspace/tenant partitions. Preserve
448+ // that boundary instead of collapsing every historical local workspace into
449+ // the tenant for whichever cwd happens to boot the v2 migration first.
450+ return { owner: "org", subject: "", tenant: scopeId };
431451 };
432452
433453 const readV1Snapshot = async (client: Client, tenantId: string): Promise<LocalV1Snapshot> => {
@@ -654,15 +674,15 @@ const providerGet = async ( · 220 unmodified lines
654674 return null;
655675 };
656676
657−const collectSecretValues = async (
658− plan: MigrationPlan,
659−): Promise<{
677+interface CollectedSecretValues {
660678 readonly fileValues: Record<string, string>;
661679 readonly keychainValues: ReadonlyArray<{ readonly id: string; readonly value: string }>;
662680 readonly idOverrides: ReadonlyMap<string, string>;
663681 readonly oauthClientIdValues: ReadonlyMap<string, string>;
664682 readonly warnings: readonly string[];
665−}> => {
683+}
684+
685+const collectSecretValues = async (plan: MigrationPlan): Promise<CollectedSecretValues> => {
666686 const authPath = resolveFileAuthPath();
667687 const fileValues = flatAuthEntries(readAuthFile(authPath));
668688 const keychainValues: { id: string; value: string }[] = [];
@@ -765,6 +785,7 @@ const insertPlan = async ( · 96 unmodified lines
765785 oauthClientIdValues: ReadonlyMap<string, string>,
766786 oauthAuthorizationUrls: ReadonlyMap<string, string>,
767787 tenantId: string,
788+ secretValues: CollectedSecretValues,
768789 ): Promise<void> => {
769790 const now = timestamp();
770791 const ownerForScope = localOwnerForScope(tenantId);
@@ -956,6 +977,20 @@ const insertPlan = async ( · 185 unmodified lines
956977 );
957978 }
958979
980+ // Secrets live outside this SQLite transaction (file/keychain providers), so
981+ // they cannot be made atomically durable with the DB. Write them before the
982+ // v1→v2 stamp and keep the writes idempotent; if the process dies before
983+ // COMMIT, the next boot replays the same secret writes harmlessly.
984+ await writeMigratedSecrets(secretValues);
985+ await executeSql(
986+ client,
987+ "CREATE TABLE IF NOT EXISTS data_migration (name TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)",
988+ );
989+ await executeSql(client, "INSERT INTO data_migration (name, time_completed) VALUES (?, ?)", [
990+ LOCAL_V1_V2_LEDGER_NAME,
991+ Date.now(),
992+ ]);
993+
959994 await client.execute("COMMIT");
960995 } catch (cause) {
961996 await client.execute("ROLLBACK");
@@ -984,20 +1019,114 @@ const renameWithRetry = async (source: string, target: string): Promise<void> => · 22 unmodified lines
9841019 }
9851020 };
9861021
1022+const fsyncFileIfExists = (path: string): void => {
1023+ if (!fs.existsSync(path)) return;
1024+ const fd = fs.openSync(path, "r");
1025+ try {
1026+ fs.fsyncSync(fd);
1027+ } finally {
1028+ fs.closeSync(fd);
1029+ }
1030+};
1031+
1032+const fsyncDirectory = (path: string): void => {
1033+ try {
1034+ const fd = fs.openSync(path, "r");
1035+ try {
1036+ fs.fsyncSync(fd);
1037+ } finally {
1038+ fs.closeSync(fd);
1039+ }
1040+ } catch {
1041+ // Some platforms/filesystems do not allow fsync on directories. The rename
1042+ // still happened; this is best-effort durability hardening.
1043+ }
1044+};
1045+
1046+const fsyncSqliteFileSet = (path: string): void => {
1047+ for (const suffix of fileSetSuffixes) fsyncFileIfExists(`${path}${suffix}`);
1048+};
1049+
1050+const fileSetAnyExists = (path: string): boolean =>
1051+ fileSetSuffixes.some((suffix) => fs.existsSync(`${path}${suffix}`));
1052+
9871053 const moveSqliteFileSet = async (source: string, target: string): Promise<void> => {
9881054 await renameWithRetry(source, target);
1055+ fsyncDirectory(dirname(target));
9891056 for (const suffix of ["-wal", "-shm"] as const) {
990− if (fs.existsSync(`${source}${suffix}`))
1057+ if (fs.existsSync(`${source}${suffix}`)) {
9911058 await renameWithRetry(`${source}${suffix}`, `${target}${suffix}`);
1059+ fsyncDirectory(dirname(target));
1060+ } else {
1061+ fs.rmSync(`${target}${suffix}`, { force: true });
1062+ }
1063+ }
1064+};
1065+
1066+const moveExistingSqliteFileSet = async (source: string, target: string): Promise<void> => {
1067+ for (const suffix of fileSetSuffixes) {
1068+ const from = `${source}${suffix}`;
1069+ if (!fs.existsSync(from)) continue;
1070+ const to = `${target}${suffix}`;
1071+ if (fs.existsSync(to)) {
1072+ fs.rmSync(from, { force: true });
1073+ } else {
1074+ await renameWithRetry(from, to);
1075+ fsyncDirectory(dirname(to));
1076+ }
9921077 }
9931078 };
9941079
1080+const copySqliteFileSet = (source: string, target: string): void => {
1081+ removeSqliteFileSet(target);
1082+ for (const suffix of fileSetSuffixes) {
1083+ const from = `${source}${suffix}`;
1084+ const to = `${target}${suffix}`;
1085+ if (fs.existsSync(from)) {
1086+ fs.copyFileSync(from, to);
1087+ fsyncFileIfExists(to);
1088+ } else {
1089+ fs.rmSync(to, { force: true });
1090+ }
1091+ }
1092+ fsyncDirectory(dirname(target));
1093+};
1094+
9951095 const removeSqliteFileSet = (path: string): void => {
9961096 for (const suffix of fileSetSuffixes) fs.rmSync(`${path}${suffix}`, { force: true });
9971097 };
9981098
999−const backupPathFor = (sqlitePath: string): string =>
1000− `${sqlitePath}.v1-v2-${Date.now()}-${randomBytes(4).toString("hex")}`;
1099+const backupPathFor = (sqlitePath: string, nonce: string): string =>
1100+ `${sqlitePath}.v1-v2-${Date.now()}-${nonce}`;
1101+
1102+const stagingPathFor = (sqlitePath: string, nonce: string): string =>
1103+ `${sqlitePath}.building-${nonce}`;
1104+
1105+const normalizedSourcePathFor = (sqlitePath: string, nonce: string): string =>
1106+ `${sqlitePath}.source-${nonce}`;
1107+
1108+const migrationJournalPath = (sqlitePath: string): string => `${sqlitePath}.v1-v2-migration.json`;
1109+
1110+const writeMigrationJournal = (path: string, journal: MigrationJournal): void => {
1111+ fs.mkdirSync(dirname(path), { recursive: true });
1112+ const tmp = `${path}.tmp-${journal.nonce}`;
1113+ fs.writeFileSync(tmp, `${JSON.stringify(journal, null, 2)}\n`, { mode: 0o600 });
1114+ fsyncFileIfExists(tmp);
1115+ fs.renameSync(tmp, path);
1116+ fsyncDirectory(dirname(path));
1117+};
1118+
1119+const removeMigrationJournal = (path: string): void => {
1120+ fs.rmSync(path, { force: true });
1121+ fsyncDirectory(dirname(path));
1122+};
1123+
1124+const readMigrationJournal = (path: string): MigrationJournal | null => {
1125+ if (!fs.existsSync(path)) return null;
1126+ const parsed = JSON.parse(fs.readFileSync(path, "utf-8")) as MigrationJournal;
1127+ if (parsed.version !== 1) return null;
1128+ return parsed;
1129+};
10011130
10021131 const writeMigratedSecrets = async (input: {
10031132 readonly fileValues: Record<string, string>;
@@ -1010,69 +1139,281 @@ const writeMigratedSecrets = async (input: { · 6 unmodified lines
10101139 writeFlatAuthFile(resolveFileAuthPath(), input.fileValues);
10111140 };
10121141
1142+interface MigrationExpectedCounts {
1143+ readonly integrations: number;
1144+ readonly connections: number;
1145+ readonly oauthClients: number;
1146+ readonly tools: number;
1147+ readonly definitions: number;
1148+ readonly policies: number;
1149+}
1150+
1151+const expectedCountsFor = (
1152+ snapshot: LocalV1Snapshot,
1153+ plan: MigrationPlan,
1154+): MigrationExpectedCounts => {
1155+ const connectionTargets = plan.connections.map((connection) => ({
1156+ sourceScopeId: connection.sourceScopeId,
1157+ sourceId: connection.sourceId,
1158+ }));
1159+ const targetCountFor = (scopeId: string, sourceId: string): number =>
1160+ connectionTargets.filter(
1161+ (target) => target.sourceScopeId === scopeId && target.sourceId === sourceId,
1162+ ).length;
1163+ return {
1164+ integrations: plan.integrations.length,
1165+ connections: plan.connections.length,
1166+ oauthClients: plan.oauthClients.length,
1167+ tools: snapshot.tools.reduce((sum, row) => sum + targetCountFor(row.scopeId, row.sourceId), 0),
1168+ definitions: snapshot.definitions.reduce(
1169+ (sum, row) => sum + targetCountFor(row.scopeId, row.sourceId),
1170+ 0,
1171+ ),
1172+ policies: plan.policies.length,
1173+ };
1174+};
1175+
1176+const scalarNumber = async (client: Client, sql: string): Promise<number> => {
1177+ const result = await client.execute(sql);
1178+ const value = result.rows[0]?.["n"];
1179+ return typeof value === "number" ? value : Number(value ?? 0);
1180+};
1181+
1182+const verifyStagingDatabase = async (
1183+ path: string,
1184+ expected: MigrationExpectedCounts,
1185+): Promise<void> => {
1186+ const client = await openLocalLibsql(path);
1187+ try {
1188+ const integrity = await client.execute("PRAGMA integrity_check");
1189+ if (integrity.rows[0]?.["integrity_check"] !== "ok") {
1190+ throw new LocalV1V2MigrationError({
1191+ message: `Staged v2 database failed integrity_check: ${JSON.stringify(integrity.rows)}`,
1192+ cause: integrity.rows,
1193+ });
1194+ }
1195+ const stamped = await queryFirst<{ name: string }>(
1196+ client,
1197+ "SELECT name FROM data_migration WHERE name = ?",
1198+ [LOCAL_V1_V2_LEDGER_NAME],
1199+ );
1200+ if (!stamped) {
1201+ throw new LocalV1V2MigrationError({
1202+ message: "Staged v2 database is missing the v1→v2 stamp",
1203+ });
1204+ }
1205+
1206+ const actual = {
1207+ integrations: await scalarNumber(client, "SELECT COUNT(*) AS n FROM integration"),
1208+ connections: await scalarNumber(client, "SELECT COUNT(*) AS n FROM connection"),
1209+ oauthClients: await scalarNumber(client, "SELECT COUNT(*) AS n FROM oauth_client"),
1210+ tools: await scalarNumber(client, "SELECT COUNT(*) AS n FROM tool"),
1211+ definitions: await scalarNumber(client, "SELECT COUNT(*) AS n FROM definition"),
1212+ policies: await scalarNumber(client, "SELECT COUNT(*) AS n FROM tool_policy"),
1213+ };
1214+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
1215+ throw new LocalV1V2MigrationError({
1216+ message: `Staged v2 database row-count mismatch: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
1217+ cause: { expected, actual },
1218+ });
1219+ }
1220+ await client.execute("PRAGMA wal_checkpoint(TRUNCATE)");
1221+ } finally {
1222+ client.close();
1223+ }
1224+ fsyncSqliteFileSet(path);
1225+};
1226+
1227+const completeJournaledFlip = async (
1228+ journalPath: string,
1229+ journal: MigrationJournal,
1230+): Promise<void> => {
1231+ if (fileSetAnyExists(journal.source)) {
1232+ writeMigrationJournal(journalPath, { ...journal, phase: "canonical-moved" });
1233+ await moveExistingSqliteFileSet(journal.source, journal.backup);
1234+ }
1235+ if (!fs.existsSync(journal.source) && fs.existsSync(journal.staging)) {
1236+ await moveSqliteFileSet(journal.staging, journal.source);
1237+ }
1238+ writeMigrationJournal(journalPath, { ...journal, phase: "committed" });
1239+ removeSqliteFileSet(journal.normalizedSource);
1240+ removeSqliteFileSet(journal.staging);
1241+ removeMigrationJournal(journalPath);
1242+};
1243+
1244+const recoverV1V2Migration = async (sqlitePath: string): Promise<void> => {
1245+ const journalPath = migrationJournalPath(sqlitePath);
1246+ const journal = readMigrationJournal(journalPath);
1247+ if (!journal) return;
1248+
1249+ if (journal.phase === "building") {
1250+ removeSqliteFileSet(journal.normalizedSource);
1251+ removeSqliteFileSet(journal.staging);
1252+ removeMigrationJournal(journalPath);
1253+ return;
1254+ }
1255+
1256+ if (journal.phase === "built" || journal.phase === "canonical-moved") {
1257+ await completeJournaledFlip(journalPath, journal);
1258+ return;
1259+ }
1260+
1261+ removeSqliteFileSet(journal.normalizedSource);
1262+ removeSqliteFileSet(journal.staging);
1263+ removeMigrationJournal(journalPath);
1264+};
1265+
1266+const isPidAlive = (pid: number): boolean => {
1267+ if (!Number.isInteger(pid) || pid <= 0) return false;
1268+ try {
1269+ process.kill(pid, 0);
1270+ return true;
1271+ } catch {
1272+ return false;
1273+ }
1274+};
1275+
1276+interface MigrationLock {
1277+ readonly path: string;
1278+}
1279+
1280+const acquireMigrationLock = (sqlitePath: string): MigrationLock => {
1281+ const lockPath = `${sqlitePath}.v1-v2.lock`;
1282+ fs.mkdirSync(dirname(lockPath), { recursive: true });
1283+ const payload = `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`;
1284+ try {
1285+ fs.writeFileSync(lockPath, payload, { flag: "wx", mode: 0o600 });
1286+ return { path: lockPath };
1287+ } catch (cause) {
1288+ if (!fs.existsSync(lockPath)) throw cause;
1289+ try {
1290+ const existing = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as { pid?: unknown };
1291+ if (typeof existing.pid === "number" && !isPidAlive(existing.pid)) {
1292+ fs.rmSync(lockPath, { force: true });
1293+ fs.writeFileSync(lockPath, payload, { flag: "wx", mode: 0o600 });
1294+ return { path: lockPath };
1295+ }
1296+ } catch {}
1297+ throw new LocalV1V2MigrationError({
1298+ message: "Another local v1→v2 SQLite migration is already in progress.",
1299+ });
1300+ }
1301+};
1302+
1303+const releaseMigrationLock = (lock: MigrationLock): void => {
1304+ fs.rmSync(lock.path, { force: true });
1305+};
1306+
10131307 export const migrateLocalV1ToV2IfNeeded = async (
10141308 options: LocalV1V2MigrationOptions,
10151309 ): Promise<LocalV1V2MigrationResult> => {
1016− if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] };
1310+ const lock = acquireMigrationLock(options.sqlitePath);
1311+ let normalizedSourcePath: string | null = null;
1312+ let stagingPath: string | null = null;
1313+ let target: Awaited<ReturnType<typeof createSqliteFumaDb>> | null = null;
1314+ let reader: Client | null = null;
1315+ let flipStarted = false;
10171316
1018− const reader = await openLocalLibsql(options.sqlitePath);
10191317 try {
1318+ await recoverV1V2Migration(options.sqlitePath);
1319+ if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] };
1320+
1321+ const nonce = randomBytes(4).toString("hex");
1322+ normalizedSourcePath = normalizedSourcePathFor(options.sqlitePath, nonce);
1323+ stagingPath = stagingPathFor(options.sqlitePath, nonce);
1324+ const backupPath = backupPathFor(options.sqlitePath, nonce);
1325+ const journalPath = migrationJournalPath(options.sqlitePath);
1326+
1327+ copySqliteFileSet(options.sqlitePath, normalizedSourcePath);
1328+ reader = await openLocalLibsql(normalizedSourcePath);
10201329 // Ledger short-circuit: once a database has been through this gate the
10211330 // boot data-migration registry stamps it, and the stamp — not schema
10221331 // shape — decides. Probing by shape on every boot is what made a v2
10231332 // database with residual v1-looking state re-enter the migration.
1024− if (await hasV1GateStamp(reader)) return { migrated: false, warnings: [] };
1025− if (!(await isLocalV1Database(reader))) return { migrated: false, warnings: [] };
1333+ if (await hasV1GateStamp(reader)) {
1334+ reader.close();
1335+ reader = null;
1336+ removeSqliteFileSet(normalizedSourcePath);
1337+ normalizedSourcePath = null;
1338+ return { migrated: false, warnings: [] };
1339+ }
1340+ if (!(await isLocalV1Database(reader))) {
1341+ reader.close();
1342+ reader = null;
1343+ removeSqliteFileSet(normalizedSourcePath);
1344+ normalizedSourcePath = null;
1345+ return { migrated: false, warnings: [] };
1346+ }
1347+
1348+ const journal: MigrationJournal = {
1349+ version: 1,
1350+ source: options.sqlitePath,
1351+ normalizedSource: normalizedSourcePath,
1352+ staging: stagingPath,
1353+ backup: backupPath,
1354+ nonce,
1355+ phase: "building",
1356+ };
1357+ writeMigrationJournal(journalPath, journal);
1358+
10261359 const replayWarnings: string[] = [];
1360+ // Legacy replay is intentionally confined to the normalized source copy;
1361+ // the canonical data.db remains a complete v1 database until the final
1362+ // journaled flip installs the staged v2 database.
10271363 await replayLegacyV1Migrations(reader, replayWarnings);
10281364 const snapshot = await readV1Snapshot(reader, options.tenantId);
1365+ reader.close();
1366+ reader = null;
1367+
10291368 const plan = planMigration(snapshot.input);
10301369 const secretValues = await collectSecretValues(plan);
10311370 const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, {
10321371 fetch: options.oauthMetadataFetch ?? fetch,
10331372 timeoutMs: options.oauthMetadataTimeoutMs,
10341373 });
1035− const backupPath = backupPathFor(options.sqlitePath);
1036−
1037− reader.close();
1038− await moveSqliteFileSet(options.sqlitePath, backupPath);
10391374
1040− let target: Awaited<ReturnType<typeof createSqliteFumaDb>> | null = null;
1041− try {
1042− target = await createSqliteFumaDb({
1043− tables: options.tables,
1044− namespace: options.namespace,
1045− path: options.sqlitePath,
1046− });
1047− await insertPlan(
1048− target.client,
1049− snapshot,
1050− plan,
1051− secretValues.idOverrides,
1052− secretValues.oauthClientIdValues,
1053− oauthAuthorizationUrls,
1054− options.tenantId,
1055− );
1056− await target.close();
1057− target = null;
1058− await writeMigratedSecrets(secretValues);
1059− return {
1060− migrated: true,
1061− backupPath,
1062− report: plan.report,
1063− warnings: [...replayWarnings, ...plan.report.warnings, ...secretValues.warnings],
1064− };
1065− } catch (cause) {
1066− if (target) await target.close();
1067− removeSqliteFileSet(options.sqlitePath);
1068− if (fs.existsSync(backupPath)) await moveSqliteFileSet(backupPath, options.sqlitePath);
1069− throw cause;
1375+ target = await createSqliteFumaDb({
1376+ tables: options.tables,
1377+ namespace: options.namespace,
1378+ path: stagingPath,
1379+ });
1380+ await insertPlan(
1381+ target.client,
1382+ snapshot,
1383+ plan,
1384+ secretValues.idOverrides,
1385+ secretValues.oauthClientIdValues,
1386+ oauthAuthorizationUrls,
1387+ options.tenantId,
1388+ secretValues,
1389+ );
1390+ await target.client.execute("PRAGMA wal_checkpoint(TRUNCATE)");
1391+ await target.close();
1392+ target = null;
1393+
1394+ await verifyStagingDatabase(stagingPath, expectedCountsFor(snapshot, plan));
1395+ writeMigrationJournal(journalPath, { ...journal, phase: "built" });
1396+ flipStarted = true;
1397+ await completeJournaledFlip(journalPath, { ...journal, phase: "built" });
1398+
1399+ normalizedSourcePath = null;
1400+ stagingPath = null;
1401+ return {
1402+ migrated: true,
1403+ backupPath,
1404+ report: plan.report,
1405+ warnings: [...replayWarnings, ...plan.report.warnings, ...secretValues.warnings],
1406+ };
1407+ } catch (cause) {
1408+ if (target) await target.close();
1409+ if (reader) reader.close();
1410+ if (!flipStarted) {
1411+ if (normalizedSourcePath) removeSqliteFileSet(normalizedSourcePath);
1412+ if (stagingPath) removeSqliteFileSet(stagingPath);
1413+ removeMigrationJournal(migrationJournalPath(options.sqlitePath));
10701414 }
1415+ throw cause;
10711416 } finally {
1072− try {
1073− reader.close();
1074− } catch {
1075− // already closed after the snapshot was read
1076− }
1417+ releaseMigrationLock(lock);
10771418 }
10781419 };