Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 23e4e35ae8 fix(core): tolerate older migration schemas 2026-08-11 12:16:59 -04:00
3 changed files with 193 additions and 5 deletions
+81 -5
View File
@@ -159,6 +159,58 @@ type NextMessage = {
readonly data: string
}
type SourceColumns<Row> = Readonly<Record<keyof Row, true | null>>
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<NextProject>
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<NextSession>
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<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
selectSourceRows<NextProject>(source, "project", nextProjectColumns).map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
const sessions = selectSourceRows<NextSession>(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<Row>(
source: SQLiteDatabase,
table: "project" | "session",
columns: SourceColumns<Row>,
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: {
@@ -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}}'
);
+38
View File
@@ -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* () {