diff --git a/docs/design/v1-v2-database-migration.md b/docs/design/v1-v2-database-migration.md index faabd0a79e..e384440df2 100644 --- a/docs/design/v1-v2-database-migration.md +++ b/docs/design/v1-v2-database-migration.md @@ -269,7 +269,8 @@ Before transforming V1 rows, look for `opencode-next.db` in the data directory. builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions. Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its -maximum message sequence. Commit each imported Session independently and leave the source database untouched. +maximum message sequence. Commit each imported Session independently and leave the source database untouched. Skip and +warn for beta Sessions whose referenced project row is missing rather than blocking the remaining migration. The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor: the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed. diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts index 4517411794..3c77539c28 100644 --- a/packages/core/src/database/v1-migration.ts +++ b/packages/core/src/database/v1-migration.ts @@ -618,10 +618,15 @@ function importNextDatabase( nextCompleted = 0 for (const session of sessions) { const project = projects.get(session.project_id) - if (!project) - return yield* Effect.die( - new Error(`Previous V2 session ${session.id} references missing project ${session.project_id}`), - ) + if (!project) { + yield* Effect.logWarning("Skipped previous V2 session with missing project", { + sessionID: session.id, + projectID: session.project_id, + }) + nextCompleted++ + yield* Effect.yieldNow + continue + } const messages = source .query( "SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq", diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index 327e00c8f6..a3c9dda7fe 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -825,10 +825,12 @@ describe("V1Migration database workflow", () => { ) VALUES ('ses_next', 'next-project', 'next', 'C:/Users/sewer', 'Imported', '2', 'build', '{"id":"model","providerID":"provider"}', 10, 20), - ('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21); + ('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21), + ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22); INSERT INTO session_message VALUES ('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next","time":{"created":12}}'), - ('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'); + ('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'), + ('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}'); `) source.close() @@ -851,13 +853,13 @@ describe("V1Migration database workflow", () => { expect(yield* V1Migration.status({ nextDatabasePath: filename })).toEqual({ status: "required", completed: 0, - total: 2, + total: 3, }) expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" }) expect(yield* V1Migration.status({ nextDatabasePath: filename })).toEqual({ status: "completed", - completed: 2, - total: 2, + completed: 3, + total: 3, }) expect(yield* db.get(sql`SELECT title, agent, model FROM session_v2 WHERE id = 'ses_next'`)).toEqual({ title: "Imported", @@ -888,6 +890,7 @@ describe("V1Migration database workflow", () => { expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_existing'`)).toEqual([ { id: "msg_current_existing" }, ]) + expect(yield* db.get(sql`SELECT id FROM session_v2 WHERE id = 'ses_orphan'`)).toBeUndefined() expect(yield* db.get(sql`SELECT name, worktree FROM project WHERE id = 'next-project'`)).toEqual({ name: "Current project", worktree: "/tmp/current",