From 57edb0ddc5fe6e1bc64e7b318add274335af4771 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 26 Jan 2026 08:44:19 -0500 Subject: [PATCH] sync --- AGENTS.md | 26 ++- packages/opencode/src/cli/cmd/database.ts | 16 +- packages/opencode/src/cli/cmd/import.ts | 8 +- packages/opencode/src/permission/next.ts | 2 +- packages/opencode/src/project/project.sql.ts | 20 +- packages/opencode/src/project/project.ts | 4 +- .../opencode/src/server/routes/session.ts | 19 +- packages/opencode/src/session/index.ts | 211 +++++++++++++----- packages/opencode/src/session/message-v2.ts | 8 +- packages/opencode/src/session/prompt.ts | 29 +-- packages/opencode/src/session/revert.ts | 22 +- packages/opencode/src/session/session.sql.ts | 76 +++---- packages/opencode/src/session/summary.ts | 13 +- packages/opencode/src/session/todo.ts | 6 +- packages/opencode/src/share/share-next.ts | 8 +- packages/opencode/src/share/share.sql.ts | 8 +- packages/opencode/src/storage/db.ts | 87 ++++---- .../opencode/src/storage/json-migration.ts | 26 +-- .../test/storage/json-migration.test.ts | 2 +- 19 files changed, 340 insertions(+), 251 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6119e653e1..750aeff1de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,16 +19,16 @@ ### Naming -Prefer single word variable names. Only use multiple words if necessary. +Prefer single word names for variables and functions. Only use multiple words if necessary. ```ts // Good const foo = 1 -const bar = 2 +function journal(dir: string) {} // Bad const fooBar = 1 -const barBaz = 2 +function prepareJournal(dir: string) {} ``` Reduce total variable count by inlining when a value is only used once. @@ -87,6 +87,26 @@ function foo() { } ``` +### Schema Definitions (Drizzle) + +Use snake_case for field names so column names don't need to be redefined as strings. + +```ts +// Good +const table = sqliteTable("session", { + id: text().primaryKey(), + project_id: text().notNull(), + created_at: integer().notNull(), +}) + +// Bad +const table = sqliteTable("session", { + id: text("id").primaryKey(), + projectID: text("project_id").notNull(), + createdAt: integer("created_at").notNull(), +}) +``` + ## Testing - Avoid mocks as much as possible diff --git a/packages/opencode/src/cli/cmd/database.ts b/packages/opencode/src/cli/cmd/database.ts index 1fda74cf6b..44e7295053 100644 --- a/packages/opencode/src/cli/cmd/database.ts +++ b/packages/opencode/src/cli/cmd/database.ts @@ -65,7 +65,7 @@ const ExportCommand = cmd({ // Export sessions (organized by projectID) const sessionDir = path.join(outDir, "session") for (const row of Database.use((db) => db.select().from(SessionTable).all())) { - const dir = path.join(sessionDir, row.projectID) + const dir = path.join(sessionDir, row.project_id) await fs.mkdir(dir, { recursive: true }) await Bun.write(path.join(dir, `${row.id}.json`), JSON.stringify(Session.fromRow(row), null, 2)) stats.sessions++ @@ -74,7 +74,7 @@ const ExportCommand = cmd({ // Export messages (organized by sessionID) const messageDir = path.join(outDir, "message") for (const row of Database.use((db) => db.select().from(MessageTable).all())) { - const dir = path.join(messageDir, row.sessionID) + const dir = path.join(messageDir, row.session_id) await fs.mkdir(dir, { recursive: true }) await Bun.write(path.join(dir, `${row.id}.json`), JSON.stringify(row.data, null, 2)) stats.messages++ @@ -83,7 +83,7 @@ const ExportCommand = cmd({ // Export parts (organized by messageID) const partDir = path.join(outDir, "part") for (const row of Database.use((db) => db.select().from(PartTable).all())) { - const dir = path.join(partDir, row.messageID) + const dir = path.join(partDir, row.message_id) await fs.mkdir(dir, { recursive: true }) await Bun.write(path.join(dir, `${row.id}.json`), JSON.stringify(row.data, null, 2)) stats.parts++ @@ -93,7 +93,7 @@ const ExportCommand = cmd({ const diffDir = path.join(outDir, "session_diff") await fs.mkdir(diffDir, { recursive: true }) for (const row of Database.use((db) => db.select().from(SessionDiffTable).all())) { - await Bun.write(path.join(diffDir, `${row.sessionID}.json`), JSON.stringify(row.data, null, 2)) + await Bun.write(path.join(diffDir, `${row.session_id}.json`), JSON.stringify(row.data, null, 2)) stats.diffs++ } @@ -101,7 +101,7 @@ const ExportCommand = cmd({ const todoDir = path.join(outDir, "todo") await fs.mkdir(todoDir, { recursive: true }) for (const row of Database.use((db) => db.select().from(TodoTable).all())) { - await Bun.write(path.join(todoDir, `${row.sessionID}.json`), JSON.stringify(row.data, null, 2)) + await Bun.write(path.join(todoDir, `${row.session_id}.json`), JSON.stringify(row.data, null, 2)) stats.todos++ } @@ -109,7 +109,7 @@ const ExportCommand = cmd({ const permDir = path.join(outDir, "permission") await fs.mkdir(permDir, { recursive: true }) for (const row of Database.use((db) => db.select().from(PermissionTable).all())) { - await Bun.write(path.join(permDir, `${row.projectID}.json`), JSON.stringify(row.data, null, 2)) + await Bun.write(path.join(permDir, `${row.project_id}.json`), JSON.stringify(row.data, null, 2)) stats.permissions++ } @@ -117,7 +117,7 @@ const ExportCommand = cmd({ const sessionShareDir = path.join(outDir, "session_share") await fs.mkdir(sessionShareDir, { recursive: true }) for (const row of Database.use((db) => db.select().from(SessionShareTable).all())) { - await Bun.write(path.join(sessionShareDir, `${row.sessionID}.json`), JSON.stringify(row.data, null, 2)) + await Bun.write(path.join(sessionShareDir, `${row.session_id}.json`), JSON.stringify(row.data, null, 2)) stats.sessionShares++ } @@ -125,7 +125,7 @@ const ExportCommand = cmd({ const shareDir = path.join(outDir, "share") await fs.mkdir(shareDir, { recursive: true }) for (const row of Database.use((db) => db.select().from(ShareTable).all())) { - await Bun.write(path.join(shareDir, `${row.sessionID}.json`), JSON.stringify(row.data, null, 2)) + await Bun.write(path.join(shareDir, `${row.session_id}.json`), JSON.stringify(row.data, null, 2)) stats.shares++ } diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 7f97b70c31..5c0d9bdad5 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -90,8 +90,8 @@ export const ImportCommand = cmd({ .insert(MessageTable) .values({ id: msg.info.id, - sessionID: exportData.info.id, - createdAt: msg.info.time?.created ?? Date.now(), + session_id: exportData.info.id, + created_at: msg.info.time?.created ?? Date.now(), data: msg.info, }) .onConflictDoNothing() @@ -104,8 +104,8 @@ export const ImportCommand = cmd({ .insert(PartTable) .values({ id: part.id, - messageID: msg.info.id, - sessionID: exportData.info.id, + message_id: msg.info.id, + session_id: exportData.info.id, data: part, }) .onConflictDoNothing() diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 98840867b2..1e1df62a3c 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -109,7 +109,7 @@ export namespace PermissionNext { const state = Instance.state(() => { const projectID = Instance.project.id const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.projectID, projectID)).get(), + db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(), ) const stored = row?.data ?? ([] as Ruleset) diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts index 651d537cf2..0f5a856e51 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/opencode/src/project/project.sql.ts @@ -1,14 +1,14 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" export const ProjectTable = sqliteTable("project", { - id: text("id").primaryKey(), - worktree: text("worktree").notNull(), - vcs: text("vcs"), - name: text("name"), - icon_url: text("icon_url"), - icon_color: text("icon_color"), - time_created: integer("time_created").notNull(), - time_updated: integer("time_updated").notNull(), - time_initialized: integer("time_initialized"), - sandboxes: text("sandboxes", { mode: "json" }).notNull().$type(), + id: text().primaryKey(), + worktree: text().notNull(), + vcs: text(), + name: text(), + icon_url: text(), + icon_color: text(), + time_created: integer().notNull(), + time_updated: integer().notNull(), + time_initialized: integer(), + sandboxes: text({ mode: "json" }).notNull().$type(), }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index fc940b9588..715b4f3230 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -299,7 +299,7 @@ export namespace Project { if (!globalRow) return const globalSessions = Database.use((db) => - db.select().from(SessionTable).where(eq(SessionTable.projectID, "global")).all(), + db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(), ) if (globalSessions.length === 0) return @@ -311,7 +311,7 @@ export namespace Project { log.info("migrating session", { sessionID: row.id, from: "global", to: newProjectID }) Database.use((db) => - db.update(SessionTable).set({ projectID: newProjectID }).where(eq(SessionTable.id, row.id)).run(), + db.update(SessionTable).set({ project_id: newProjectID }).where(eq(SessionTable.id, row.id)).run(), ) }).catch((error) => { log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID }) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 3850376bdb..de1ebffe70 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -276,18 +276,15 @@ export const SessionRoutes = lazy(() => const sessionID = c.req.valid("param").sessionID const updates = c.req.valid("json") - const updatedSession = await Session.update( - sessionID, - (session) => { - if (updates.title !== undefined) { - session.title = updates.title - } - if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived - }, - { touch: false }, - ) + let session = await Session.get(sessionID) + if (updates.title !== undefined) { + session = await Session.setTitle({ sessionID, title: updates.title }) + } + if (updates.time?.archived !== undefined) { + session = await Session.setArchived({ sessionID, time: updates.time.archived }) + } - return c.json(updatedSession) + return c.json(session) }, ) .post( diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index db06483eea..5ad143c93e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -55,10 +55,10 @@ export namespace Session { : undefined const share = row.share_url ? { url: row.share_url } : undefined const revert = - row.revert_messageID !== null + row.revert_message_id !== null ? { - messageID: row.revert_messageID, - partID: row.revert_partID ?? undefined, + messageID: row.revert_message_id, + partID: row.revert_part_id ?? undefined, snapshot: row.revert_snapshot ?? undefined, diff: row.revert_diff ?? undefined, } @@ -66,9 +66,9 @@ export namespace Session { return { id: row.id, slug: row.slug, - projectID: row.projectID, + projectID: row.project_id, directory: row.directory, - parentID: row.parentID ?? undefined, + parentID: row.parent_id ?? undefined, title: row.title, version: row.version, summary, @@ -87,8 +87,8 @@ export namespace Session { export function toRow(info: Info) { return { id: info.id, - projectID: info.projectID, - parentID: info.parentID, + project_id: info.projectID, + parent_id: info.parentID, slug: info.slug, directory: info.directory, title: info.title, @@ -98,8 +98,8 @@ export namespace Session { summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, summary_diffs: info.summary?.diffs, - revert_messageID: info.revert?.messageID ?? null, - revert_partID: info.revert?.partID ?? null, + revert_message_id: info.revert?.messageID ?? null, + revert_part_id: info.revert?.partID ?? null, revert_snapshot: info.revert?.snapshot ?? null, revert_diff: info.revert?.diff ?? null, permission: info.permission, @@ -255,9 +255,10 @@ export namespace Session { ) export const touch = fn(Identifier.schema("session"), async (sessionID) => { - await update(sessionID, (draft) => { - draft.time.updated = Date.now() - }) + const now = Date.now() + Database.use((db) => db.update(SessionTable).set({ time_updated: now }).where(eq(SessionTable.id, sessionID)).run()) + const info = await get(sessionID) + Bus.publish(Event.Updated, { info }) }) export async function createNext(input: { @@ -288,15 +289,9 @@ export namespace Session { }) const cfg = await Config.get() if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) - share(result.id) - .then((share) => { - update(result.id, (draft) => { - draft.share = share - }) - }) - .catch(() => { - // Silently ignore sharing errors during session creation - }) + share(result.id).catch(() => { + // Silently ignore sharing errors during session creation + }) Bus.publish(Event.Updated, { info: result, }) @@ -317,7 +312,7 @@ export namespace Session { }) export const getShare = fn(Identifier.schema("session"), async (id) => { - const row = Database.use((db) => db.select().from(ShareTable).where(eq(ShareTable.sessionID, id)).get()) + const row = Database.use((db) => db.select().from(ShareTable).where(eq(ShareTable.session_id, id)).get()) return row?.data }) @@ -328,15 +323,9 @@ export namespace Session { } const { ShareNext } = await import("@/share/share-next") const share = await ShareNext.create(id) - await update( - id, - (draft) => { - draft.share = { - url: share.url, - } - }, - { touch: false }, - ) + Database.use((db) => db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).run()) + const info = await get(id) + Bus.publish(Event.Updated, { info }) return share }) @@ -344,33 +333,135 @@ export namespace Session { // Use ShareNext to remove the share (same as share function uses ShareNext to create) const { ShareNext } = await import("@/share/share-next") await ShareNext.remove(id) - await update( - id, - (draft) => { - draft.share = undefined - }, - { touch: false }, - ) + Database.use((db) => db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).run()) + const info = await get(id) + Bus.publish(Event.Updated, { info }) }) - export function update(id: string, editor: (session: Info) => void, options?: { touch?: boolean }) { - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - if (!row) throw new Error(`Session not found: ${id}`) - const data = fromRow(row) - editor(data) - if (options?.touch !== false) { - data.time.updated = Date.now() - } - Database.use((db) => db.update(SessionTable).set(toRow(data)).where(eq(SessionTable.id, id)).run()) - Bus.publish(Event.Updated, { - info: data, - }) - return data - } + export const setTitle = fn( + z.object({ + sessionID: Identifier.schema("session"), + title: z.string(), + }), + async (input) => { + Database.use((db) => + db.update(SessionTable).set({ title: input.title }).where(eq(SessionTable.id, input.sessionID)).run(), + ) + const info = await get(input.sessionID) + Bus.publish(Event.Updated, { info }) + return info + }, + ) + + export const setArchived = fn( + z.object({ + sessionID: Identifier.schema("session"), + time: z.number().optional(), + }), + async (input) => { + Database.use((db) => + db.update(SessionTable).set({ time_archived: input.time }).where(eq(SessionTable.id, input.sessionID)).run(), + ) + const info = await get(input.sessionID) + Bus.publish(Event.Updated, { info }) + return info + }, + ) + + export const setPermission = fn( + z.object({ + sessionID: Identifier.schema("session"), + permission: PermissionNext.Ruleset, + }), + async (input) => { + Database.use((db) => + db + .update(SessionTable) + .set({ permission: input.permission, time_updated: Date.now() }) + .where(eq(SessionTable.id, input.sessionID)) + .run(), + ) + const info = await get(input.sessionID) + Bus.publish(Event.Updated, { info }) + return info + }, + ) + + export const setRevert = fn( + z.object({ + sessionID: Identifier.schema("session"), + revert: Info.shape.revert, + summary: Info.shape.summary, + }), + async (input) => { + Database.use((db) => + db + .update(SessionTable) + .set({ + revert_message_id: input.revert?.messageID ?? null, + revert_part_id: input.revert?.partID ?? null, + revert_snapshot: input.revert?.snapshot ?? null, + revert_diff: input.revert?.diff ?? null, + summary_additions: input.summary?.additions, + summary_deletions: input.summary?.deletions, + summary_files: input.summary?.files, + time_updated: Date.now(), + }) + .where(eq(SessionTable.id, input.sessionID)) + .run(), + ) + const info = await get(input.sessionID) + Bus.publish(Event.Updated, { info }) + return info + }, + ) + + export const clearRevert = fn(Identifier.schema("session"), async (sessionID) => { + Database.use((db) => + db + .update(SessionTable) + .set({ + revert_message_id: null, + revert_part_id: null, + revert_snapshot: null, + revert_diff: null, + time_updated: Date.now(), + }) + .where(eq(SessionTable.id, sessionID)) + .run(), + ) + const info = await get(sessionID) + Bus.publish(Event.Updated, { info }) + return info + }) + + export const setSummary = fn( + z.object({ + sessionID: Identifier.schema("session"), + summary: Info.shape.summary, + }), + async (input) => { + Database.use((db) => + db + .update(SessionTable) + .set({ + summary_additions: input.summary?.additions, + summary_deletions: input.summary?.deletions, + summary_files: input.summary?.files, + time_updated: Date.now(), + }) + .where(eq(SessionTable.id, input.sessionID)) + .run(), + ) + const info = await get(input.sessionID) + Bus.publish(Event.Updated, { info }) + return info + }, + ) export const diff = fn(Identifier.schema("session"), async (sessionID) => { const row = Database.use((db) => - db.select().from(SessionDiffTable).where(eq(SessionDiffTable.sessionID, sessionID)).get(), + db.select().from(SessionDiffTable).where(eq(SessionDiffTable.session_id, sessionID)).get(), ) return row?.data ?? [] }) @@ -394,7 +485,7 @@ export namespace Session { export function* list() { const project = Instance.project const rows = Database.use((db) => - db.select().from(SessionTable).where(eq(SessionTable.projectID, project.id)).all(), + db.select().from(SessionTable).where(eq(SessionTable.project_id, project.id)).all(), ) for (const row of rows) { yield fromRow(row) @@ -402,7 +493,7 @@ export namespace Session { } export const children = fn(Identifier.schema("session"), async (parentID) => { - const rows = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.parentID, parentID)).all()) + const rows = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.parent_id, parentID)).all()) return rows.map((row) => fromRow(row)) }) @@ -425,14 +516,14 @@ export namespace Session { }) export const updateMessage = fn(MessageV2.Info, async (msg) => { - const createdAt = msg.role === "user" ? msg.time.created : msg.time.created + const created_at = msg.role === "user" ? msg.time.created : msg.time.created Database.use((db) => db .insert(MessageTable) .values({ id: msg.id, - sessionID: msg.sessionID, - createdAt, + session_id: msg.sessionID, + created_at, data: msg, }) .onConflictDoUpdate({ target: MessageTable.id, set: { data: msg } }) @@ -497,8 +588,8 @@ export namespace Session { .insert(PartTable) .values({ id: part.id, - messageID: part.messageID, - sessionID: part.sessionID, + message_id: part.messageID, + session_id: part.sessionID, data: part, }) .onConflictDoUpdate({ target: PartTable.id, set: { data: part } }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e92252400c..6e38edbad6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -612,8 +612,8 @@ export namespace MessageV2 { db .select() .from(MessageTable) - .where(eq(MessageTable.sessionID, sessionID)) - .orderBy(desc(MessageTable.createdAt)) + .where(eq(MessageTable.session_id, sessionID)) + .orderBy(desc(MessageTable.created_at)) .all(), ) for (const row of rows) { @@ -624,8 +624,8 @@ export namespace MessageV2 { } }) - export const parts = fn(Identifier.schema("message"), async (messageID) => { - const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.messageID, messageID)).all()) + export const parts = fn(Identifier.schema("message"), async (message_id) => { + const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).all()) const result = rows.map((row) => row.data) result.sort((a, b) => (a.id > b.id ? 1 : -1)) return result diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8554b44a72..fc10d7c780 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -167,9 +167,7 @@ export namespace SessionPrompt { } if (permissions.length > 0) { session.permission = permissions - await Session.update(session.id, (draft) => { - draft.permission = permissions - }) + await Session.setPermission({ sessionID: session.id, permission: permissions }) } if (input.noReply === true) { @@ -1795,21 +1793,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the ], }) const text = await result.text.catch((err) => log.error("failed to generate title", { error: err })) - if (text) - return Session.update( - input.session.id, - (draft) => { - const cleaned = text - .replace(/[\s\S]*?<\/think>\s*/g, "") - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) - if (!cleaned) return + if (text) { + const cleaned = text + .replace(/[\s\S]*?<\/think>\s*/g, "") + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + if (!cleaned) return - const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - draft.title = title - }, - { touch: false }, - ) + const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned + return Session.setTitle({ sessionID: input.session.id, title }) + } } } diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 6a27fc7b9a..a4356dc70a 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -64,21 +64,22 @@ export namespace SessionRevert { Database.use((db) => db .insert(SessionDiffTable) - .values({ sessionID: input.sessionID, data: diffs }) - .onConflictDoUpdate({ target: SessionDiffTable.sessionID, set: { data: diffs } }) + .values({ session_id: input.sessionID, data: diffs }) + .onConflictDoUpdate({ target: SessionDiffTable.session_id, set: { data: diffs } }) .run(), ) Bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs, }) - return Session.update(input.sessionID, (draft) => { - draft.revert = revert - draft.summary = { + return Session.setRevert({ + sessionID: input.sessionID, + revert, + summary: { additions: diffs.reduce((sum, x) => sum + x.additions, 0), deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), files: diffs.length, - } + }, }) } return session @@ -90,10 +91,7 @@ export namespace SessionRevert { const session = await Session.get(input.sessionID) if (!session.revert) return session if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot) - const next = await Session.update(input.sessionID, (draft) => { - draft.revert = undefined - }) - return next + return Session.clearRevert(input.sessionID) } export async function cleanup(session: Session.Info) { @@ -121,8 +119,6 @@ export namespace SessionRevert { }) } } - await Session.update(sessionID, (draft) => { - draft.revert = undefined - }) + await Session.clearRevert(sessionID) } } diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index be35dd1703..7d52d87a4b 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -8,76 +8,76 @@ import type { PermissionNext } from "@/permission/next" export const SessionTable = sqliteTable( "session", { - id: text("id").primaryKey(), - projectID: text("project_id") + id: text().primaryKey(), + project_id: text() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), - parentID: text("parent_id"), - 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(), - 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(), - time_created: integer("time_created").notNull(), - time_updated: integer("time_updated").notNull(), - time_compacting: integer("time_compacting"), - time_archived: integer("time_archived"), + parent_id: text(), + slug: text().notNull(), + directory: text().notNull(), + title: text().notNull(), + version: text().notNull(), + share_url: text(), + summary_additions: integer(), + summary_deletions: integer(), + summary_files: integer(), + summary_diffs: text({ mode: "json" }).$type(), + revert_message_id: text(), + revert_part_id: text(), + revert_snapshot: text(), + revert_diff: text(), + permission: text({ mode: "json" }).$type(), + time_created: integer().notNull(), + time_updated: integer().notNull(), + time_compacting: integer(), + time_archived: integer(), }, - (table) => [index("session_project_idx").on(table.projectID), index("session_parent_idx").on(table.parentID)], + (table) => [index("session_project_idx").on(table.project_id), index("session_parent_idx").on(table.parent_id)], ) export const MessageTable = sqliteTable( "message", { - id: text("id").primaryKey(), - sessionID: text("session_id") + id: text().primaryKey(), + session_id: text() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - createdAt: integer("created_at").notNull(), - data: text("data", { mode: "json" }).notNull().$type(), + created_at: integer().notNull(), + data: text({ mode: "json" }).notNull().$type(), }, - (table) => [index("message_session_idx").on(table.sessionID)], + (table) => [index("message_session_idx").on(table.session_id)], ) export const PartTable = sqliteTable( "part", { - id: text("id").primaryKey(), - messageID: text("message_id") + id: text().primaryKey(), + message_id: text() .notNull() .references(() => MessageTable.id, { onDelete: "cascade" }), - sessionID: text("session_id").notNull(), - data: text("data", { mode: "json" }).notNull().$type(), + session_id: text().notNull(), + data: text({ mode: "json" }).notNull().$type(), }, - (table) => [index("part_message_idx").on(table.messageID), index("part_session_idx").on(table.sessionID)], + (table) => [index("part_message_idx").on(table.message_id), index("part_session_idx").on(table.session_id)], ) export const SessionDiffTable = sqliteTable("session_diff", { - sessionID: text("session_id") + session_id: text() .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), - data: text("data", { mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }) export const TodoTable = sqliteTable("todo", { - sessionID: text("session_id") + session_id: text() .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), - data: text("data", { mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }) export const PermissionTable = sqliteTable("permission", { - projectID: text("project_id") + project_id: text() .primaryKey() .references(() => ProjectTable.id, { onDelete: "cascade" }), - data: text("data", { mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 2472eee7a3..0183a9d3df 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -48,18 +48,19 @@ export namespace SessionSummary { return files.has(x.file) }), ) - await Session.update(input.sessionID, (draft) => { - draft.summary = { + await Session.setSummary({ + sessionID: input.sessionID, + summary: { additions: diffs.reduce((sum, x) => sum + x.additions, 0), deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), files: diffs.length, - } + }, }) Database.use((db) => db .insert(SessionDiffTable) - .values({ sessionID: input.sessionID, data: diffs }) - .onConflictDoUpdate({ target: SessionDiffTable.sessionID, set: { data: diffs } }) + .values({ session_id: input.sessionID, data: diffs }) + .onConflictDoUpdate({ target: SessionDiffTable.session_id, set: { data: diffs } }) .run(), ) Bus.publish(Session.Event.Diff, { @@ -124,7 +125,7 @@ export namespace SessionSummary { }), async (input) => { const row = Database.use((db) => - db.select().from(SessionDiffTable).where(eq(SessionDiffTable.sessionID, input.sessionID)).get(), + db.select().from(SessionDiffTable).where(eq(SessionDiffTable.session_id, input.sessionID)).get(), ) return row?.data ?? [] }, diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 8ba5a0281c..03bbcc148e 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -29,15 +29,15 @@ export namespace Todo { Database.use((db) => db .insert(TodoTable) - .values({ sessionID: input.sessionID, data: input.todos }) - .onConflictDoUpdate({ target: TodoTable.sessionID, set: { data: input.todos } }) + .values({ session_id: input.sessionID, data: input.todos }) + .onConflictDoUpdate({ target: TodoTable.session_id, set: { data: input.todos } }) .run(), ) Bus.publish(Event.Updated, input) } export function get(sessionID: string) { - const row = Database.use((db) => db.select().from(TodoTable).where(eq(TodoTable.sessionID, sessionID)).get()) + const row = Database.use((db) => db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).get()) return row?.data ?? [] } } diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 0f18cb974d..0cf978930e 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -81,8 +81,8 @@ export namespace ShareNext { Database.use((db) => db .insert(SessionShareTable) - .values({ sessionID, data: result }) - .onConflictDoUpdate({ target: SessionShareTable.sessionID, set: { data: result } }) + .values({ session_id: sessionID, data: result }) + .onConflictDoUpdate({ target: SessionShareTable.session_id, set: { data: result } }) .run(), ) fullSync(sessionID) @@ -91,7 +91,7 @@ export namespace ShareNext { function get(sessionID: string) { const row = Database.use((db) => - db.select().from(SessionShareTable).where(eq(SessionShareTable.sessionID, sessionID)).get(), + db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(), ) return row?.data } @@ -169,7 +169,7 @@ export namespace ShareNext { secret: share.secret, }), }) - Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.sessionID, sessionID)).run()) + Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) } async function fullSync(sessionID: string) { diff --git a/packages/opencode/src/share/share.sql.ts b/packages/opencode/src/share/share.sql.ts index 7a65fd764b..bf8a190461 100644 --- a/packages/opencode/src/share/share.sql.ts +++ b/packages/opencode/src/share/share.sql.ts @@ -3,10 +3,10 @@ import { SessionTable } from "../session/session.sql" import type { Session } from "../session" export const SessionShareTable = sqliteTable("session_share", { - sessionID: text("session_id") + session_id: text() .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), - data: text("data", { mode: "json" }).notNull().$type<{ + data: text({ mode: "json" }).notNull().$type<{ id: string secret: string url: string @@ -14,6 +14,6 @@ export const SessionShareTable = sqliteTable("session_share", { }) export const ShareTable = sqliteTable("share", { - sessionID: text("session_id").primaryKey(), - data: text("data", { mode: "json" }).notNull().$type(), + session_id: text().primaryKey(), + data: text({ mode: "json" }).notNull().$type(), }) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 824bc85658..1f6cc30807 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -1,6 +1,6 @@ import { Database as BunDatabase } from "bun:sqlite" import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" -import { migrate as drizzleMigrate } from "drizzle-orm/bun-sqlite/migrator" +import { migrate } from "drizzle-orm/bun-sqlite/migrator" import type { SQLiteTransaction } from "drizzle-orm/sqlite-core" export * from "drizzle-orm" import { Context } from "../util/context" @@ -29,6 +29,22 @@ export namespace Database { type Client = SQLiteBunDatabase + type Journal = { sql: string; timestamp: number }[] + + function journal(dir: string): Journal { + const file = path.join(dir, "meta/_journal.json") + if (!Bun.file(file).size) return [] + + const data = JSON.parse(readFileSync(file, "utf-8")) as { + entries: { tag: string; when: number }[] + } + + return data.entries.map((entry) => ({ + sql: readFileSync(path.join(dir, `${entry.tag}.sql`), "utf-8"), + timestamp: entry.when, + })) + } + const client = lazy(() => { log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") }) @@ -41,11 +57,22 @@ export namespace Database { sqlite.run("PRAGMA foreign_keys = ON") const db = drizzle({ client: sqlite }) - migrate(db) + + // Apply schema migrations + const entries = + typeof OPENCODE_MIGRATIONS !== "undefined" + ? OPENCODE_MIGRATIONS + : journal(path.join(import.meta.dirname, "../../migration")) + if (entries.length > 0) { + log.info("applying migrations", { + count: entries.length, + mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", + }) + migrate(db, entries) + } // Run json migration if not already done - const marker = sqlite.prepare("SELECT 1 FROM __drizzle_migrations WHERE hash = 'json-migration'").get() - if (!marker) { + if (!sqlite.prepare("SELECT 1 FROM __drizzle_migrations WHERE hash = 'json-migration'").get()) { Bun.file(path.join(Global.Path.data, "storage/project")) .exists() .then((exists) => { @@ -62,19 +89,18 @@ export namespace Database { export type TxOrDb = Transaction | Client - const TransactionContext = Context.create<{ + const ctx = Context.create<{ tx: TxOrDb effects: (() => void | Promise)[] }>("database") export function use(callback: (trx: TxOrDb) => T): T { try { - const { tx } = TransactionContext.use() - return callback(tx) + return callback(ctx.use().tx) } catch (err) { if (err instanceof Context.NotFound) { const effects: (() => void | Promise)[] = [] - const result = TransactionContext.provide({ effects, tx: client() }, () => callback(client())) + const result = ctx.provide({ effects, tx: client() }, () => callback(client())) for (const effect of effects) effect() return result } @@ -82,24 +108,22 @@ export namespace Database { } } - export function effect(effect: () => void | Promise) { + export function effect(fn: () => void | Promise) { try { - const { effects } = TransactionContext.use() - effects.push(effect) + ctx.use().effects.push(fn) } catch { - effect() + fn() } } export function transaction(callback: (tx: TxOrDb) => T): T { try { - const { tx } = TransactionContext.use() - return callback(tx) + return callback(ctx.use().tx) } catch (err) { if (err instanceof Context.NotFound) { const effects: (() => void | Promise)[] = [] const result = client().transaction((tx) => { - return TransactionContext.provide({ tx, effects }, () => callback(tx)) + return ctx.provide({ tx, effects }, () => callback(tx)) }) for (const effect of effects) effect() return result @@ -108,36 +132,3 @@ export namespace Database { } } } - -type MigrationsJournal = { sql: string; timestamp: number }[] - -function prepareJournal(dir: string): MigrationsJournal { - const file = path.join(dir, "meta/_journal.json") - if (!Bun.file(file).size) return [] - - const journal = JSON.parse(readFileSync(file, "utf-8")) as { - entries: { tag: string; when: number }[] - } - - return journal.entries.map((entry) => ({ - sql: readFileSync(path.join(dir, `${entry.tag}.sql`), "utf-8"), - timestamp: entry.when, - })) -} - -function migrate(db: SQLiteBunDatabase) { - const journal = - typeof OPENCODE_MIGRATIONS !== "undefined" - ? OPENCODE_MIGRATIONS - : prepareJournal(path.join(import.meta.dirname, "../../migration")) - - if (journal.length === 0) { - log.info("no migrations found") - return - } - log.info("applying migrations", { - count: journal.length, - mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", - }) - drizzleMigrate(db, journal) -} diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index df15ff6df0..4936387fd7 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -84,8 +84,8 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin db.insert(SessionTable) .values({ id: data.id, - projectID: data.projectID, - parentID: data.parentID ?? null, + project_id: data.projectID, + parent_id: data.parentID ?? null, slug: data.slug ?? "", directory: data.directory ?? "", title: data.title ?? "", @@ -95,8 +95,8 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin 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_message_id: data.revert?.messageID ?? null, + revert_part_id: data.revert?.partID ?? null, revert_snapshot: data.revert?.snapshot ?? null, revert_diff: data.revert?.diff ?? null, permission: data.permission ?? null, @@ -132,8 +132,8 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin db.insert(MessageTable) .values({ id: data.id, - sessionID: data.sessionID, - createdAt: data.time?.created ?? Date.now(), + session_id: data.sessionID, + created_at: data.time?.created ?? Date.now(), data, }) .onConflictDoNothing() @@ -163,8 +163,8 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin db.insert(PartTable) .values({ id: data.id, - messageID: data.messageID, - sessionID: data.sessionID, + message_id: data.messageID, + session_id: data.sessionID, data, }) .onConflictDoNothing() @@ -188,7 +188,7 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin log.warn("skipping orphaned session_diff", { sessionID }) continue } - db.insert(SessionDiffTable).values({ sessionID, data }).onConflictDoNothing().run() + db.insert(SessionDiffTable).values({ session_id: sessionID, data }).onConflictDoNothing().run() stats.diffs++ } catch (e) { stats.errors.push(`failed to migrate session_diff ${file}: ${e}`) @@ -207,7 +207,7 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin log.warn("skipping orphaned todo", { sessionID }) continue } - db.insert(TodoTable).values({ sessionID, data }).onConflictDoNothing().run() + db.insert(TodoTable).values({ session_id: sessionID, data }).onConflictDoNothing().run() stats.todos++ } catch (e) { stats.errors.push(`failed to migrate todo ${file}: ${e}`) @@ -226,7 +226,7 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin log.warn("skipping orphaned permission", { projectID }) continue } - db.insert(PermissionTable).values({ projectID, data }).onConflictDoNothing().run() + db.insert(PermissionTable).values({ project_id: projectID, data }).onConflictDoNothing().run() stats.permissions++ } catch (e) { stats.errors.push(`failed to migrate permission ${file}: ${e}`) @@ -245,7 +245,7 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin log.warn("skipping orphaned session_share", { sessionID }) continue } - db.insert(SessionShareTable).values({ sessionID, data }).onConflictDoNothing().run() + db.insert(SessionShareTable).values({ session_id: sessionID, data }).onConflictDoNothing().run() stats.shares++ } catch (e) { stats.errors.push(`failed to migrate session_share ${file}: ${e}`) @@ -259,7 +259,7 @@ export async function migrateFromJson(sqlite: Database, customStorageDir?: strin try { const data = await Bun.file(file).json() const sessionID = path.basename(file, ".json") - db.insert(ShareTable).values({ sessionID, data }).onConflictDoNothing().run() + db.insert(ShareTable).values({ session_id: sessionID, data }).onConflictDoNothing().run() } catch (e) { stats.errors.push(`failed to migrate share ${file}: ${e}`) } diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index 9be299bac3..b9981858ba 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -167,7 +167,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe("ses_test456def") - expect(sessions[0].projectID).toBe("proj_test123abc") + expect(sessions[0].project_id).toBe("proj_test123abc") expect(sessions[0].slug).toBe("test-session") expect(sessions[0].title).toBe("Test Session Title") expect(sessions[0].summary_additions).toBe(10)