diff --git a/packages/core/src/database/v1-migration.bun.ts b/packages/core/src/database/v1-migration.bun.ts index 537b91bad6..a347cb12b1 100644 --- a/packages/core/src/database/v1-migration.bun.ts +++ b/packages/core/src/database/v1-migration.bun.ts @@ -114,6 +114,23 @@ type NextProject = { readonly commands: string | null } +type NextColumns = Record + +const NEXT_PROJECT_COLUMNS = { + id: "required", + worktree: "required", + vcs: "nullable", + name: "nullable", + icon_url: "nullable", + icon_url_override: { fallback: "icon_url" }, + icon_color: "nullable", + time_created: "required", + time_updated: "required", + time_initialized: "nullable", + sandboxes: "required", + commands: "nullable", +} satisfies NextColumns + type NextSession = { readonly id: string readonly project_id: string @@ -149,6 +166,41 @@ type NextSession = { readonly time_suspended: number | null } +const NEXT_SESSION_COLUMNS = { + id: "required", + project_id: "required", + workspace_id: "nullable", + parent_id: "nullable", + fork_session_id: "nullable", + fork_boundary: "nullable", + slug: "required", + directory: "required", + path: "nullable", + title: "nullable", + version: "required", + share_url: "nullable", + summary_additions: "nullable", + summary_deletions: "nullable", + summary_files: "nullable", + summary_diffs: "nullable", + metadata: "nullable", + cost: "required", + tokens_input: "required", + tokens_output: "required", + tokens_reasoning: "required", + tokens_cache_read: "required", + tokens_cache_write: "required", + revert: "nullable", + permission: "nullable", + agent: "nullable", + model: "nullable", + time_created: "required", + time_updated: "required", + time_compacting: "nullable", + time_archived: "nullable", + time_suspended: "nullable", +} satisfies NextColumns + type NextMessage = { readonly id: string readonly session_id: string @@ -686,12 +738,9 @@ function importNextDatabase( }), ) const projects = new Map( - source - .query("SELECT * FROM project") - .all() - .map((project) => [project.id, project]), + selectNextRows(source, "project", NEXT_PROJECT_COLUMNS).map((project) => [project.id, project]), ) - const sessions = source.query("SELECT * FROM session ORDER BY id DESC").all() + const sessions = selectNextRows(source, "session", NEXT_SESSION_COLUMNS) for (const [index, session] of sessions.entries()) { const project = projects.get(session.project_id) const projectID = project ? session.project_id : Project.ID.global @@ -789,6 +838,35 @@ function isNextDatabase(source: SQLiteDatabase) { return tables.has("project") && tables.has("session") && tables.has("session_message") } +function selectNextRows(source: SQLiteDatabase, table: "project" | "session", definition: NextColumns) { + const columns = new Set( + source + .query<{ name: string }, [string]>("SELECT name FROM pragma_table_info(?)") + .all(table) + .map((column) => column.name), + ) + const missing = Object.entries(definition) + .filter(([column, strategy]) => strategy === "required" && !columns.has(column)) + .map(([column]) => column) + if (missing.length) + throw new Error(`Incompatible opencode-next.db: ${table} is missing required columns: ${missing.join(", ")}`) + const projection = Object.entries(definition).map(([column, strategy]) => { + if (columns.has(column)) return `"${column}"` + if ( + typeof strategy === "object" && + strategy !== null && + "fallback" in strategy && + typeof strategy.fallback === "string" && + columns.has(strategy.fallback) + ) + return `"${strategy.fallback}" AS "${column}"` + return `NULL AS "${column}"` + }) + return source + .query(`SELECT ${projection.join(", ")} FROM "${table}"${table === "session" ? ' ORDER BY "id" DESC' : ""}`) + .all() +} + function row( source: SourceMessage, message: { diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index e655a171d0..c924cddc96 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -946,6 +946,61 @@ describe("V1Migration database workflow", () => { ) }) + test("imports previous V2 databases missing newer nullable columns", 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.run(` + 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, + 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 VALUES ( + 'next-project', '/tmp/next', 'git', 'Source project', 'https://example.com/icon.png', 1, 2, NULL, '[]' + ); + INSERT INTO session ( + id, project_id, slug, directory, title, version, time_created, time_updated + ) VALUES ('ses_next', 'next-project', 'next', '/tmp/next', 'Imported', '2', 10, 20); + `) + source.close() + + await database( + Effect.gen(function* () { + const database = yield* Database.Service + expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" }) + expect( + yield* database.db.get(sql`SELECT fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_next'`), + ).toEqual({ fork_boundary: null, time_suspended: null }) + expect( + yield* database.db.get( + sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'next-project'`, + ), + ).toEqual({ + icon_url: "https://example.com/icon.png", + icon_url_override: "https://example.com/icon.png", + icon_color: null, + commands: null, + }) + }), + ) + }) + test("derives required status from the durable cursor", async () => { await database( Effect.gen(function* () {