This commit is contained in:
Dax Raad 2026-01-15 16:26:49 -05:00
commit a4183c3b2c
11 changed files with 193 additions and 46 deletions

View file

@ -4,4 +4,7 @@ export default defineConfig({
dialect: "sqlite",
schema: "./src/**/*.sql.ts",
out: "./migration",
dbCredentials: {
url: "/home/thdxr/.local/share/opencode/opencode.db",
},
})

View file

@ -41,6 +41,7 @@
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"better-sqlite3": "12.6.0",
"drizzle-kit": "0.31.8",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",

View file

@ -13,6 +13,7 @@ import {
TodoTable,
PermissionTable,
} from "../../session/session.sql"
import { Session } from "../../session"
import { SessionShareTable, ShareTable } from "../../share/share.sql"
import path from "path"
import fs from "fs/promises"
@ -66,7 +67,7 @@ const ExportCommand = cmd({
for (const row of db().select().from(SessionTable).all()) {
const dir = path.join(sessionDir, row.projectID)
await fs.mkdir(dir, { recursive: true })
await Bun.write(path.join(dir, `${row.id}.json`), JSON.stringify(row.data, null, 2))
await Bun.write(path.join(dir, `${row.id}.json`), JSON.stringify(Session.fromRow(row), null, 2))
stats.sessions++
}

View file

@ -84,15 +84,11 @@ export const ImportCommand = cmd({
db()
.insert(SessionTable)
.values({
id: exportData.info.id,
projectID: Instance.project.id,
parentID: exportData.info.parentID,
createdAt: exportData.info.time.created,
updatedAt: exportData.info.time.updated,
data: exportData.info,
.values(Session.toRow({ ...exportData.info, projectID: Instance.project.id }))
.onConflictDoUpdate({
target: SessionTable.id,
set: Session.toRow({ ...exportData.info, projectID: Instance.project.id }),
})
.onConflictDoUpdate({ target: SessionTable.id, set: { data: exportData.info } })
.run()
for (const msg of exportData.messages) {

View file

@ -86,7 +86,7 @@ async function getCurrentProject(): Promise<Project.Info> {
async function getAllSessions(): Promise<Session.Info[]> {
const sessionRows = db().select().from(SessionTable).all()
return sessionRows.map((row) => row.data)
return sessionRows.map((row) => Session.fromRow(row))
}
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {

View file

@ -9,7 +9,7 @@ import { SessionTable } from "../session/session.sql"
import { eq } from "drizzle-orm"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import type { Session } from "../session"
import { Session } from "../session"
import { work } from "../util/queue"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event"
@ -304,17 +304,13 @@ export namespace Project {
log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length })
await work(10, globalSessions, async (row) => {
const session = row.data as Session.Info
const session = Session.fromRow(row)
if (!session) return
if (session.directory && session.directory !== worktree) return
session.projectID = newProjectID
log.info("migrating session", { sessionID: session.id, from: "global", to: newProjectID })
db()
.update(SessionTable)
.set({ projectID: newProjectID, data: session })
.where(eq(SessionTable.id, session.id))
.run()
db().update(SessionTable).set(Session.toRow(session)).where(eq(SessionTable.id, session.id)).run()
}).catch((error) => {
log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID })
})

View file

@ -42,6 +42,75 @@ export namespace Session {
).test(title)
}
type SessionRow = typeof SessionTable.$inferSelect
export function fromRow(row: SessionRow): Info {
const summary =
row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null
? {
additions: row.summary_additions ?? 0,
deletions: row.summary_deletions ?? 0,
files: row.summary_files ?? 0,
diffs: row.summary_diffs ?? undefined,
}
: undefined
const share = row.share_url ? { url: row.share_url } : undefined
const revert =
row.revert_messageID !== null
? {
messageID: row.revert_messageID,
partID: row.revert_partID ?? undefined,
snapshot: row.revert_snapshot ?? undefined,
diff: row.revert_diff ?? undefined,
}
: undefined
return {
id: row.id,
slug: row.slug,
projectID: row.projectID,
directory: row.directory,
parentID: row.parentID ?? undefined,
title: row.title,
version: row.version,
summary,
share,
revert,
permission: row.permission ?? undefined,
time: {
created: row.time_created,
updated: row.time_updated,
compacting: row.time_compacting ?? undefined,
archived: row.time_archived ?? undefined,
},
}
}
export function toRow(info: Info) {
return {
id: info.id,
projectID: info.projectID,
parentID: info.parentID,
slug: info.slug,
directory: info.directory,
title: info.title,
version: info.version,
share_url: info.share?.url,
summary_additions: info.summary?.additions,
summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs,
revert_messageID: info.revert?.messageID,
revert_partID: info.revert?.partID,
revert_snapshot: info.revert?.snapshot,
revert_diff: info.revert?.diff,
permission: info.permission,
time_created: info.time.created,
time_updated: info.time.updated,
time_compacting: info.time.compacting,
time_archived: info.time.archived,
}
}
export const Info = z
.object({
id: Identifier.schema("session"),
@ -214,17 +283,7 @@ export namespace Session {
},
}
log.info("created", result)
db()
.insert(SessionTable)
.values({
id: result.id,
projectID: result.projectID,
parentID: result.parentID,
createdAt: result.time.created,
updatedAt: result.time.updated,
data: result,
})
.run()
db().insert(SessionTable).values(toRow(result)).run()
Bus.publish(Event.Created, {
info: result,
})
@ -255,7 +314,7 @@ export namespace Session {
export const get = fn(Identifier.schema("session"), async (id) => {
const row = db().select().from(SessionTable).where(eq(SessionTable.id, id)).get()
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
return row.data
return fromRow(row)
})
export const getShare = fn(Identifier.schema("session"), async (id) => {
@ -298,12 +357,12 @@ export namespace Session {
export function update(id: string, editor: (session: Info) => void, options?: { touch?: boolean }) {
const row = db().select().from(SessionTable).where(eq(SessionTable.id, id)).get()
if (!row) throw new Error(`Session not found: ${id}`)
const data = { ...row.data }
const data = fromRow(row)
editor(data)
if (options?.touch !== false) {
data.time.updated = Date.now()
}
db().update(SessionTable).set({ updatedAt: data.time.updated, data }).where(eq(SessionTable.id, id)).run()
db().update(SessionTable).set(toRow(data)).where(eq(SessionTable.id, id)).run()
Bus.publish(Event.Updated, {
info: data,
})
@ -335,13 +394,13 @@ export namespace Session {
const project = Instance.project
const rows = db().select().from(SessionTable).where(eq(SessionTable.projectID, project.id)).all()
for (const row of rows) {
yield row.data
yield fromRow(row)
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
const rows = db().select().from(SessionTable).where(eq(SessionTable.parentID, parentID)).all()
return rows.map((row) => row.data)
return rows.map((row) => fromRow(row))
})
export const remove = fn(Identifier.schema("session"), async (sessionID) => {

View file

@ -1,6 +1,5 @@
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { Session } from "./index"
import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "@/snapshot"
import type { Todo } from "./todo"
@ -14,9 +13,24 @@ export const SessionTable = sqliteTable(
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
parentID: text("parent_id"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
data: text("data", { mode: "json" }).notNull().$type<Session.Info>(),
slug: text("slug").notNull(),
directory: text("directory").notNull(),
title: text("title").notNull(),
version: text("version").notNull(),
share_url: text("share_url"),
summary_additions: integer("summary_additions"),
summary_deletions: integer("summary_deletions"),
summary_files: integer("summary_files"),
summary_diffs: text("summary_diffs", { mode: "json" }).$type<Snapshot.FileDiff[]>(),
revert_messageID: text("revert_message_id"),
revert_partID: text("revert_part_id"),
revert_snapshot: text("revert_snapshot"),
revert_diff: text("revert_diff"),
permission: text("permission", { mode: "json" }).$type<PermissionNext.Ruleset>(),
time_created: integer("time_created").notNull(),
time_updated: integer("time_updated").notNull(),
time_compacting: integer("time_compacting"),
time_archived: integer("time_archived"),
},
(table) => [index("session_project_idx").on(table.projectID), index("session_parent_idx").on(table.parentID)],
)

View file

@ -97,10 +97,25 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin
.values({
id: data.id,
projectID: data.projectID,
parentID: data.parentID,
createdAt: data.time?.created ?? Date.now(),
updatedAt: data.time?.updated ?? Date.now(),
data,
parentID: data.parentID ?? null,
slug: data.slug ?? "",
directory: data.directory ?? "",
title: data.title ?? "",
version: data.version ?? "",
share_url: data.share?.url ?? null,
summary_additions: data.summary?.additions ?? null,
summary_deletions: data.summary?.deletions ?? null,
summary_files: data.summary?.files ?? null,
summary_diffs: data.summary?.diffs ?? null,
revert_messageID: data.revert?.messageID ?? null,
revert_partID: data.revert?.partID ?? null,
revert_snapshot: data.revert?.snapshot ?? null,
revert_diff: data.revert?.diff ?? null,
permission: data.permission ?? null,
time_created: data.time?.created ?? Date.now(),
time_updated: data.time?.updated ?? Date.now(),
time_compacting: data.time?.compacting ?? null,
time_archived: data.time?.archived ?? null,
})
.onConflictDoNothing()
.run()

View file

@ -239,10 +239,10 @@ describe("JSON to SQLite migration", () => {
expect(stats?.sessions).toBe(1)
const db = drizzle(sqlite)
const row = db.select().from(SessionTable).where(eq(SessionTable.id, fixtures.session.id)).get()
expect(row?.data.id).toBe(fixtures.session.id)
expect(row?.id).toBe(fixtures.session.id)
expect(row?.projectID).toBe(fixtures.project.id)
expect(row?.createdAt).toBe(fixtures.session.time.created)
expect(row?.updatedAt).toBe(fixtures.session.time.updated)
expect(row?.time_created).toBe(fixtures.session.time.created)
expect(row?.time_updated).toBe(fixtures.session.time.updated)
})
test("migrates session with parentID", async () => {
@ -296,8 +296,8 @@ describe("JSON to SQLite migration", () => {
expect(stats?.sessions).toBe(1)
const db = drizzle(sqlite)
const row = db.select().from(SessionTable).where(eq(SessionTable.id, fixtures.session.id)).get()
expect(row?.createdAt).toBeGreaterThanOrEqual(before)
expect(row?.createdAt).toBeLessThanOrEqual(after)
expect(row?.time_created).toBeGreaterThanOrEqual(before)
expect(row?.time_created).toBeLessThanOrEqual(after)
})
test("skips session with missing required fields", async () => {