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
19 19
20export const localDataMigrations: readonly SqliteDataMigration[] = [20export const localDataMigrations: readonly SqliteDataMigration[] = [
21 // The v1→v2 gate itself runs BEFORE the executor (and this registry) can21 // The v1→v2 gate itself runs BEFORE the executor (and this registry) can
22 // exist — see migrateLocalV1ToV2IfNeeded in executor.ts. This entry only22 // exist — see migrateLocalV1ToV2IfNeeded in executor.ts. Migrated v1 DBs are
23 // stamps the outcome: by the time the registry runs, the database is v223 // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get
24 // (it was either migrated or inspected and found already-v2), and the24 // the same stamp here so future boots skip legacy shape probing.
25 // stamp short-circuits every future boot's schema probing.
26 { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void },25 { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void },
27 // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)26 // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)
28 // into the shared placements model.27 // 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
245 245
246 const boot1 = await bootOnce(dbPath);246 const boot1 = await bootOnce(dbPath);
247 expect(boot1.migration.migrated).toBe(true);247 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);
249 await expectV2WithContext7(dbPath);249 await expectV2WithContext7(dbPath);
250 250
251 const boot2 = await bootOnce(dbPath);251 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
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 { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";5import {
6 copyFileSync,
7 existsSync,
8 mkdirSync,
9 mkdtempSync,
10 readFileSync,
11 rmSync,
12 writeFileSync,
13} from "node:fs";
6import { tmpdir } from "node:os";14import { tmpdir } from "node:os";
7import { join } from "node:path";15import { join } from "node:path";
8import { Schema } from "effect";16import { Schema } from "effect";
@@ -668,6 +676,155 @@ const seedV1Db = async ( · 659 unmodified lines
668 client.close();676 client.close();
669};677};
670 678
679const 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
822const 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
671describe("local v1 -> v2 migration", () => {828describe("local v1 -> v2 migration", () => {
672 it("moves a scoped v1 DB to a v2 DB and re-keys file auth.json", async () => {829 it("moves a scoped v1 DB to a v2 DB and re-keys file auth.json", async () => {
673 const scopeId = "executor-workspace-abcd1234";830 const scopeId = "executor-workspace-abcd1234";
@@ -783,6 +940,157 @@ describe("local v1 -> v2 migration", () => { · 109 unmodified lines
783 expect(auth[itemId]).toBe("sk_test_123");940 expect(auth[itemId]).toBe("sk_test_123");
784 });941 });
785 942
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
786 it("preserves migrated tool slugs and stamps MCP tools with their upstream binding", async () => {1094 it("preserves migrated tool slugs and stamps MCP tools with their upstream binding", async () => {
787 const scopeId = "executor-workspace-abcd1234";1095 const scopeId = "executor-workspace-abcd1234";
788 const tenantId = "executor-workspace-abcd1234";1096 const tenantId = "executor-workspace-abcd1234";
apps/local/src/db/v1-v2-migration.ts+394−53
@@ -3,7 +3,7 @@ · 2 unmodified lines
3import type { Client } from "@libsql/client";3import type { Client } from "@libsql/client";
4import { drizzle } from "drizzle-orm/libsql";4import { drizzle } from "drizzle-orm/libsql";
5import { migrate } from "drizzle-orm/libsql/migrator";5import { migrate } from "drizzle-orm/libsql/migrator";
6import { Effect } from "effect";6import { Data, Effect } from "effect";
7import { createId } from "@executor-js/fumadb/cuid";7import { createId } from "@executor-js/fumadb/cuid";
8import { createHash, randomBytes } from "node:crypto";8import { createHash, randomBytes } from "node:crypto";
9import * as fs from "node:fs";9import * as fs from "node:fs";
@@ -103,11 +103,28 @@ export interface LocalV1V2MigrationOptions { · 93 unmodified lines
103 readonly oauthMetadataTimeoutMs?: number;103 readonly oauthMetadataTimeoutMs?: number;
104}104}
105 105
106class LocalV1V2MigrationError extends Data.TaggedError("LocalV1V2MigrationError")<{
107 readonly message: string;
108 readonly cause?: unknown;
109}> {}
110
106const FILE_PROVIDER = "file";111const FILE_PROVIDER = "file";
107const KEYCHAIN_PROVIDER = "keychain";112const KEYCHAIN_PROVIDER = "keychain";
108 113
109const fileSetSuffixes = ["", "-wal", "-shm"] as const;114const fileSetSuffixes = ["", "-wal", "-shm"] as const;
110 115
116type MigrationJournalPhase = "building" | "built" | "canonical-moved" | "committed";
117
118interface 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
111const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`;128const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`;
112 129
113const tableExists = async (client: Client, table: string): Promise<boolean> =>130const tableExists = async (client: Client, table: string): Promise<boolean> =>
@@ -423,11 +440,14 @@ const discoverMcpOAuthResourceOverrides = async ( · 309 unmodified lines
423};440};
424 441
425const localOwnerForScope =442const localOwnerForScope =
426 (tenantId: string) =>443 (_tenantId: string) =>
427 (scopeId: string): OwnerKeys | null => {444 (scopeId: string): OwnerKeys | null => {
428 const cloud = parseScope(scopeId);445 const cloud = parseScope(scopeId);
429 if (cloud) return cloud;446 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 };
431 };451 };
432 452
433const readV1Snapshot = async (client: Client, tenantId: string): Promise<LocalV1Snapshot> => {453const readV1Snapshot = async (client: Client, tenantId: string): Promise<LocalV1Snapshot> => {
@@ -654,15 +674,15 @@ const providerGet = async ( · 220 unmodified lines
654 return null;674 return null;
655};675};
656 676
657const collectSecretValues = async (677interface CollectedSecretValues {
658 plan: MigrationPlan,
659): Promise<{
660 readonly fileValues: Record<string, string>;678 readonly fileValues: Record<string, string>;
661 readonly keychainValues: ReadonlyArray<{ readonly id: string; readonly value: string }>;679 readonly keychainValues: ReadonlyArray<{ readonly id: string; readonly value: string }>;
662 readonly idOverrides: ReadonlyMap<string, string>;680 readonly idOverrides: ReadonlyMap<string, string>;
663 readonly oauthClientIdValues: ReadonlyMap<string, string>;681 readonly oauthClientIdValues: ReadonlyMap<string, string>;
664 readonly warnings: readonly string[];682 readonly warnings: readonly string[];
665}> => {683}
684
685const collectSecretValues = async (plan: MigrationPlan): Promise<CollectedSecretValues> => {
666 const authPath = resolveFileAuthPath();686 const authPath = resolveFileAuthPath();
667 const fileValues = flatAuthEntries(readAuthFile(authPath));687 const fileValues = flatAuthEntries(readAuthFile(authPath));
668 const keychainValues: { id: string; value: string }[] = [];688 const keychainValues: { id: string; value: string }[] = [];
@@ -765,6 +785,7 @@ const insertPlan = async ( · 96 unmodified lines
765 oauthClientIdValues: ReadonlyMap<string, string>,785 oauthClientIdValues: ReadonlyMap<string, string>,
766 oauthAuthorizationUrls: ReadonlyMap<string, string>,786 oauthAuthorizationUrls: ReadonlyMap<string, string>,
767 tenantId: string,787 tenantId: string,
788 secretValues: CollectedSecretValues,
768): Promise<void> => {789): Promise<void> => {
769 const now = timestamp();790 const now = timestamp();
770 const ownerForScope = localOwnerForScope(tenantId);791 const ownerForScope = localOwnerForScope(tenantId);
@@ -956,6 +977,20 @@ const insertPlan = async ( · 185 unmodified lines
956 );977 );
957 }978 }
958 979
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
959 await client.execute("COMMIT");994 await client.execute("COMMIT");
960 } catch (cause) {995 } catch (cause) {
961 await client.execute("ROLLBACK");996 await client.execute("ROLLBACK");
@@ -984,20 +1019,114 @@ const renameWithRetry = async (source: string, target: string): Promise<void> => · 22 unmodified lines
984 }1019 }
985};1020};
986 1021
1022const 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
1032const 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
1046const fsyncSqliteFileSet = (path: string): void => {
1047 for (const suffix of fileSetSuffixes) fsyncFileIfExists(`${path}${suffix}`);
1048};
1049
1050const fileSetAnyExists = (path: string): boolean =>
1051 fileSetSuffixes.some((suffix) => fs.existsSync(`${path}${suffix}`));
1052
987const moveSqliteFileSet = async (source: string, target: string): Promise<void> => {1053const moveSqliteFileSet = async (source: string, target: string): Promise<void> => {
988 await renameWithRetry(source, target);1054 await renameWithRetry(source, target);
1055 fsyncDirectory(dirname(target));
989 for (const suffix of ["-wal", "-shm"] as const) {1056 for (const suffix of ["-wal", "-shm"] as const) {
990 if (fs.existsSync(`${source}${suffix}`))1057 if (fs.existsSync(`${source}${suffix}`)) {
991 await renameWithRetry(`${source}${suffix}`, `${target}${suffix}`);1058 await renameWithRetry(`${source}${suffix}`, `${target}${suffix}`);
1059 fsyncDirectory(dirname(target));
1060 } else {
1061 fs.rmSync(`${target}${suffix}`, { force: true });
1062 }
1063 }
1064};
1065
1066const 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 }
992 }1077 }
993};1078};
994 1079
1080const 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
995const removeSqliteFileSet = (path: string): void => {1095const removeSqliteFileSet = (path: string): void => {
996 for (const suffix of fileSetSuffixes) fs.rmSync(`${path}${suffix}`, { force: true });1096 for (const suffix of fileSetSuffixes) fs.rmSync(`${path}${suffix}`, { force: true });
997};1097};
998 1098
999const backupPathFor = (sqlitePath: string): string =>1099const backupPathFor = (sqlitePath: string, nonce: string): string =>
1000 `${sqlitePath}.v1-v2-${Date.now()}-${randomBytes(4).toString("hex")}`;1100 `${sqlitePath}.v1-v2-${Date.now()}-${nonce}`;
1101
1102const stagingPathFor = (sqlitePath: string, nonce: string): string =>
1103 `${sqlitePath}.building-${nonce}`;
1104
1105const normalizedSourcePathFor = (sqlitePath: string, nonce: string): string =>
1106 `${sqlitePath}.source-${nonce}`;
1107
1108const migrationJournalPath = (sqlitePath: string): string => `${sqlitePath}.v1-v2-migration.json`;
1109
1110const 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
1119const removeMigrationJournal = (path: string): void => {
1120 fs.rmSync(path, { force: true });
1121 fsyncDirectory(dirname(path));
1122};
1123
1124const 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};
1001 1130
1002const writeMigratedSecrets = async (input: {1131const writeMigratedSecrets = async (input: {
1003 readonly fileValues: Record<string, string>;1132 readonly fileValues: Record<string, string>;
@@ -1010,69 +1139,281 @@ const writeMigratedSecrets = async (input: { · 6 unmodified lines
1010 writeFlatAuthFile(resolveFileAuthPath(), input.fileValues);1139 writeFlatAuthFile(resolveFileAuthPath(), input.fileValues);
1011};1140};
1012 1141
1142interface 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
1151const 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
1176const 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
1182const 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
1227const 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
1244const 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
1266const 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
1276interface MigrationLock {
1277 readonly path: string;
1278}
1279
1280const 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
1303const releaseMigrationLock = (lock: MigrationLock): void => {
1304 fs.rmSync(lock.path, { force: true });
1305};
1306
1013export const migrateLocalV1ToV2IfNeeded = async (1307export const migrateLocalV1ToV2IfNeeded = async (
1014 options: LocalV1V2MigrationOptions,1308 options: LocalV1V2MigrationOptions,
1015): Promise<LocalV1V2MigrationResult> => {1309): 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;
1017 1316
1018 const reader = await openLocalLibsql(options.sqlitePath);
1019 try {1317 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);
1020 // Ledger short-circuit: once a database has been through this gate the1329 // Ledger short-circuit: once a database has been through this gate the
1021 // boot data-migration registry stamps it, and the stamp — not schema1330 // boot data-migration registry stamps it, and the stamp — not schema
1022 // shape — decides. Probing by shape on every boot is what made a v21331 // shape — decides. Probing by shape on every boot is what made a v2
1023 // database with residual v1-looking state re-enter the migration.1332 // database with residual v1-looking state re-enter the migration.
1024 if (await hasV1GateStamp(reader)) return { migrated: false, warnings: [] };1333 if (await hasV1GateStamp(reader)) {
1025 if (!(await isLocalV1Database(reader))) return { migrated: false, warnings: [] };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
1026 const replayWarnings: string[] = [];1359 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.
1027 await replayLegacyV1Migrations(reader, replayWarnings);1363 await replayLegacyV1Migrations(reader, replayWarnings);
1028 const snapshot = await readV1Snapshot(reader, options.tenantId);1364 const snapshot = await readV1Snapshot(reader, options.tenantId);
1365 reader.close();
1366 reader = null;
1367
1029 const plan = planMigration(snapshot.input);1368 const plan = planMigration(snapshot.input);
1030 const secretValues = await collectSecretValues(plan);1369 const secretValues = await collectSecretValues(plan);
1031 const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, {1370 const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, {
1032 fetch: options.oauthMetadataFetch ?? fetch,1371 fetch: options.oauthMetadataFetch ?? fetch,
1033 timeoutMs: options.oauthMetadataTimeoutMs,1372 timeoutMs: options.oauthMetadataTimeoutMs,
1034 });1373 });
1035 const backupPath = backupPathFor(options.sqlitePath);
1036
1037 reader.close();
1038 await moveSqliteFileSet(options.sqlitePath, backupPath);
1039 1374
1040 let target: Awaited<ReturnType<typeof createSqliteFumaDb>> | null = null;1375 target = await createSqliteFumaDb({
1041 try {1376 tables: options.tables,
1042 target = await createSqliteFumaDb({1377 namespace: options.namespace,
1043 tables: options.tables,1378 path: stagingPath,
1044 namespace: options.namespace,1379 });
1045 path: options.sqlitePath,1380 await insertPlan(
1046 });1381 target.client,
1047 await insertPlan(1382 snapshot,
1048 target.client,1383 plan,
1049 snapshot,1384 secretValues.idOverrides,
1050 plan,1385 secretValues.oauthClientIdValues,
1051 secretValues.idOverrides,1386 oauthAuthorizationUrls,
1052 secretValues.oauthClientIdValues,1387 options.tenantId,
1053 oauthAuthorizationUrls,1388 secretValues,
1054 options.tenantId,1389 );
1055 );1390 await target.client.execute("PRAGMA wal_checkpoint(TRUNCATE)");
1056 await target.close();1391 await target.close();
1057 target = null;1392 target = null;
1058 await writeMigratedSecrets(secretValues);1393
1059 return {1394 await verifyStagingDatabase(stagingPath, expectedCountsFor(snapshot, plan));
1060 migrated: true,1395 writeMigrationJournal(journalPath, { ...journal, phase: "built" });
1061 backupPath,1396 flipStarted = true;
1062 report: plan.report,1397 await completeJournaledFlip(journalPath, { ...journal, phase: "built" });
1063 warnings: [...replayWarnings, ...plan.report.warnings, ...secretValues.warnings],1398
1064 };1399 normalizedSourcePath = null;
1065 } catch (cause) {1400 stagingPath = null;
1066 if (target) await target.close();1401 return {
1067 removeSqliteFileSet(options.sqlitePath);1402 migrated: true,
1068 if (fs.existsSync(backupPath)) await moveSqliteFileSet(backupPath, options.sqlitePath);1403 backupPath,
1069 throw cause;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));
1070 }1414 }
1415 throw cause;
1071 } finally {1416 } finally {
1072 try {1417 releaseMigrationLock(lock);
1073 reader.close();
1074 } catch {
1075 // already closed after the snapshot was read
1076 }
1077 }1418 }
1078};1419};