From 66312fc5a6d69524a79fa23e68dc5d622966e4ff Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 26 Apr 2026 20:45:14 -0700 Subject: [PATCH] make v1 migration org-idempotent --- .../src/lib/trpc/routers/migration/index.ts | 19 ---- .../useMigrateV1DataToV2/migrate.test.ts | 95 ++++++++++++++++--- .../hooks/useMigrateV1DataToV2/migrate.ts | 30 ++---- 3 files changed, 88 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/migration/index.ts b/apps/desktop/src/lib/trpc/routers/migration/index.ts index 509753b937b..f7d3bd2643b 100644 --- a/apps/desktop/src/lib/trpc/routers/migration/index.ts +++ b/apps/desktop/src/lib/trpc/routers/migration/index.ts @@ -96,24 +96,5 @@ export const createMigrationRouter = () => { .where(eq(v1MigrationState.organizationId, input.organizationId)) .run(); }), - - findMigrationByOtherOrg: publicProcedure - .input(z.object({ organizationId: z.string().min(1) })) - .query(({ input }) => { - const other = localDb - .select({ - organizationId: v1MigrationState.organizationId, - status: v1MigrationState.status, - }) - .from(v1MigrationState) - .where(eq(v1MigrationState.kind, "project")) - .all() - .find( - (row) => - row.organizationId !== input.organizationId && - (row.status === "success" || row.status === "linked"), - ); - return other?.organizationId ?? null; - }), }); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts index 9720ff8a1ee..2c8eba026a2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts @@ -59,7 +59,6 @@ interface FakeEnv { v1Worktrees: V1WorktreeRow[]; v1Sections: V1SectionRow[]; state: Map; - otherOrg: string | null; findByPath: Map; failNextStateWriteFor: Set; hostProjectsByPath: Map; @@ -88,7 +87,6 @@ function makeFakeEnv(overrides: Partial = {}): FakeEnv { v1Worktrees: [], v1Sections: [], state: new Map(), - otherOrg: null, findByPath: new Map(), failNextStateWriteFor: new Set(), hostProjectsByPath: new Map(), @@ -130,9 +128,6 @@ function makeElectronTrpc(env: FakeEnv): ElectronTrpcClient { (r) => r.organizationId === organizationId, ), }, - findMigrationByOtherOrg: { - query: async (_: { organizationId: string }) => env.otherOrg, - }, upsertState: { mutate: async (row: Omit) => { const key = `${row.kind}:${row.v1Id}`; @@ -589,20 +584,35 @@ describe("migrateV1DataToV2", () => { expect(env.state.get("workspace:w1")?.status).toBe("error"); }); - test("other-org guard rejects migration", async () => { + test("other-org state does not block migration for the active organization", async () => { const env = makeFakeEnv({ - otherOrg: "some-other-org", v1Projects: [project("p1")], + state: new Map([ + [ + "project:p1", + { + v1Id: "p1", + v2Id: "v2-other-org-project", + organizationId: "some-other-org", + kind: "project", + status: "success", + reason: null, + }, + ], + ]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), }); - await expect( - migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }), - ).rejects.toThrow(/already been migrated/); + expect(summary.projectsCreated).toBe(1); + expect(summary.errors).toHaveLength(0); + expect(env.state.get("project:p1")?.organizationId).toBe(ORG); + expect(env.state.get("project:p1")?.status).toBe("success"); }); test("rerun skips rows already in success/linked state, retries error rows and skipped workspaces", async () => { @@ -729,6 +739,61 @@ describe("migrateV1DataToV2", () => { expect(env.adoptCalls).toHaveLength(0); }); + test("running a completed migration again does not create duplicate projects or workspaces", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), + ], + v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], + }); + const electronTrpc = makeElectronTrpc(env); + const hostService = makeHostService(env); + const collections = makeCollections(); + + const first = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc, + hostService, + collections, + }); + + expect(first.projectsCreated).toBe(1); + expect(first.workspacesCreated).toBe(1); + expect(env.createCalls).toHaveLength(1); + expect(env.adoptCalls).toHaveLength(1); + expect(env.createdWorkspaceIds).toHaveLength(1); + + const second = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc, + hostService, + collections, + }); + + expect(second.projectsCreated).toBe(0); + expect(second.projectsLinked).toBe(0); + expect(second.workspacesCreated).toBe(0); + expect(second.workspacesErrored).toBe(0); + expect(second.projects).toEqual([ + { name: "project-p1", status: "synced", reason: "Already imported" }, + ]); + expect(second.workspaces).toEqual([ + { + name: "workspace-w1", + branch: "branch-w1", + status: "synced", + reason: "Already imported", + }, + ]); + expect(env.createCalls).toHaveLength(1); + expect(env.adoptCalls).toHaveLength(1); + expect(env.createdWorkspaceIds).toHaveLength(1); + expect(env.setupCalls).toEqual([ + { projectId: "v2-proj-1", repoPath: "/repos/p1" }, + ]); + }); + test("project state write failure does not migrate child workspaces until rerun reconciles the project", async () => { const env = makeFakeEnv({ v1Projects: [project("p1")], diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts index 38e7e0d65d0..2c4220359b4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts @@ -187,28 +187,14 @@ export async function migrateV1DataToV2(args: Args): Promise { const { organizationId, electronTrpc, hostService, collections } = args; const summary = emptySummary(); - const [ - v1Projects, - v1Workspaces, - v1Worktrees, - v1Sections, - existingState, - otherOrg, - ] = await Promise.all([ - electronTrpc.migration.readV1Projects.query(), - electronTrpc.migration.readV1Workspaces.query(), - electronTrpc.migration.readV1Worktrees.query(), - electronTrpc.migration.readV1WorkspaceSections.query(), - electronTrpc.migration.listState.query({ organizationId }), - electronTrpc.migration.findMigrationByOtherOrg.query({ organizationId }), - ]); - - if (otherOrg) { - throw new Error( - `v1 data has already been migrated to organization ${otherOrg}. ` + - "Contact support if you need to migrate to a different organization.", - ); - } + const [v1Projects, v1Workspaces, v1Worktrees, v1Sections, existingState] = + await Promise.all([ + electronTrpc.migration.readV1Projects.query(), + electronTrpc.migration.readV1Workspaces.query(), + electronTrpc.migration.readV1Worktrees.query(), + electronTrpc.migration.readV1WorkspaceSections.query(), + electronTrpc.migration.listState.query({ organizationId }), + ]); const stateByKey = new Map(); for (const row of existingState) {