diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ba8d3339c3..b4834dd512 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -488,14 +488,34 @@ describe("worktree helpers", () => { expect(full.nullifyColumns).toEqual({}); }); - it("rejects a source migration journal that is ahead of the code journal", () => { + it("rejects a source migration journal that diverges from the code journal", () => { expect(() => resolveWorktreeSeedMigrationRevision({ status: "upToDate", tableCount: 1, availableMigrations: ["0001_initial.sql", "0002_current.sql"], - appliedMigrations: ["0001_initial.sql", "0002_current.sql"], + appliedMigrations: ["0001_initial.sql", "0003_unknown.sql"], journalEntryCount: 3, - }, "sourcePrefix")).toThrow("Migration journal is ahead of this Paperclip checkout"); + }, "sourcePrefix")).toThrow("Migration journal is not a prefix of this Paperclip checkout"); + }); + + it("accepts a current source whose migration application order differs from filename order", () => { + expect(resolveWorktreeSeedMigrationRevision({ + status: "upToDate", + tableCount: 1, + availableMigrations: [ + "0001_initial.sql", + "0002_renumbered.sql", + "0003_applied_earlier.sql", + "0004_current.sql", + ], + appliedMigrations: [ + "0001_initial.sql", + "0003_applied_earlier.sql", + "0002_renumbered.sql", + "0004_current.sql", + ], + journalEntryCount: 6, + }, "upToDate")).toBe("0004_current.sql"); }); it("accepts a source migration journal that is multiple revisions behind", () => { @@ -508,9 +528,9 @@ describe("worktree helpers", () => { "0003_pending.sql", "0004_pending.sql", ], - appliedMigrations: ["0001_initial.sql", "0002_applied.sql"], + appliedMigrations: ["0002_applied.sql", "0001_initial.sql"], pendingMigrations: ["0003_pending.sql", "0004_pending.sql"], - journalEntryCount: 2, + journalEntryCount: 3, reason: "pending-migrations", }, "sourcePrefix")).toBe("0002_applied.sql"); }); @@ -1244,7 +1264,7 @@ describe("worktree helpers", () => { }); itEmbeddedPostgres( - "seeds a source whose migration journal is behind the code journal", + "seeds a lagging source whose migration application order differs from filename order", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed"); @@ -1264,7 +1284,30 @@ describe("worktree helpers", () => { DELETE FROM "drizzle"."__drizzle_migrations" WHERE "id" = ( SELECT max("id") FROM "drizzle"."__drizzle_migrations" + ); + + WITH pair AS ( + SELECT + array_agg("id" ORDER BY "id" DESC) AS ids, + array_agg("hash" ORDER BY "id" DESC) AS hashes + FROM ( + SELECT "id", "hash" + FROM "drizzle"."__drizzle_migrations" + ORDER BY "id" DESC + LIMIT 2 + ) latest ) + UPDATE "drizzle"."__drizzle_migrations" migrations + SET "hash" = CASE + WHEN migrations."id" = pair.ids[1] THEN pair.hashes[2] + WHEN migrations."id" = pair.ids[2] THEN pair.hashes[1] + ELSE migrations."hash" + END + FROM pair + WHERE migrations."id" IN (pair.ids[1], pair.ids[2]); + + INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at") + VALUES ('stale-unresolvable-migration-hash', 0) `); await sourceDbClient.$client.end({ timeout: 5 }); const laggingMigrationState = await inspectMigrations(sourceDb.connectionString); @@ -1273,7 +1316,18 @@ describe("worktree helpers", () => { throw new Error("Expected the source migration journal to lag the code journal"); } expect(laggingMigrationState.pendingMigrations).toHaveLength(1); - const sourceMigrationRevision = laggingMigrationState.appliedMigrations.at(-1); + const expectedAppliedPrefix = laggingMigrationState.availableMigrations.slice( + 0, + laggingMigrationState.appliedMigrations.length, + ); + expect(laggingMigrationState.appliedMigrations).not.toEqual(expectedAppliedPrefix); + expect([...laggingMigrationState.appliedMigrations].sort()).toEqual( + [...expectedAppliedPrefix].sort(), + ); + expect(laggingMigrationState.journalEntryCount).toBeGreaterThan( + laggingMigrationState.appliedMigrations.length, + ); + const sourceMigrationRevision = expectedAppliedPrefix.at(-1); expect(sourceMigrationRevision).toBeTruthy(); fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index be6926c220..d31dabaefc 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1416,20 +1416,14 @@ export function resolveWorktreeSeedMigrationRevision( migrationState: Awaited>, requirement: "sourcePrefix" | "upToDate", ): string { - if (migrationState.journalEntryCount > migrationState.availableMigrations.length) { - throw new Error( - `Migration journal is ahead of this Paperclip checkout (${migrationState.journalEntryCount} applied migration(s), ${migrationState.availableMigrations.length} available).`, - ); - } - const expectedAppliedPrefix = migrationState.availableMigrations.slice( 0, migrationState.appliedMigrations.length, ); + const appliedMigrationNames = new Set(migrationState.appliedMigrations); if ( - migrationState.appliedMigrations.some( - (migration, index) => migration !== expectedAppliedPrefix[index], - ) + appliedMigrationNames.size !== expectedAppliedPrefix.length || + expectedAppliedPrefix.some((migration) => !appliedMigrationNames.has(migration)) ) { throw new Error("Migration journal is not a prefix of this Paperclip checkout's migration journal."); } @@ -1440,7 +1434,7 @@ export function resolveWorktreeSeedMigrationRevision( ); } - const migrationRevision = migrationState.appliedMigrations.at(-1); + const migrationRevision = expectedAppliedPrefix.at(-1); if (!migrationRevision) { throw new Error("Migration journal has no applied revision."); }