diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts index d6aad774a77..855a57d6ed8 100644 --- a/packages/core/src/database/v1-migration.ts +++ b/packages/core/src/database/v1-migration.ts @@ -159,6 +159,58 @@ type NextMessage = { readonly data: string } +type SourceColumns = Readonly> + +const nextProjectColumns = { + id: true, + worktree: true, + vcs: null, + name: null, + icon_url: null, + icon_url_override: null, + icon_color: null, + time_created: true, + time_updated: true, + time_initialized: null, + sandboxes: true, + commands: null, +} satisfies SourceColumns + +const nextSessionColumns = { + id: true, + project_id: true, + workspace_id: null, + parent_id: null, + fork_session_id: null, + fork_boundary: null, + slug: true, + directory: true, + path: null, + title: null, + version: true, + share_url: null, + summary_additions: null, + summary_deletions: null, + summary_files: null, + summary_diffs: null, + metadata: null, + cost: true, + tokens_input: true, + tokens_output: true, + tokens_reasoning: true, + tokens_cache_read: true, + tokens_cache_write: true, + revert: null, + permission: null, + agent: null, + model: null, + time_created: true, + time_updated: true, + time_compacting: null, + time_archived: null, + time_suspended: null, +} satisfies SourceColumns + const lock = Semaphore.makeUnsafe(1) const MIGRATION_STATE_KEY = "migration.v1-v2" const EVENT_DELETE_BATCH_SIZE = 1_000 @@ -678,12 +730,9 @@ function importNextDatabase( }), ) const projects = new Map( - source - .query("SELECT * FROM project") - .all() - .map((project) => [project.id, project]), + selectSourceRows(source, "project", nextProjectColumns).map((project) => [project.id, project]), ) - const sessions = source.query("SELECT * FROM session ORDER BY id DESC").all() + const sessions = selectSourceRows(source, "session", nextSessionColumns, "id") for (const [index, session] of sessions.entries()) { const project = projects.get(session.project_id) const projectID = project ? session.project_id : Project.ID.global @@ -776,6 +825,33 @@ function isNextDatabase(source: SQLiteDatabase) { return tables.has("project") && tables.has("session") && tables.has("session_message") } +function selectSourceRows( + source: SQLiteDatabase, + table: "project" | "session", + columns: SourceColumns, + orderBy?: keyof Row, +) { + const available = new Set( + source + .query<{ name: string }, []>(`PRAGMA table_info("${table}")`) + .all() + .map((column) => column.name), + ) + const selection = Object.entries(columns) + .map(([name, required]) => { + if (available.has(name)) return `"${name}"` + if (!required) return `NULL AS "${name}"` + throw new Error(`Previous V2 database ${table} table is missing required column ${name}`) + }) + .join(", ") + return source + .query< + Row, + [] + >(`SELECT ${selection} FROM "${table}"${orderBy === undefined ? "" : ` ORDER BY "${String(orderBy)}" DESC`}`) + .all() +} + function row( source: SourceMessage, message: { diff --git a/packages/core/test/fixture/v1-migration-old-next.sql b/packages/core/test/fixture/v1-migration-old-next.sql new file mode 100644 index 00000000000..647a5c02280 --- /dev/null +++ b/packages/core/test/fixture/v1-migration-old-next.sql @@ -0,0 +1,74 @@ +CREATE TABLE project ( + id text PRIMARY KEY, + worktree text NOT NULL, + vcs text, + name text, + icon_url text, + time_created integer NOT NULL, + time_updated integer NOT NULL, + time_initialized integer, + sandboxes text NOT NULL +); + +CREATE TABLE session ( + id text PRIMARY KEY, + project_id text NOT NULL, + workspace_id text, + parent_id text, + fork_session_id text, + fork_message_id text, + fork_seq integer, + slug text NOT NULL, + directory text NOT NULL, + path text, + title text, + version text NOT NULL, + share_url text, + summary_additions integer, + summary_deletions integer, + summary_files integer, + summary_diffs text, + metadata text, + cost real DEFAULT 0 NOT NULL, + tokens_input integer DEFAULT 0 NOT NULL, + tokens_output integer DEFAULT 0 NOT NULL, + tokens_reasoning integer DEFAULT 0 NOT NULL, + tokens_cache_read integer DEFAULT 0 NOT NULL, + tokens_cache_write integer DEFAULT 0 NOT NULL, + revert text, + permission text, + agent text, + model text, + time_created integer NOT NULL, + time_updated integer NOT NULL, + time_compacting integer, + time_archived integer +); + +CREATE TABLE session_message ( + id text PRIMARY KEY, + session_id text NOT NULL, + type text NOT NULL, + seq integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL +); + +INSERT INTO project ( + id, worktree, vcs, name, icon_url, time_created, time_updated, time_initialized, sandboxes +) VALUES ( + 'old-project', '/tmp/old-next', 'git', 'Old project', 'https://example.test/icon.png', 1, 2, 3, '[]' +); + +INSERT INTO session ( + id, project_id, fork_session_id, fork_message_id, fork_seq, slug, directory, title, version, + time_created, time_updated +) VALUES ( + 'ses_old_next', 'old-project', 'ses_parent', 'msg_parent', 4, 'old-next', '/tmp/old-next', + 'Old imported session', '2', 10, 20 +); + +INSERT INTO session_message VALUES ( + 'msg_old_next', 'ses_old_next', 'user', 0, 12, 13, '{"text":"from old next","time":{"created":12}}' +); diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index b3ec583f69a..e2dafd3190b 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -945,6 +945,44 @@ describe("V1Migration database workflow", () => { ) }) + test("imports previous V2 sessions from an older source schema", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "opencode-next.db") + const sqlite = await import("bun:sqlite") + const source = new sqlite.Database(filename) + source.exec(await Bun.file(path.join(import.meta.dir, "fixture/v1-migration-old-next.sql")).text()) + source.close() + + await database( + Effect.gen(function* () { + const { db } = yield* Database.Service + + expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" }) + expect( + yield* db.get( + sql`SELECT fork_session_id, fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_old_next'`, + ), + ).toEqual({ fork_session_id: "ses_parent", fork_boundary: null, time_suspended: null }) + expect( + yield* db.get( + sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'old-project'`, + ), + ).toEqual({ + icon_url: "https://example.test/icon.png", + icon_url_override: null, + icon_color: null, + commands: null, + }) + expect(yield* db.all(sql`SELECT id, seq FROM session_message WHERE session_id = 'ses_old_next'`)).toEqual([ + { id: "msg_old_next", seq: 0 }, + ]) + expect(yield* db.get(sql`SELECT seq FROM event_sequence WHERE aggregate_id = 'ses_old_next'`)).toEqual({ + seq: 0, + }) + }), + ) + }) + test("derives required status from the durable cursor", async () => { await database( Effect.gen(function* () {