mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 21:21:18 -04:00
fix(core): tolerate older migration schemas
This commit is contained in:
@@ -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}}'
|
||||
);
|
||||
@@ -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* () {
|
||||
|
||||
Reference in New Issue
Block a user