This commit is contained in:
Dax Raad 2026-01-26 08:44:19 -05:00
parent a614b78c6d
commit 57edb0ddc5
19 changed files with 340 additions and 251 deletions

View file

@ -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

View file

@ -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++
}

View file

@ -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()

View file

@ -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)

View file

@ -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<string[]>(),
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<string[]>(),
})

View file

@ -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 })

View file

@ -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(

View file

@ -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 } })

View file

@ -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

View file

@ -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(/<think>[\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(/<think>[\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 })
}
}
}

View file

@ -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)
}
}

View file

@ -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<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"),
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<Snapshot.FileDiff[]>(),
revert_message_id: text(),
revert_part_id: text(),
revert_snapshot: text(),
revert_diff: text(),
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
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<MessageV2.Info>(),
created_at: integer().notNull(),
data: text({ mode: "json" }).notNull().$type<MessageV2.Info>(),
},
(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<MessageV2.Part>(),
session_id: text().notNull(),
data: text({ mode: "json" }).notNull().$type<MessageV2.Part>(),
},
(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<Snapshot.FileDiff[]>(),
data: text({ mode: "json" }).notNull().$type<Snapshot.FileDiff[]>(),
})
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<Todo.Info[]>(),
data: text({ mode: "json" }).notNull().$type<Todo.Info[]>(),
})
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<PermissionNext.Ruleset>(),
data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
})

View file

@ -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 ?? []
},

View file

@ -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 ?? []
}
}

View file

@ -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) {

View file

@ -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.ShareInfo>(),
session_id: text().primaryKey(),
data: text({ mode: "json" }).notNull().$type<Session.ShareInfo>(),
})

View file

@ -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<void>)[]
}>("database")
export function use<T>(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<void>)[] = []
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<void>) {
export function effect(fn: () => void | Promise<void>) {
try {
const { effects } = TransactionContext.use()
effects.push(effect)
ctx.use().effects.push(fn)
} catch {
effect()
fn()
}
}
export function transaction<T>(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<void>)[] = []
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)
}

View file

@ -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}`)
}

View file

@ -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)