summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax <[email protected]>2026-02-13 23:19:02 -0500
committerGitHub <[email protected]>2026-02-14 04:19:02 +0000
commit6d95f0d14cbd83fc8b7775f77ba39ab2881008f3 (patch)
tree6359d8e64ef6322c0e750fb7730e0b52438ebdaf /packages
parentd018903887861c64ec7ee037e60b24a61501c9c6 (diff)
downloadopencode-6d95f0d14cbd83fc8b7775f77ba39ab2881008f3.tar.gz
opencode-6d95f0d14cbd83fc8b7775f77ba39ab2881008f3.zip
sqlite again (#10597)
Co-authored-by: Github Action <[email protected]> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Brendan Allan <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts18
-rw-r--r--packages/console/core/package.json4
-rw-r--r--packages/console/core/src/drizzle/index.ts5
-rw-r--r--packages/opencode/AGENTS.md33
-rw-r--r--packages/opencode/drizzle.config.ts10
-rw-r--r--packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql90
-rw-r--r--packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json796
-rw-r--r--packages/opencode/migration/20260211171708_add_project_commands/migration.sql1
-rw-r--r--packages/opencode/migration/20260211171708_add_project_commands/snapshot.json847
-rw-r--r--packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql11
-rw-r--r--packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json941
-rw-r--r--packages/opencode/package.json9
-rwxr-xr-xpackages/opencode/script/build.ts27
-rw-r--r--packages/opencode/script/check-migrations.ts16
-rw-r--r--packages/opencode/src/acp/agent.ts90
-rw-r--r--packages/opencode/src/cli/cmd/import.ts31
-rw-r--r--packages/opencode/src/cli/cmd/stats.ts24
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx18
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx4
-rw-r--r--packages/opencode/src/config/config.ts24
-rw-r--r--packages/opencode/src/control/control.sql.ts22
-rw-r--r--packages/opencode/src/control/index.ts67
-rw-r--r--packages/opencode/src/index.ts41
-rw-r--r--packages/opencode/src/permission/next.ts16
-rw-r--r--packages/opencode/src/project/bootstrap.ts2
-rw-r--r--packages/opencode/src/project/project.sql.ts15
-rw-r--r--packages/opencode/src/project/project.ts261
-rw-r--r--packages/opencode/src/server/error.ts4
-rw-r--r--packages/opencode/src/server/routes/pty.ts4
-rw-r--r--packages/opencode/src/server/routes/session.ts19
-rw-r--r--packages/opencode/src/server/server.ts4
-rw-r--r--packages/opencode/src/session/index.ts451
-rw-r--r--packages/opencode/src/session/message-v2.ts90
-rw-r--r--packages/opencode/src/session/processor.ts27
-rw-r--r--packages/opencode/src/session/prompt.ts31
-rw-r--r--packages/opencode/src/session/revert.ts73
-rw-r--r--packages/opencode/src/session/session.sql.ts88
-rw-r--r--packages/opencode/src/session/summary.ts7
-rw-r--r--packages/opencode/src/session/todo.ts35
-rw-r--r--packages/opencode/src/share/share-next.ts30
-rw-r--r--packages/opencode/src/share/share.sql.ts13
-rw-r--r--packages/opencode/src/share/share.ts92
-rw-r--r--packages/opencode/src/sql.d.ts4
-rw-r--r--packages/opencode/src/storage/db.ts140
-rw-r--r--packages/opencode/src/storage/json-migration.ts437
-rw-r--r--packages/opencode/src/storage/schema.sql.ts10
-rw-r--r--packages/opencode/src/storage/schema.ts4
-rw-r--r--packages/opencode/src/util/lazy.ts11
-rw-r--r--packages/opencode/src/worktree/index.ts6
-rw-r--r--packages/opencode/test/acp/event-subscription.test.ts50
-rw-r--r--packages/opencode/test/permission/next.test.ts1
-rw-r--r--packages/opencode/test/preload.ts97
-rw-r--r--packages/opencode/test/project/project.test.ts189
-rw-r--r--packages/opencode/test/storage/json-migration.test.ts687
-rw-r--r--packages/sdk/js/openapi.json0
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts17
-rw-r--r--packages/sdk/openapi.json39
57 files changed, 5448 insertions, 635 deletions
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index fa1a43d47..66fcac66d 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -231,6 +231,24 @@ export function applyDirectoryEvent(input: {
}
break
}
+ case "message.part.delta": {
+ const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
+ const parts = input.store.part[props.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, props.partID, (p) => p.id)
+ if (!result.found) break
+ input.setStore(
+ "part",
+ props.messageID,
+ produce((draft) => {
+ const part = draft[result.index]
+ const field = props.field as keyof typeof part
+ const existing = part[field] as string | undefined
+ ;(part[field] as string) = (existing ?? "") + props.delta
+ }),
+ )
+ break
+ }
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 0676595c7..a38e09307 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -12,7 +12,7 @@
"@opencode-ai/console-resource": "workspace:*",
"@planetscale/database": "1.19.0",
"aws4fetch": "1.0.20",
- "drizzle-orm": "0.41.0",
+ "drizzle-orm": "catalog:",
"postgres": "3.4.7",
"stripe": "18.0.0",
"ulid": "catalog:",
@@ -44,7 +44,7 @@
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0",
"@types/node": "catalog:",
- "drizzle-kit": "0.30.5",
+ "drizzle-kit": "catalog:",
"mysql2": "3.14.4",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
diff --git a/packages/console/core/src/drizzle/index.ts b/packages/console/core/src/drizzle/index.ts
index f0f065de4..d3a4b63bf 100644
--- a/packages/console/core/src/drizzle/index.ts
+++ b/packages/console/core/src/drizzle/index.ts
@@ -4,7 +4,6 @@ export * from "drizzle-orm"
import { Client } from "@planetscale/database"
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
-import type { ExtractTablesWithRelations } from "drizzle-orm"
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
import { Context } from "../context"
import { memo } from "../util/memo"
@@ -14,7 +13,7 @@ export namespace Database {
PlanetscaleQueryResultHKT,
PlanetScalePreparedQueryHKT,
Record<string, never>,
- ExtractTablesWithRelations<Record<string, never>>
+ any
>
const client = memo(() => {
@@ -23,7 +22,7 @@ export namespace Database {
username: Resource.Database.username,
password: Resource.Database.password,
})
- const db = drizzle(result, {})
+ const db = drizzle({ client: result })
return db
})
diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md
index a68fd7f3e..dcfc336d6 100644
--- a/packages/opencode/AGENTS.md
+++ b/packages/opencode/AGENTS.md
@@ -1,27 +1,10 @@
-# opencode agent guidelines
+# opencode database guide
-## Build/Test Commands
+## Database
-- **Install**: `bun install`
-- **Run**: `bun run --conditions=browser ./src/index.ts`
-- **Typecheck**: `bun run typecheck` (npm run typecheck)
-- **Test**: `bun test` (runs all tests)
-- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
-
-## Code Style
-
-- **Runtime**: Bun with TypeScript ESM modules
-- **Imports**: Use relative imports for local modules, named imports preferred
-- **Types**: Zod schemas for validation, TypeScript interfaces for structure
-- **Naming**: camelCase for variables/functions, PascalCase for classes/namespaces
-- **Error handling**: Use Result patterns, avoid throwing exceptions in tools
-- **File structure**: Namespace-based organization (e.g., `Tool.define()`, `Session.create()`)
-
-## Architecture
-
-- **Tools**: Implement `Tool.Info` interface with `execute()` method
-- **Context**: Pass `sessionID` in tool context, use `App.provide()` for DI
-- **Validation**: All inputs validated with Zod schemas
-- **Logging**: Use `Log.create({ service: "name" })` pattern
-- **Storage**: Use `Storage` namespace for persistence
-- **API Client**: The TypeScript TUI (built with SolidJS + OpenTUI) communicates with the OpenCode server using `@opencode-ai/sdk`. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `./script/generate.ts` to regenerate the SDK and related files.
+- **Schema**: Drizzle schema lives in `src/**/*.sql.ts`.
+- **Naming**: tables and columns use snake*case; join columns are `<entity>_id`; indexes are `<table>*<column>\_idx`.
+- **Migrations**: generated by Drizzle Kit using `drizzle.config.ts` (schema: `./src/**/*.sql.ts`, output: `./migration`).
+- **Command**: `bun run db generate --name <slug>`.
+- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
+- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
diff --git a/packages/opencode/drizzle.config.ts b/packages/opencode/drizzle.config.ts
new file mode 100644
index 000000000..1b4fd556e
--- /dev/null
+++ b/packages/opencode/drizzle.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "drizzle-kit"
+
+export default defineConfig({
+ dialect: "sqlite",
+ schema: "./src/**/*.sql.ts",
+ out: "./migration",
+ dbCredentials: {
+ url: "/home/thdxr/.local/share/opencode/opencode.db",
+ },
+})
diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql b/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql
new file mode 100644
index 000000000..775c1a117
--- /dev/null
+++ b/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql
@@ -0,0 +1,90 @@
+CREATE TABLE `project` (
+ `id` text PRIMARY KEY,
+ `worktree` text NOT NULL,
+ `vcs` text,
+ `name` text,
+ `icon_url` text,
+ `icon_color` text,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `time_initialized` integer,
+ `sandboxes` text NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `message` (
+ `id` text PRIMARY KEY,
+ `session_id` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `part` (
+ `id` text PRIMARY KEY,
+ `message_id` text NOT NULL,
+ `session_id` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_part_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `permission` (
+ `project_id` text PRIMARY KEY,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `session` (
+ `id` text PRIMARY KEY,
+ `project_id` text NOT NULL,
+ `parent_id` text,
+ `slug` text NOT NULL,
+ `directory` text NOT NULL,
+ `title` text NOT NULL,
+ `version` text NOT NULL,
+ `share_url` text,
+ `summary_additions` integer,
+ `summary_deletions` integer,
+ `summary_files` integer,
+ `summary_diffs` text,
+ `revert` text,
+ `permission` text,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `time_compacting` integer,
+ `time_archived` integer,
+ CONSTRAINT `fk_session_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `todo` (
+ `session_id` text NOT NULL,
+ `content` text NOT NULL,
+ `status` text NOT NULL,
+ `priority` text NOT NULL,
+ `position` integer NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `todo_pk` PRIMARY KEY(`session_id`, `position`),
+ CONSTRAINT `fk_todo_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `session_share` (
+ `session_id` text PRIMARY KEY,
+ `id` text NOT NULL,
+ `secret` text NOT NULL,
+ `url` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `fk_session_share_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE INDEX `message_session_idx` ON `message` (`session_id`);--> statement-breakpoint
+CREATE INDEX `part_message_idx` ON `part` (`message_id`);--> statement-breakpoint
+CREATE INDEX `part_session_idx` ON `part` (`session_id`);--> statement-breakpoint
+CREATE INDEX `session_project_idx` ON `session` (`project_id`);--> statement-breakpoint
+CREATE INDEX `session_parent_idx` ON `session` (`parent_id`);--> statement-breakpoint
+CREATE INDEX `todo_session_idx` ON `todo` (`session_id`); \ No newline at end of file
diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json b/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json
new file mode 100644
index 000000000..ff76ee209
--- /dev/null
+++ b/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json
@@ -0,0 +1,796 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "068758ed-a97a-46f6-8a59-6c639ae7c20c",
+ "prevIds": ["00000000-0000-0000-0000-000000000000"],
+ "ddl": [
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": ["message_id"],
+ "tableTo": "message",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id", "position"],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["project_id"],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["session_id"],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/migration/20260211171708_add_project_commands/migration.sql b/packages/opencode/migration/20260211171708_add_project_commands/migration.sql
new file mode 100644
index 000000000..b63f147a0
--- /dev/null
+++ b/packages/opencode/migration/20260211171708_add_project_commands/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE `project` ADD `commands` text; \ No newline at end of file
diff --git a/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json b/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json
new file mode 100644
index 000000000..5a40e85c1
--- /dev/null
+++ b/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json
@@ -0,0 +1,847 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "8bc2d11d-97fa-4ba8-8bfa-6c5956c49aeb",
+ "prevIds": [
+ "068758ed-a97a-46f6-8a59-6c639ae7c20c"
+ ],
+ "ddl": [
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "commands",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": [
+ "message_id"
+ ],
+ "tableTo": "message",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "tableTo": "project",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "tableTo": "project",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": [
+ "session_id",
+ "position"
+ ],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+} \ No newline at end of file
diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql b/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql
new file mode 100644
index 000000000..3085fe280
--- /dev/null
+++ b/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql
@@ -0,0 +1,11 @@
+CREATE TABLE `control_account` (
+ `email` text NOT NULL,
+ `url` text NOT NULL,
+ `access_token` text NOT NULL,
+ `refresh_token` text NOT NULL,
+ `token_expiry` integer,
+ `active` integer NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `control_account_pk` PRIMARY KEY(`email`, `url`)
+);
diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json b/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json
new file mode 100644
index 000000000..157459afa
--- /dev/null
+++ b/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json
@@ -0,0 +1,941 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "d2736e43-700f-4e9e-8151-9f2f0d967bc8",
+ "prevIds": [
+ "8bc2d11d-97fa-4ba8-8bfa-6c5956c49aeb"
+ ],
+ "ddl": [
+ {
+ "name": "control_account",
+ "entityType": "tables"
+ },
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "access_token",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refresh_token",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token_expiry",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "commands",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": [
+ "message_id"
+ ],
+ "tableTo": "message",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "tableTo": "project",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "tableTo": "project",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": [
+ "email",
+ "url"
+ ],
+ "nameExplicit": false,
+ "name": "control_account_pk",
+ "entityType": "pks",
+ "table": "control_account"
+ },
+ {
+ "columns": [
+ "session_id",
+ "position"
+ ],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+} \ No newline at end of file
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 03e58c029..faf29850a 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -15,7 +15,8 @@
"lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
- "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'"
+ "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
+ "db": "bun drizzle-kit"
},
"bin": {
"opencode": "./bin/opencode"
@@ -42,6 +43,8 @@
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
+ "drizzle-kit": "1.0.0-beta.12-a5629fb",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -100,6 +103,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
@@ -122,5 +126,8 @@
"yargs": "18.0.0",
"zod": "catalog:",
"zod-to-json-schema": "3.24.5"
+ },
+ "overrides": {
+ "drizzle-orm": "1.0.0-beta.12-a5629fb"
}
}
diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts
index f0b3fa828..ddb476991 100755
--- a/packages/opencode/script/build.ts
+++ b/packages/opencode/script/build.ts
@@ -25,6 +25,32 @@ await Bun.write(
)
console.log("Generated models-snapshot.ts")
+// Load migrations from migration directories
+const migrationDirs = (await fs.promises.readdir(path.join(dir, "migration"), { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
+ .map((entry) => entry.name)
+ .sort()
+
+const migrations = await Promise.all(
+ migrationDirs.map(async (name) => {
+ const file = path.join(dir, "migration", name, "migration.sql")
+ const sql = await Bun.file(file).text()
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
+ const timestamp = match
+ ? Date.UTC(
+ Number(match[1]),
+ Number(match[2]) - 1,
+ Number(match[3]),
+ Number(match[4]),
+ Number(match[5]),
+ Number(match[6]),
+ )
+ : 0
+ return { sql, timestamp }
+ }),
+)
+console.log(`Loaded ${migrations.length} migrations`)
+
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
@@ -156,6 +182,7 @@ for (const item of targets) {
entrypoints: ["./src/index.ts", parserWorker, workerPath],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
+ OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
diff --git a/packages/opencode/script/check-migrations.ts b/packages/opencode/script/check-migrations.ts
new file mode 100644
index 000000000..f5eaf7932
--- /dev/null
+++ b/packages/opencode/script/check-migrations.ts
@@ -0,0 +1,16 @@
+#!/usr/bin/env bun
+
+import { $ } from "bun"
+
+// drizzle-kit check compares schema to migrations, exits non-zero if drift
+const result = await $`bun drizzle-kit check`.quiet().nothrow()
+
+if (result.exitCode !== 0) {
+ console.error("Schema has changes not captured in migrations!")
+ console.error("Run: bun drizzle-kit generate")
+ console.error("")
+ console.error(result.stderr.toString())
+ process.exit(1)
+}
+
+console.log("Migrations are up to date")
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index ae6f6fcc2..9512406b3 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -435,46 +435,68 @@ export namespace ACP {
return
}
}
+ return
+ }
- if (part.type === "text") {
- const delta = props.delta
- if (delta && part.ignored !== true) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_message_chunk",
- content: {
- type: "text",
- text: delta,
- },
+ case "message.part.delta": {
+ const props = event.properties
+ const session = this.sessionManager.tryGet(props.sessionID)
+ if (!session) return
+ const sessionId = session.id
+
+ const message = await this.sdk.session
+ .message(
+ {
+ sessionID: props.sessionID,
+ messageID: props.messageID,
+ directory: session.cwd,
+ },
+ { throwOnError: true },
+ )
+ .then((x) => x.data)
+ .catch((error) => {
+ log.error("unexpected error when fetching message", { error })
+ return undefined
+ })
+
+ if (!message || message.info.role !== "assistant") return
+
+ const part = message.parts.find((p) => p.id === props.partID)
+ if (!part) return
+
+ if (part.type === "text" && props.field === "text" && part.ignored !== true) {
+ await this.connection
+ .sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: {
+ type: "text",
+ text: props.delta,
},
- })
- .catch((error) => {
- log.error("failed to send text to ACP", { error })
- })
- }
+ },
+ })
+ .catch((error) => {
+ log.error("failed to send text delta to ACP", { error })
+ })
return
}
- if (part.type === "reasoning") {
- const delta = props.delta
- if (delta) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_thought_chunk",
- content: {
- type: "text",
- text: delta,
- },
+ if (part.type === "reasoning" && props.field === "text") {
+ await this.connection
+ .sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_thought_chunk",
+ content: {
+ type: "text",
+ text: props.delta,
},
- })
- .catch((error) => {
- log.error("failed to send reasoning to ACP", { error })
- })
- }
+ },
+ })
+ .catch((error) => {
+ log.error("failed to send reasoning delta to ACP", { error })
+ })
}
return
}
diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts
index 37419f4e2..fd45a09b7 100644
--- a/packages/opencode/src/cli/cmd/import.ts
+++ b/packages/opencode/src/cli/cmd/import.ts
@@ -3,7 +3,8 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "../../session"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
-import { Storage } from "../../storage/storage"
+import { Database } from "../../storage/db"
+import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
@@ -130,13 +131,35 @@ export const ImportCommand = cmd({
return
}
- await Storage.write(["session", Instance.project.id, exportData.info.id], exportData.info)
+ Database.use((db) => db.insert(SessionTable).values(Session.toRow(exportData.info)).onConflictDoNothing().run())
for (const msg of exportData.messages) {
- await Storage.write(["message", exportData.info.id, msg.info.id], msg.info)
+ Database.use((db) =>
+ db
+ .insert(MessageTable)
+ .values({
+ id: msg.info.id,
+ session_id: exportData.info.id,
+ time_created: msg.info.time?.created ?? Date.now(),
+ data: msg.info,
+ })
+ .onConflictDoNothing()
+ .run(),
+ )
for (const part of msg.parts) {
- await Storage.write(["part", msg.info.id, part.id], part)
+ Database.use((db) =>
+ db
+ .insert(PartTable)
+ .values({
+ id: part.id,
+ message_id: msg.info.id,
+ session_id: exportData.info.id,
+ data: part,
+ })
+ .onConflictDoNothing()
+ .run(),
+ )
}
}
diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts
index 9239bb90a..04c1fe2eb 100644
--- a/packages/opencode/src/cli/cmd/stats.ts
+++ b/packages/opencode/src/cli/cmd/stats.ts
@@ -2,7 +2,8 @@ import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
-import { Storage } from "../../storage/storage"
+import { Database } from "../../storage/db"
+import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
@@ -87,25 +88,8 @@ async function getCurrentProject(): Promise<Project.Info> {
}
async function getAllSessions(): Promise<Session.Info[]> {
- const sessions: Session.Info[] = []
-
- const projectKeys = await Storage.list(["project"])
- const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
-
- for (const project of projects) {
- if (!project) continue
-
- const sessionKeys = await Storage.list(["session", project.id])
- const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read<Session.Info>(key)))
-
- for (const session of projectSessions) {
- if (session) {
- sessions.push(session)
- }
- }
- }
-
- return sessions
+ const rows = Database.use((db) => db.select().from(SessionTable).all())
+ return rows.map((row) => Session.fromRow(row))
}
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index eb8ed2d9b..269ed7ae0 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -299,6 +299,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
+ case "message.part.delta": {
+ const parts = store.part[event.properties.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, event.properties.partID, (p) => p.id)
+ if (!result.found) break
+ setStore(
+ "part",
+ event.properties.messageID,
+ produce((draft) => {
+ const part = draft[result.index]
+ const field = event.properties.field as keyof typeof part
+ const existing = part[field] as string | undefined
+ ;(part[field] as string) = (existing ?? "") + event.properties.delta
+ }),
+ )
+ break
+ }
+
case "message.part.removed": {
const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index b843bda1c..e83b9abe9 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -2042,8 +2042,8 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
</For>
</Match>
<Match when={true}>
- <InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}>
- apply_patch
+ <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
+ Patch
</InlineTool>
</Match>
</Switch>
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 8f0f583ea..261731b8b 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -31,6 +31,7 @@ import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
+import { Control } from "@/control"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -53,7 +54,7 @@ export namespace Config {
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them
- function mergeConfigConcatArrays(target: Info, source: Info): Info {
+ function merge(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -88,20 +89,21 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
- result = mergeConfigConcatArrays(
- result,
- await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`),
- )
+ result = merge(result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`))
log.debug("loaded remote config from well-known", { url: key })
}
}
+ const token = await Control.token()
+ if (token) {
+ }
+
// Global user config overrides remote config.
- result = mergeConfigConcatArrays(result, await global())
+ result = merge(result, await global())
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
- result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
+ result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
@@ -110,7 +112,7 @@ export namespace Config {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
- result = mergeConfigConcatArrays(result, await loadFile(resolved))
+ result = merge(result, await loadFile(resolved))
}
}
}
@@ -153,7 +155,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
- result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
+ result = merge(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -176,7 +178,7 @@ export namespace Config {
// Inline config content overrides all non-managed config sources.
if (Flag.OPENCODE_CONFIG_CONTENT) {
- result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
+ result = merge(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -186,7 +188,7 @@ export namespace Config {
// This way it only loads config file and not skills/plugins/commands
if (existsSync(managedConfigDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
- result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file)))
+ result = merge(result, await loadFile(path.join(managedConfigDir, file)))
}
}
diff --git a/packages/opencode/src/control/control.sql.ts b/packages/opencode/src/control/control.sql.ts
new file mode 100644
index 000000000..7b805c162
--- /dev/null
+++ b/packages/opencode/src/control/control.sql.ts
@@ -0,0 +1,22 @@
+import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
+import { eq } from "drizzle-orm"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const ControlAccountTable = sqliteTable(
+ "control_account",
+ {
+ email: text().notNull(),
+ url: text().notNull(),
+ access_token: text().notNull(),
+ refresh_token: text().notNull(),
+ token_expiry: integer(),
+ active: integer({ mode: "boolean" })
+ .notNull()
+ .$default(() => false),
+ ...Timestamps,
+ },
+ (table) => [
+ primaryKey({ columns: [table.email, table.url] }),
+ // uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
+ ],
+)
diff --git a/packages/opencode/src/control/index.ts b/packages/opencode/src/control/index.ts
new file mode 100644
index 000000000..f712e8828
--- /dev/null
+++ b/packages/opencode/src/control/index.ts
@@ -0,0 +1,67 @@
+import { eq, and } from "drizzle-orm"
+import { Database } from "@/storage/db"
+import { ControlAccountTable } from "./control.sql"
+import z from "zod"
+
+export * from "./control.sql"
+
+export namespace Control {
+ export const Account = z.object({
+ email: z.string(),
+ url: z.string(),
+ })
+ export type Account = z.infer<typeof Account>
+
+ function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
+ return {
+ email: row.email,
+ url: row.url,
+ }
+ }
+
+ export function account(): Account | undefined {
+ const row = Database.use((db) =>
+ db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
+ )
+ return row ? fromRow(row) : undefined
+ }
+
+ export async function token(): Promise<string | undefined> {
+ const row = Database.use((db) =>
+ db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
+ )
+ if (!row) return undefined
+ if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
+
+ const res = await fetch(`${row.url}/oauth/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: row.refresh_token,
+ }).toString(),
+ })
+
+ if (!res.ok) return
+
+ const json = (await res.json()) as {
+ access_token: string
+ refresh_token?: string
+ expires_in?: number
+ }
+
+ Database.use((db) =>
+ db
+ .update(ControlAccountTable)
+ .set({
+ access_token: json.access_token,
+ refresh_token: json.refresh_token ?? row.refresh_token,
+ token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
+ })
+ .where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
+ .run(),
+ )
+
+ return json.access_token
+ }
+}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 6dc5e99e9..420ead555 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -26,6 +26,10 @@ import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
+import path from "path"
+import { Global } from "./global"
+import { JsonMigration } from "./storage/json-migration"
+import { Database } from "./storage/db"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -74,6 +78,43 @@ const cli = yargs(hideBin(process.argv))
version: Installation.VERSION,
args: process.argv.slice(2),
})
+
+ const marker = path.join(Global.Path.data, "opencode.db")
+ if (!(await Bun.file(marker).exists())) {
+ console.log("Performing one time database migration, may take a few minutes...")
+ const tty = process.stdout.isTTY
+ const width = 36
+ const orange = "\x1b[38;5;214m"
+ const muted = "\x1b[0;2m"
+ const reset = "\x1b[0m"
+ let last = -1
+ if (tty) process.stdout.write("\x1b[?25l")
+ try {
+ await JsonMigration.run(Database.Client().$client, {
+ progress: (event) => {
+ const percent = Math.floor((event.current / event.total) * 100)
+ if (percent === last && event.current !== event.total) return
+ last = percent
+ if (tty) {
+ const fill = Math.round((percent / 100) * width)
+ const bar = `${"â– ".repeat(fill)}${"ï½¥".repeat(width - fill)}`
+ process.stdout.write(
+ `\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`,
+ )
+ if (event.current === event.total) process.stdout.write("\n")
+ } else {
+ console.log(`sqlite-migration:${percent}`)
+ }
+ },
+ })
+ } finally {
+ if (tty) process.stdout.write("\x1b[?25h")
+ else {
+ console.log(`sqlite-migration:done`)
+ }
+ }
+ console.log("Database migration complete.")
+ }
})
.usage("\n" + UI.logo())
.completion("completion", "generate shell completion script")
diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts
index 2481f104e..1e1df62a3 100644
--- a/packages/opencode/src/permission/next.ts
+++ b/packages/opencode/src/permission/next.ts
@@ -3,7 +3,8 @@ import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
-import { Storage } from "@/storage/storage"
+import { Database, eq } from "@/storage/db"
+import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
@@ -105,9 +106,12 @@ export namespace PermissionNext {
),
}
- const state = Instance.state(async () => {
+ const state = Instance.state(() => {
const projectID = Instance.project.id
- const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset)
+ const row = Database.use((db) =>
+ db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(),
+ )
+ const stored = row?.data ?? ([] as Ruleset)
const pending: Record<
string,
@@ -222,7 +226,8 @@ export namespace PermissionNext {
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
- // await Storage.write(["permission", Instance.project.id], s.approved)
+ // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
+ // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
return
}
},
@@ -275,6 +280,7 @@ export namespace PermissionNext {
}
export async function list() {
- return state().then((x) => Object.values(x.pending).map((x) => x.info))
+ const s = await state()
+ return Object.values(s.pending).map((x) => x.info)
}
}
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index efdcaba99..a2be3733f 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -1,5 +1,4 @@
import { Plugin } from "../plugin"
-import { Share } from "../share/share"
import { Format } from "../format"
import { LSP } from "../lsp"
import { FileWatcher } from "../file/watcher"
@@ -17,7 +16,6 @@ import { Truncate } from "../tool/truncation"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
- Share.init()
ShareNext.init()
Format.init()
await LSP.init()
diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts
new file mode 100644
index 000000000..12373244f
--- /dev/null
+++ b/packages/opencode/src/project/project.sql.ts
@@ -0,0 +1,15 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const ProjectTable = sqliteTable("project", {
+ id: text().primaryKey(),
+ worktree: text().notNull(),
+ vcs: text(),
+ name: text(),
+ icon_url: text(),
+ icon_color: text(),
+ ...Timestamps,
+ time_initialized: integer(),
+ sandboxes: text({ mode: "json" }).notNull().$type<string[]>(),
+ commands: text({ mode: "json" }).$type<{ start?: string }>(),
+})
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index c79a62c6c..8fa0f6c6f 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -1,11 +1,11 @@
import z from "zod"
-import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import path from "path"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { ProjectTable } from "./project.sql"
+import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
-import { Session } from "../session"
import { work } from "../util/queue"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event"
@@ -50,10 +50,33 @@ export namespace Project {
Updated: BusEvent.define("project.updated", Info),
}
+ type Row = typeof ProjectTable.$inferSelect
+
+ export function fromRow(row: Row): Info {
+ const icon =
+ row.icon_url || row.icon_color
+ ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
+ : undefined
+ return {
+ id: row.id,
+ worktree: row.worktree,
+ vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
+ name: row.name ?? undefined,
+ icon,
+ time: {
+ created: row.time_created,
+ updated: row.time_updated,
+ initialized: row.time_initialized ?? undefined,
+ },
+ sandboxes: row.sandboxes,
+ commands: row.commands ?? undefined,
+ }
+ }
+
export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory })
- const { id, sandbox, worktree, vcs } = await iife(async () => {
+ const data = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
@@ -169,47 +192,73 @@ export namespace Project {
}
})
- let existing = await Storage.read<Info>(["project", id]).catch(() => undefined)
- if (!existing) {
- existing = {
- id,
- worktree,
- vcs: vcs as Info["vcs"],
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
+ const existing = await iife(async () => {
+ if (row) return fromRow(row)
+ const fresh: Info = {
+ id: data.id,
+ worktree: data.worktree,
+ vcs: data.vcs as Info["vcs"],
sandboxes: [],
time: {
created: Date.now(),
updated: Date.now(),
},
}
- if (id !== "global") {
- await migrateFromGlobal(id, worktree)
+ if (data.id !== "global") {
+ await migrateFromGlobal(data.id, data.worktree)
}
- }
-
- // migrate old projects before sandboxes
- if (!existing.sandboxes) existing.sandboxes = []
+ return fresh
+ })
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
- worktree,
- vcs: vcs as Info["vcs"],
+ worktree: data.worktree,
+ vcs: data.vcs as Info["vcs"],
time: {
...existing.time,
updated: Date.now(),
},
}
- if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox)
+ if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
+ result.sandboxes.push(data.sandbox)
result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
- await Storage.write<Info>(["project", id], result)
+ const insert = {
+ id: result.id,
+ worktree: result.worktree,
+ vcs: result.vcs ?? null,
+ name: result.name,
+ icon_url: result.icon?.url,
+ icon_color: result.icon?.color,
+ time_created: result.time.created,
+ time_updated: result.time.updated,
+ time_initialized: result.time.initialized,
+ sandboxes: result.sandboxes,
+ commands: result.commands,
+ }
+ const updateSet = {
+ worktree: result.worktree,
+ vcs: result.vcs ?? null,
+ name: result.name,
+ icon_url: result.icon?.url,
+ icon_color: result.icon?.color,
+ time_updated: result.time.updated,
+ time_initialized: result.time.initialized,
+ sandboxes: result.sandboxes,
+ commands: result.commands,
+ }
+ Database.use((db) =>
+ db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
+ )
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
- return { project: result, sandbox }
+ return { project: result, sandbox: data.sandbox }
}
export async function discover(input: Info) {
@@ -242,43 +291,54 @@ export namespace Project {
return
}
- async function migrateFromGlobal(newProjectID: string, worktree: string) {
- const globalProject = await Storage.read<Info>(["project", "global"]).catch(() => undefined)
- if (!globalProject) return
+ async function migrateFromGlobal(id: string, worktree: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get())
+ if (!row) return
- const globalSessions = await Storage.list(["session", "global"]).catch(() => [])
- if (globalSessions.length === 0) return
+ const sessions = Database.use((db) =>
+ db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(),
+ )
+ if (sessions.length === 0) return
- log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length })
+ log.info("migrating sessions from global", { newProjectID: id, worktree, count: sessions.length })
- await work(10, globalSessions, async (key) => {
- const sessionID = key[key.length - 1]
- const session = await Storage.read<Session.Info>(key).catch(() => undefined)
- if (!session) return
- if (session.directory && session.directory !== worktree) return
+ await work(10, sessions, async (row) => {
+ // Skip sessions that belong to a different directory
+ if (row.directory && row.directory !== worktree) return
- session.projectID = newProjectID
- log.info("migrating session", { sessionID, from: "global", to: newProjectID })
- await Storage.write(["session", newProjectID, sessionID], session)
- await Storage.remove(key)
+ log.info("migrating session", { sessionID: row.id, from: "global", to: id })
+ Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run())
}).catch((error) => {
- log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID })
+ log.error("failed to migrate sessions from global to project", { error, projectId: id })
})
}
- export async function setInitialized(projectID: string) {
- await Storage.update<Info>(["project", projectID], (draft) => {
- draft.time.initialized = Date.now()
- })
+ export function setInitialized(id: string) {
+ Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({
+ time_initialized: Date.now(),
+ })
+ .where(eq(ProjectTable.id, id))
+ .run(),
+ )
+ }
+
+ export function list() {
+ return Database.use((db) =>
+ db
+ .select()
+ .from(ProjectTable)
+ .all()
+ .map((row) => fromRow(row)),
+ )
}
- export async function list() {
- const keys = await Storage.list(["project"])
- const projects = await Promise.all(keys.map((x) => Storage.read<Info>(x)))
- return projects.map((project) => ({
- ...project,
- sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
- }))
+ export function get(id: string): Info | undefined {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) return undefined
+ return fromRow(row)
}
export const update = fn(
@@ -289,77 +349,90 @@ export namespace Project {
commands: Info.shape.commands.optional(),
}),
async (input) => {
- const result = await Storage.update<Info>(["project", input.projectID], (draft) => {
- if (input.name !== undefined) draft.name = input.name
- if (input.icon !== undefined) {
- draft.icon = {
- ...draft.icon,
- }
- if (input.icon.url !== undefined) draft.icon.url = input.icon.url
- if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
- if (input.icon.color !== undefined) draft.icon.color = input.icon.color
- }
-
- if (input.commands?.start !== undefined) {
- const start = input.commands.start || undefined
- draft.commands = {
- ...(draft.commands ?? {}),
- }
- draft.commands.start = start
- if (!draft.commands.start) draft.commands = undefined
- }
-
- draft.time.updated = Date.now()
- })
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({
+ name: input.name,
+ icon_url: input.icon?.url,
+ icon_color: input.icon?.color,
+ commands: input.commands,
+ time_updated: Date.now(),
+ })
+ .where(eq(ProjectTable.id, input.projectID))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${input.projectID}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
},
)
- export async function sandboxes(projectID: string) {
- const project = await Storage.read<Info>(["project", projectID]).catch(() => undefined)
- if (!project?.sandboxes) return []
+ export async function sandboxes(id: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) return []
+ const data = fromRow(row)
const valid: string[] = []
- for (const dir of project.sandboxes) {
- const stat = await fs.stat(dir).catch(() => undefined)
+ for (const dir of data.sandboxes) {
+ const stat = await Bun.file(dir)
+ .stat()
+ .catch(() => undefined)
if (stat?.isDirectory()) valid.push(dir)
}
return valid
}
- export async function addSandbox(projectID: string, directory: string) {
- const result = await Storage.update<Info>(["project", projectID], (draft) => {
- const sandboxes = draft.sandboxes ?? []
- if (!sandboxes.includes(directory)) sandboxes.push(directory)
- draft.sandboxes = sandboxes
- draft.time.updated = Date.now()
- })
+ export async function addSandbox(id: string, directory: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) throw new Error(`Project not found: ${id}`)
+ const sandboxes = [...row.sandboxes]
+ if (!sandboxes.includes(directory)) sandboxes.push(directory)
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({ sandboxes, time_updated: Date.now() })
+ .where(eq(ProjectTable.id, id))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${id}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
}
- export async function removeSandbox(projectID: string, directory: string) {
- const result = await Storage.update<Info>(["project", projectID], (draft) => {
- const sandboxes = draft.sandboxes ?? []
- draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
- draft.time.updated = Date.now()
- })
+ export async function removeSandbox(id: string, directory: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) throw new Error(`Project not found: ${id}`)
+ const sandboxes = row.sandboxes.filter((s) => s !== directory)
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({ sandboxes, time_updated: Date.now() })
+ .where(eq(ProjectTable.id, id))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${id}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
}
}
diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts
index 26e2dfcb1..cc5fa9618 100644
--- a/packages/opencode/src/server/error.ts
+++ b/packages/opencode/src/server/error.ts
@@ -1,6 +1,6 @@
import { resolver } from "hono-openapi"
import z from "zod"
-import { Storage } from "../storage/storage"
+import { NotFoundError } from "../storage/db"
export const ERRORS = {
400: {
@@ -25,7 +25,7 @@ export const ERRORS = {
description: "Not found",
content: {
"application/json": {
- schema: resolver(Storage.NotFoundError.Schema),
+ schema: resolver(NotFoundError.Schema),
},
},
},
diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts
index 10bf51cb9..21156190d 100644
--- a/packages/opencode/src/server/routes/pty.ts
+++ b/packages/opencode/src/server/routes/pty.ts
@@ -3,7 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
-import { Storage } from "../../storage/storage"
+import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -76,7 +76,7 @@ export const PtyRoutes = lazy(() =>
async (c) => {
const info = Pty.get(c.req.valid("param").ptyID)
if (!info) {
- throw new Storage.NotFoundError({ message: "Session not found" })
+ throw new NotFoundError({ message: "Session not found" })
}
return c.json(info)
},
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index 82e6f3121..2cf5473f2 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/server/server.ts b/packages/opencode/src/server/server.ts
index 9fb520655..c1896a8d1 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -31,7 +31,7 @@ import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap"
-import { Storage } from "../storage/storage"
+import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
@@ -65,7 +65,7 @@ export namespace Server {
})
if (err instanceof NamedError) {
let status: ContentfulStatusCode
- if (err instanceof Storage.NotFoundError) status = 404
+ if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name.startsWith("Worktree")) status = 400
else status = 500
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index b0ffaaf70..5f889385c 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -10,7 +10,9 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
-import { Storage } from "../storage/storage"
+import { Database, NotFoundError, eq, and, or, like } from "../storage/db"
+import { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
@@ -41,6 +43,64 @@ 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 ?? undefined
+ return {
+ id: row.id,
+ slug: row.slug,
+ projectID: row.project_id,
+ directory: row.directory,
+ parentID: row.parent_id ?? 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,
+ project_id: info.projectID,
+ parent_id: 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: info.revert ?? null,
+ permission: info.permission,
+ time_created: info.time.created,
+ time_updated: info.time.updated,
+ time_compacting: info.time.compacting,
+ time_archived: info.time.archived,
+ }
+ }
+
function getForkedTitle(title: string): string {
const match = title.match(/^(.+) \(fork #(\d+)\)$/)
if (match) {
@@ -94,16 +154,6 @@ export namespace Session {
})
export type Info = z.output<typeof Info>
- export const ShareInfo = z
- .object({
- secret: z.string(),
- url: z.string(),
- })
- .meta({
- ref: "SessionShare",
- })
- export type ShareInfo = z.output<typeof ShareInfo>
-
export const Event = {
Created: BusEvent.define(
"session.created",
@@ -200,8 +250,17 @@ 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) => {
+ const row = db
+ .update(SessionTable)
+ .set({ time_updated: now })
+ .where(eq(SessionTable.id, sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
})
})
@@ -227,21 +286,19 @@ export namespace Session {
},
}
log.info("created", result)
- await Storage.write(["session", Instance.project.id, result.id], result)
- Bus.publish(Event.Created, {
- info: result,
+ Database.use((db) => {
+ db.insert(SessionTable).values(toRow(result)).run()
+ Database.effect(() =>
+ Bus.publish(Event.Created, {
+ info: result,
+ }),
+ )
})
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,
})
@@ -256,12 +313,9 @@ export namespace Session {
}
export const get = fn(Identifier.schema("session"), async (id) => {
- const read = await Storage.read<Info>(["session", Instance.project.id, id])
- return read as Info
- })
-
- export const getShare = fn(Identifier.schema("session"), async (id) => {
- return Storage.read<ShareInfo>(["share", id])
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ return fromRow(row)
})
export const share = fn(Identifier.schema("session"), async (id) => {
@@ -271,15 +325,12 @@ 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) => {
+ const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ })
return share
})
@@ -287,32 +338,155 @@ 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) => {
+ const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ })
})
- export async function update(id: string, editor: (session: Info) => void, options?: { touch?: boolean }) {
- const project = Instance.project
- const result = await Storage.update<Info>(["session", project.id, id], (draft) => {
- editor(draft)
- if (options?.touch !== false) {
- draft.time.updated = Date.now()
- }
- })
- Bus.publish(Event.Updated, {
- info: result,
+ export const setTitle = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ title: z.string(),
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ title: input.title })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const setArchived = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ time: z.number().optional(),
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ time_archived: input.time })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const setPermission = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ permission: PermissionNext.Ruleset,
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ permission: input.permission, time_updated: Date.now() })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => 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) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({
+ revert: input.revert ?? 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))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const clearRevert = fn(Identifier.schema("session"), async (sessionID) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({
+ revert: null,
+ time_updated: Date.now(),
+ })
+ .where(eq(SessionTable.id, sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
})
- return result
- }
+ })
+
+ export const setSummary = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ summary: Info.shape.summary,
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = 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))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
export const diff = fn(Identifier.schema("session"), async (sessionID) => {
- const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
- return diffs ?? []
+ try {
+ return await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
+ } catch {
+ return []
+ }
})
export const messages = fn(
@@ -331,25 +505,37 @@ export namespace Session {
},
)
- export async function* list() {
+ export function* list() {
const project = Instance.project
- for (const item of await Storage.list(["session", project.id])) {
- const session = await Storage.read<Info>(item).catch(() => undefined)
- if (!session) continue
- yield session
+ const rel = path.relative(Instance.worktree, Instance.directory)
+ const suffix = path.sep + rel
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(SessionTable)
+ .where(
+ and(
+ eq(SessionTable.project_id, project.id),
+ or(eq(SessionTable.directory, Instance.directory), like(SessionTable.directory, `%${suffix}`)),
+ ),
+ )
+ .all(),
+ )
+ for (const row of rows) {
+ yield fromRow(row)
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project
- const result = [] as Session.Info[]
- for (const item of await Storage.list(["session", project.id])) {
- const session = await Storage.read<Info>(item).catch(() => undefined)
- if (!session) continue
- if (session.parentID !== parentID) continue
- result.push(session)
- }
- return result
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(SessionTable)
+ .where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
+ .all(),
+ )
+ return rows.map(fromRow)
})
export const remove = fn(Identifier.schema("session"), async (sessionID) => {
@@ -360,15 +546,14 @@ export namespace Session {
await remove(child.id)
}
await unshare(sessionID).catch(() => {})
- for (const msg of await Storage.list(["message", sessionID])) {
- for (const part of await Storage.list(["part", msg.at(-1)!])) {
- await Storage.remove(part)
- }
- await Storage.remove(msg)
- }
- await Storage.remove(["session", project.id, sessionID])
- Bus.publish(Event.Deleted, {
- info: session,
+ // CASCADE delete handles messages and parts automatically
+ Database.use((db) => {
+ db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
+ Database.effect(() =>
+ Bus.publish(Event.Deleted, {
+ info: session,
+ }),
+ )
})
} catch (e) {
log.error(e)
@@ -376,9 +561,23 @@ export namespace Session {
})
export const updateMessage = fn(MessageV2.Info, async (msg) => {
- await Storage.write(["message", msg.sessionID, msg.id], msg)
- Bus.publish(MessageV2.Event.Updated, {
- info: msg,
+ const time_created = msg.role === "user" ? msg.time.created : msg.time.created
+ const { id, sessionID, ...data } = msg
+ Database.use((db) => {
+ db.insert(MessageTable)
+ .values({
+ id,
+ session_id: sessionID,
+ time_created,
+ data,
+ })
+ .onConflictDoUpdate({ target: MessageTable.id, set: { data } })
+ .run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.Updated, {
+ info: msg,
+ }),
+ )
})
return msg
})
@@ -389,10 +588,15 @@ export namespace Session {
messageID: Identifier.schema("message"),
}),
async (input) => {
- await Storage.remove(["message", input.sessionID, input.messageID])
- Bus.publish(MessageV2.Event.Removed, {
- sessionID: input.sessionID,
- messageID: input.messageID,
+ // CASCADE delete handles parts automatically
+ Database.use((db) => {
+ db.delete(MessageTable).where(eq(MessageTable.id, input.messageID)).run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.Removed, {
+ sessionID: input.sessionID,
+ messageID: input.messageID,
+ }),
+ )
})
return input.messageID
},
@@ -405,39 +609,58 @@ export namespace Session {
partID: Identifier.schema("part"),
}),
async (input) => {
- await Storage.remove(["part", input.messageID, input.partID])
- Bus.publish(MessageV2.Event.PartRemoved, {
- sessionID: input.sessionID,
- messageID: input.messageID,
- partID: input.partID,
+ Database.use((db) => {
+ db.delete(PartTable).where(eq(PartTable.id, input.partID)).run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.PartRemoved, {
+ sessionID: input.sessionID,
+ messageID: input.messageID,
+ partID: input.partID,
+ }),
+ )
})
return input.partID
},
)
- const UpdatePartInput = z.union([
- MessageV2.Part,
- z.object({
- part: MessageV2.TextPart,
- delta: z.string(),
- }),
- z.object({
- part: MessageV2.ReasoningPart,
- delta: z.string(),
- }),
- ])
-
- export const updatePart = fn(UpdatePartInput, async (input) => {
- const part = "delta" in input ? input.part : input
- const delta = "delta" in input ? input.delta : undefined
- await Storage.write(["part", part.messageID, part.id], part)
- Bus.publish(MessageV2.Event.PartUpdated, {
- part,
- delta,
+ const UpdatePartInput = MessageV2.Part
+
+ export const updatePart = fn(UpdatePartInput, async (part) => {
+ const { id, messageID, sessionID, ...data } = part
+ const time = Date.now()
+ Database.use((db) => {
+ db.insert(PartTable)
+ .values({
+ id,
+ message_id: messageID,
+ session_id: sessionID,
+ time_created: time,
+ data,
+ })
+ .onConflictDoUpdate({ target: PartTable.id, set: { data } })
+ .run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.PartUpdated, {
+ part,
+ }),
+ )
})
return part
})
+ export const updatePartDelta = fn(
+ z.object({
+ sessionID: z.string(),
+ messageID: z.string(),
+ partID: z.string(),
+ field: z.string(),
+ delta: z.string(),
+ }),
+ async (input) => {
+ Bus.publish(MessageV2.Event.PartDelta, input)
+ },
+ )
+
export const getUsage = fn(
z.object({
model: z.custom<Provider.Model>(),
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 70763548c..178751a22 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -6,6 +6,10 @@ import { Identifier } from "../id/id"
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
+import { Database, eq, desc, inArray } from "@/storage/db"
+import { MessageTable, PartTable } from "./session.sql"
+import { ProviderTransform } from "@/provider/transform"
+import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
@@ -456,7 +460,16 @@ export namespace MessageV2 {
"message.part.updated",
z.object({
part: Part,
- delta: z.string().optional(),
+ }),
+ ),
+ PartDelta: BusEvent.define(
+ "message.part.delta",
+ z.object({
+ sessionID: z.string(),
+ messageID: z.string(),
+ partID: z.string(),
+ field: z.string(),
+ delta: z.string(),
}),
),
PartRemoved: BusEvent.define(
@@ -701,23 +714,65 @@ export namespace MessageV2 {
}
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
- const list = await Array.fromAsync(await Storage.list(["message", sessionID]))
- for (let i = list.length - 1; i >= 0; i--) {
- yield await get({
- sessionID,
- messageID: list[i][2],
- })
+ const size = 50
+ let offset = 0
+ while (true) {
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(MessageTable)
+ .where(eq(MessageTable.session_id, sessionID))
+ .orderBy(desc(MessageTable.time_created))
+ .limit(size)
+ .offset(offset)
+ .all(),
+ )
+ if (rows.length === 0) break
+
+ const ids = rows.map((row) => row.id)
+ const partsByMessage = new Map<string, MessageV2.Part[]>()
+ if (ids.length > 0) {
+ const partRows = Database.use((db) =>
+ db
+ .select()
+ .from(PartTable)
+ .where(inArray(PartTable.message_id, ids))
+ .orderBy(PartTable.message_id, PartTable.id)
+ .all(),
+ )
+ for (const row of partRows) {
+ const part = {
+ ...row.data,
+ id: row.id,
+ sessionID: row.session_id,
+ messageID: row.message_id,
+ } as MessageV2.Part
+ const list = partsByMessage.get(row.message_id)
+ if (list) list.push(part)
+ else partsByMessage.set(row.message_id, [part])
+ }
+ }
+
+ for (const row of rows) {
+ const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
+ yield {
+ info,
+ parts: partsByMessage.get(row.id) ?? [],
+ }
+ }
+
+ offset += rows.length
+ if (rows.length < size) break
}
})
- export const parts = fn(Identifier.schema("message"), async (messageID) => {
- const result = [] as MessageV2.Part[]
- for (const item of await Storage.list(["part", messageID])) {
- const read = await Storage.read<MessageV2.Part>(item)
- result.push(read)
- }
- result.sort((a, b) => (a.id > b.id ? 1 : -1))
- return result
+ 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)).orderBy(PartTable.id).all(),
+ )
+ return rows.map(
+ (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part,
+ )
})
export const get = fn(
@@ -726,8 +781,11 @@ export namespace MessageV2 {
messageID: Identifier.schema("message"),
}),
async (input): Promise<WithParts> => {
+ const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get())
+ if (!row) throw new Error(`Message not found: ${input.messageID}`)
+ const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
return {
- info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
+ info,
parts: await parts(input.messageID),
}
},
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 8e7472e2f..e7532d200 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -63,17 +63,19 @@ export namespace SessionProcessor {
if (value.id in reasoningMap) {
continue
}
- reasoningMap[value.id] = {
+ const reasoningPart = {
id: Identifier.ascending("part"),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
- type: "reasoning",
+ type: "reasoning" as const,
text: "",
time: {
start: Date.now(),
},
metadata: value.providerMetadata,
}
+ reasoningMap[value.id] = reasoningPart
+ await Session.updatePart(reasoningPart)
break
case "reasoning-delta":
@@ -81,7 +83,13 @@ export namespace SessionProcessor {
const part = reasoningMap[value.id]
part.text += value.text
if (value.providerMetadata) part.metadata = value.providerMetadata
- if (part.text) await Session.updatePart({ part, delta: value.text })
+ await Session.updatePartDelta({
+ sessionID: part.sessionID,
+ messageID: part.messageID,
+ partID: part.id,
+ field: "text",
+ delta: value.text,
+ })
}
break
@@ -288,17 +296,20 @@ export namespace SessionProcessor {
},
metadata: value.providerMetadata,
}
+ await Session.updatePart(currentText)
break
case "text-delta":
if (currentText) {
currentText.text += value.text
if (value.providerMetadata) currentText.metadata = value.providerMetadata
- if (currentText.text)
- await Session.updatePart({
- part: currentText,
- delta: value.text,
- })
+ await Session.updatePartDelta({
+ sessionID: currentText.sessionID,
+ messageID: currentText.messageID,
+ partID: currentText.id,
+ field: "text",
+ delta: value.text,
+ })
}
break
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index be813e823..f705f209a 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -174,9 +174,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) {
@@ -1946,21 +1944,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
-
- const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
- draft.title = title
- },
- { touch: false },
- )
+ 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
+ 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 7afe44e2c..ef9c7e2aa 100644
--- a/packages/opencode/src/session/revert.ts
+++ b/packages/opencode/src/session/revert.ts
@@ -4,8 +4,9 @@ import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2"
import { Session } from "."
import { Log } from "../util/log"
-import { splitWhen } from "remeda"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { MessageTable, PartTable } from "./session.sql"
+import { Storage } from "@/storage/storage"
import { Bus } from "../bus"
import { SessionPrompt } from "./prompt"
import { SessionSummary } from "./summary"
@@ -65,13 +66,14 @@ export namespace SessionRevert {
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
@@ -83,39 +85,54 @@ 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) {
if (!session.revert) return
const sessionID = session.id
- let msgs = await Session.messages({ sessionID })
+ const msgs = await Session.messages({ sessionID })
const messageID = session.revert.messageID
- const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
- msgs = preserve
+ const preserve = [] as MessageV2.WithParts[]
+ const remove = [] as MessageV2.WithParts[]
+ let target: MessageV2.WithParts | undefined
+ for (const msg of msgs) {
+ if (msg.info.id < messageID) {
+ preserve.push(msg)
+ continue
+ }
+ if (msg.info.id > messageID) {
+ remove.push(msg)
+ continue
+ }
+ if (session.revert.partID) {
+ preserve.push(msg)
+ target = msg
+ continue
+ }
+ remove.push(msg)
+ }
for (const msg of remove) {
- await Storage.remove(["message", sessionID, msg.info.id])
+ Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
}
- const last = preserve.at(-1)
- if (session.revert.partID && last) {
+ if (session.revert.partID && target) {
const partID = session.revert.partID
- const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
- last.parts = preserveParts
- for (const part of removeParts) {
- await Storage.remove(["part", last.info.id, part.id])
- await Bus.publish(MessageV2.Event.PartRemoved, {
- sessionID: sessionID,
- messageID: last.info.id,
- partID: part.id,
- })
+ const removeStart = target.parts.findIndex((part) => part.id === partID)
+ if (removeStart >= 0) {
+ const preserveParts = target.parts.slice(0, removeStart)
+ const removeParts = target.parts.slice(removeStart)
+ target.parts = preserveParts
+ for (const part of removeParts) {
+ Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
+ await Bus.publish(MessageV2.Event.PartRemoved, {
+ sessionID: sessionID,
+ messageID: target.info.id,
+ partID: part.id,
+ })
+ }
}
}
- 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
new file mode 100644
index 000000000..9c5c72c4c
--- /dev/null
+++ b/packages/opencode/src/session/session.sql.ts
@@ -0,0 +1,88 @@
+import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
+import { ProjectTable } from "../project/project.sql"
+import type { MessageV2 } from "./message-v2"
+import type { Snapshot } from "@/snapshot"
+import type { PermissionNext } from "@/permission/next"
+import { Timestamps } from "@/storage/schema.sql"
+
+type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
+type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
+
+export const SessionTable = sqliteTable(
+ "session",
+ {
+ id: text().primaryKey(),
+ project_id: text()
+ .notNull()
+ .references(() => ProjectTable.id, { onDelete: "cascade" }),
+ 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: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(),
+ permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
+ ...Timestamps,
+ time_compacting: integer(),
+ time_archived: integer(),
+ },
+ (table) => [index("session_project_idx").on(table.project_id), index("session_parent_idx").on(table.parent_id)],
+)
+
+export const MessageTable = sqliteTable(
+ "message",
+ {
+ id: text().primaryKey(),
+ session_id: text()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type<InfoData>(),
+ },
+ (table) => [index("message_session_idx").on(table.session_id)],
+)
+
+export const PartTable = sqliteTable(
+ "part",
+ {
+ id: text().primaryKey(),
+ message_id: text()
+ .notNull()
+ .references(() => MessageTable.id, { onDelete: "cascade" }),
+ session_id: text().notNull(),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type<PartData>(),
+ },
+ (table) => [index("part_message_idx").on(table.message_id), index("part_session_idx").on(table.session_id)],
+)
+
+export const TodoTable = sqliteTable(
+ "todo",
+ {
+ session_id: text()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ content: text().notNull(),
+ status: text().notNull(),
+ priority: text().notNull(),
+ position: integer().notNull(),
+ ...Timestamps,
+ },
+ (table) => [
+ primaryKey({ columns: [table.session_id, table.position] }),
+ index("todo_session_idx").on(table.session_id),
+ ],
+)
+
+export const PermissionTable = sqliteTable("permission", {
+ project_id: text()
+ .primaryKey()
+ .references(() => ProjectTable.id, { onDelete: "cascade" }),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
+})
diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts
index b92fc9979..c3e14ddd6 100644
--- a/packages/opencode/src/session/summary.ts
+++ b/packages/opencode/src/session/summary.ts
@@ -90,12 +90,13 @@ export namespace SessionSummary {
async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) {
const diffs = await computeDiff({ messages: input.messages })
- 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,
- }
+ },
})
await Storage.write(["session_diff", input.sessionID], diffs)
Bus.publish(Session.Event.Diff, {
diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts
index aa7df7e98..ec2bcdda3 100644
--- a/packages/opencode/src/session/todo.ts
+++ b/packages/opencode/src/session/todo.ts
@@ -1,7 +1,8 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
-import { Storage } from "../storage/storage"
+import { Database, eq, asc } from "../storage/db"
+import { TodoTable } from "./session.sql"
export namespace Todo {
export const Info = z
@@ -9,7 +10,6 @@ export namespace Todo {
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
- id: z.string().describe("Unique identifier for the todo item"),
})
.meta({ ref: "Todo" })
export type Info = z.infer<typeof Info>
@@ -24,14 +24,33 @@ export namespace Todo {
),
}
- export async function update(input: { sessionID: string; todos: Info[] }) {
- await Storage.write(["todo", input.sessionID], input.todos)
+ export function update(input: { sessionID: string; todos: Info[] }) {
+ Database.transaction((db) => {
+ db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
+ if (input.todos.length === 0) return
+ db.insert(TodoTable)
+ .values(
+ input.todos.map((todo, position) => ({
+ session_id: input.sessionID,
+ content: todo.content,
+ status: todo.status,
+ priority: todo.priority,
+ position,
+ })),
+ )
+ .run()
+ })
Bus.publish(Event.Updated, input)
}
- export async function get(sessionID: string) {
- return Storage.read<Info[]>(["todo", sessionID])
- .then((x) => x || [])
- .catch(() => [])
+ export function get(sessionID: string) {
+ const rows = Database.use((db) =>
+ db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
+ )
+ return rows.map((row) => ({
+ content: row.content,
+ status: row.status,
+ priority: row.priority,
+ }))
}
}
diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts
index a3a229d1a..c36616b7e 100644
--- a/packages/opencode/src/share/share-next.ts
+++ b/packages/opencode/src/share/share-next.ts
@@ -4,7 +4,8 @@ import { ulid } from "ulid"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
-import { Storage } from "@/storage/storage"
+import { Database, eq } from "@/storage/db"
+import { SessionShareTable } from "./share.sql"
import { Log } from "@/util/log"
import type * as SDK from "@opencode-ai/sdk/v2"
@@ -77,17 +78,26 @@ export namespace ShareNext {
})
.then((x) => x.json())
.then((x) => x as { id: string; url: string; secret: string })
- await Storage.write(["session_share", sessionID], result)
+ Database.use((db) =>
+ db
+ .insert(SessionShareTable)
+ .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
+ .onConflictDoUpdate({
+ target: SessionShareTable.session_id,
+ set: { id: result.id, secret: result.secret, url: result.url },
+ })
+ .run(),
+ )
fullSync(sessionID)
return result
}
function get(sessionID: string) {
- return Storage.read<{
- id: string
- secret: string
- url: string
- }>(["session_share", sessionID])
+ const row = Database.use((db) =>
+ db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
+ )
+ if (!row) return
+ return { id: row.id, secret: row.secret, url: row.url }
}
type Data =
@@ -132,7 +142,7 @@ export namespace ShareNext {
const queued = queue.get(sessionID)
if (!queued) return
queue.delete(sessionID)
- const share = await get(sessionID).catch(() => undefined)
+ const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}/sync`, {
@@ -152,7 +162,7 @@ export namespace ShareNext {
export async function remove(sessionID: string) {
if (disabled) return
log.info("removing share", { sessionID })
- const share = await get(sessionID)
+ const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}`, {
method: "DELETE",
@@ -163,7 +173,7 @@ export namespace ShareNext {
secret: share.secret,
}),
})
- await Storage.remove(["session_share", sessionID])
+ 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
new file mode 100644
index 000000000..268d41a6f
--- /dev/null
+++ b/packages/opencode/src/share/share.sql.ts
@@ -0,0 +1,13 @@
+import { sqliteTable, text } from "drizzle-orm/sqlite-core"
+import { SessionTable } from "../session/session.sql"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const SessionShareTable = sqliteTable("session_share", {
+ session_id: text()
+ .primaryKey()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ id: text().notNull(),
+ secret: text().notNull(),
+ url: text().notNull(),
+ ...Timestamps,
+})
diff --git a/packages/opencode/src/share/share.ts b/packages/opencode/src/share/share.ts
deleted file mode 100644
index f7bf4b3fa..000000000
--- a/packages/opencode/src/share/share.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { Bus } from "../bus"
-import { Installation } from "../installation"
-import { Session } from "../session"
-import { MessageV2 } from "../session/message-v2"
-import { Log } from "../util/log"
-
-export namespace Share {
- const log = Log.create({ service: "share" })
-
- let queue: Promise<void> = Promise.resolve()
- const pending = new Map<string, any>()
-
- export async function sync(key: string, content: any) {
- if (disabled) return
- const [root, ...splits] = key.split("/")
- if (root !== "session") return
- const [sub, sessionID] = splits
- if (sub === "share") return
- const share = await Session.getShare(sessionID).catch(() => {})
- if (!share) return
- const { secret } = share
- pending.set(key, content)
- queue = queue
- .then(async () => {
- const content = pending.get(key)
- if (content === undefined) return
- pending.delete(key)
-
- return fetch(`${URL}/share_sync`, {
- method: "POST",
- body: JSON.stringify({
- sessionID: sessionID,
- secret,
- key: key,
- content,
- }),
- })
- })
- .then((x) => {
- if (x) {
- log.info("synced", {
- key: key,
- status: x.status,
- })
- }
- })
- }
-
- export function init() {
- Bus.subscribe(Session.Event.Updated, async (evt) => {
- await sync("session/info/" + evt.properties.info.id, evt.properties.info)
- })
- Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
- await sync("session/message/" + evt.properties.info.sessionID + "/" + evt.properties.info.id, evt.properties.info)
- })
- Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
- await sync(
- "session/part/" +
- evt.properties.part.sessionID +
- "/" +
- evt.properties.part.messageID +
- "/" +
- evt.properties.part.id,
- evt.properties.part,
- )
- })
- }
-
- export const URL =
- process.env["OPENCODE_API"] ??
- (Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
-
- const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
-
- export async function create(sessionID: string) {
- if (disabled) return { url: "", secret: "" }
- return fetch(`${URL}/share_create`, {
- method: "POST",
- body: JSON.stringify({ sessionID: sessionID }),
- })
- .then((x) => x.json())
- .then((x) => x as { url: string; secret: string })
- }
-
- export async function remove(sessionID: string, secret: string) {
- if (disabled) return {}
- return fetch(`${URL}/share_delete`, {
- method: "POST",
- body: JSON.stringify({ sessionID, secret }),
- }).then((x) => x.json())
- }
-}
diff --git a/packages/opencode/src/sql.d.ts b/packages/opencode/src/sql.d.ts
new file mode 100644
index 000000000..28cb1e264
--- /dev/null
+++ b/packages/opencode/src/sql.d.ts
@@ -0,0 +1,4 @@
+declare module "*.sql" {
+ const content: string
+ export default content
+}
diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts
new file mode 100644
index 000000000..50aa76384
--- /dev/null
+++ b/packages/opencode/src/storage/db.ts
@@ -0,0 +1,140 @@
+import { Database as BunDatabase } from "bun:sqlite"
+import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
+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"
+import { lazy } from "../util/lazy"
+import { Global } from "../global"
+import { Log } from "../util/log"
+import { NamedError } from "@opencode-ai/util/error"
+import z from "zod"
+import path from "path"
+import { readFileSync, readdirSync } from "fs"
+import * as schema from "./schema"
+
+declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
+
+export const NotFoundError = NamedError.create(
+ "NotFoundError",
+ z.object({
+ message: z.string(),
+ }),
+)
+
+const log = Log.create({ service: "db" })
+
+export namespace Database {
+ type Schema = typeof schema
+ export type Transaction = SQLiteTransaction<"sync", void, Schema>
+
+ type Client = SQLiteBunDatabase<Schema>
+
+ type Journal = { sql: string; timestamp: number }[]
+
+ function time(tag: string) {
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
+ if (!match) return 0
+ return Date.UTC(
+ Number(match[1]),
+ Number(match[2]) - 1,
+ Number(match[3]),
+ Number(match[4]),
+ Number(match[5]),
+ Number(match[6]),
+ )
+ }
+
+ function migrations(dir: string): Journal {
+ const dirs = readdirSync(dir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+
+ const sql = dirs
+ .map((name) => {
+ const file = path.join(dir, name, "migration.sql")
+ if (!Bun.file(file).size) return
+ return {
+ sql: readFileSync(file, "utf-8"),
+ timestamp: time(name),
+ }
+ })
+ .filter(Boolean) as Journal
+
+ return sql.sort((a, b) => a.timestamp - b.timestamp)
+ }
+
+ export const Client = lazy(() => {
+ log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
+
+ const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
+
+ sqlite.run("PRAGMA journal_mode = WAL")
+ sqlite.run("PRAGMA synchronous = NORMAL")
+ sqlite.run("PRAGMA busy_timeout = 5000")
+ sqlite.run("PRAGMA cache_size = -64000")
+ sqlite.run("PRAGMA foreign_keys = ON")
+
+ const db = drizzle({ client: sqlite, schema })
+
+ // Apply schema migrations
+ const entries =
+ typeof OPENCODE_MIGRATIONS !== "undefined"
+ ? OPENCODE_MIGRATIONS
+ : migrations(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)
+ }
+
+ return db
+ })
+
+ export type TxOrDb = Transaction | Client
+
+ const ctx = Context.create<{
+ tx: TxOrDb
+ effects: (() => void | Promise<void>)[]
+ }>("database")
+
+ export function use<T>(callback: (trx: TxOrDb) => T): T {
+ try {
+ return callback(ctx.use().tx)
+ } catch (err) {
+ if (err instanceof Context.NotFound) {
+ const effects: (() => void | Promise<void>)[] = []
+ const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
+ for (const effect of effects) effect()
+ return result
+ }
+ throw err
+ }
+ }
+
+ export function effect(fn: () => any | Promise<any>) {
+ try {
+ ctx.use().effects.push(fn)
+ } catch {
+ fn()
+ }
+ }
+
+ export function transaction<T>(callback: (tx: TxOrDb) => T): T {
+ try {
+ return callback(ctx.use().tx)
+ } catch (err) {
+ if (err instanceof Context.NotFound) {
+ const effects: (() => void | Promise<void>)[] = []
+ const result = Client().transaction((tx) => {
+ return ctx.provide({ tx, effects }, () => callback(tx))
+ })
+ for (const effect of effects) effect()
+ return result
+ }
+ throw err
+ }
+ }
+}
diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts
new file mode 100644
index 000000000..89d561188
--- /dev/null
+++ b/packages/opencode/src/storage/json-migration.ts
@@ -0,0 +1,437 @@
+import { Database } from "bun:sqlite"
+import { drizzle } from "drizzle-orm/bun-sqlite"
+import { Global } from "../global"
+import { Log } from "../util/log"
+import { ProjectTable } from "../project/project.sql"
+import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
+import { SessionShareTable } from "../share/share.sql"
+import path from "path"
+import { existsSync } from "fs"
+
+export namespace JsonMigration {
+ const log = Log.create({ service: "json-migration" })
+
+ export type Progress = {
+ current: number
+ total: number
+ label: string
+ }
+
+ type Options = {
+ progress?: (event: Progress) => void
+ }
+
+ export async function run(sqlite: Database, options?: Options) {
+ const storageDir = path.join(Global.Path.data, "storage")
+
+ if (!existsSync(storageDir)) {
+ log.info("storage directory does not exist, skipping migration")
+ return {
+ projects: 0,
+ sessions: 0,
+ messages: 0,
+ parts: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ errors: [] as string[],
+ }
+ }
+
+ log.info("starting json to sqlite migration", { storageDir })
+ const start = performance.now()
+
+ const db = drizzle({ client: sqlite })
+
+ // Optimize SQLite for bulk inserts
+ sqlite.exec("PRAGMA journal_mode = WAL")
+ sqlite.exec("PRAGMA synchronous = OFF")
+ sqlite.exec("PRAGMA cache_size = 10000")
+ sqlite.exec("PRAGMA temp_store = MEMORY")
+ const stats = {
+ projects: 0,
+ sessions: 0,
+ messages: 0,
+ parts: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ errors: [] as string[],
+ }
+ const orphans = {
+ sessions: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ }
+ const errs = stats.errors
+
+ const batchSize = 1000
+ const now = Date.now()
+
+ async function list(pattern: string) {
+ const items: string[] = []
+ const scan = new Bun.Glob(pattern)
+ for await (const file of scan.scan({ cwd: storageDir, absolute: true })) {
+ items.push(file)
+ }
+ return items
+ }
+
+ async function read(files: string[], start: number, end: number) {
+ const count = end - start
+ const tasks = new Array(count)
+ for (let i = 0; i < count; i++) {
+ tasks[i] = Bun.file(files[start + i]).json()
+ }
+ const results = await Promise.allSettled(tasks)
+ const items = new Array(count)
+ for (let i = 0; i < results.length; i++) {
+ const result = results[i]
+ if (result.status === "fulfilled") {
+ items[i] = result.value
+ continue
+ }
+ errs.push(`failed to read ${files[start + i]}: ${result.reason}`)
+ }
+ return items
+ }
+
+ function insert(values: any[], table: any, label: string) {
+ if (values.length === 0) return 0
+ try {
+ db.insert(table).values(values).onConflictDoNothing().run()
+ return values.length
+ } catch (e) {
+ errs.push(`failed to migrate ${label} batch: ${e}`)
+ return 0
+ }
+ }
+
+ // Pre-scan all files upfront to avoid repeated glob operations
+ log.info("scanning files...")
+ const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([
+ list("project/*.json"),
+ list("session/*/*.json"),
+ list("message/*/*.json"),
+ list("part/*/*.json"),
+ list("todo/*.json"),
+ list("permission/*.json"),
+ list("session_share/*.json"),
+ ])
+
+ log.info("file scan complete", {
+ projects: projectFiles.length,
+ sessions: sessionFiles.length,
+ messages: messageFiles.length,
+ parts: partFiles.length,
+ todos: todoFiles.length,
+ permissions: permFiles.length,
+ shares: shareFiles.length,
+ })
+
+ const total = Math.max(
+ 1,
+ projectFiles.length +
+ sessionFiles.length +
+ messageFiles.length +
+ partFiles.length +
+ todoFiles.length +
+ permFiles.length +
+ shareFiles.length,
+ )
+ const progress = options?.progress
+ let current = 0
+ const step = (label: string, count: number) => {
+ current = Math.min(total, current + count)
+ progress?.({ current, total, label })
+ }
+
+ progress?.({ current, total, label: "starting" })
+
+ sqlite.exec("BEGIN TRANSACTION")
+
+ // Migrate projects first (no FK deps)
+ const projectIds = new Set<string>()
+ const projectValues = [] as any[]
+ for (let i = 0; i < projectFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, projectFiles.length)
+ const batch = await read(projectFiles, i, end)
+ projectValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ if (!data?.id) {
+ errs.push(`project missing id: ${projectFiles[i + j]}`)
+ continue
+ }
+ projectIds.add(data.id)
+ projectValues.push({
+ id: data.id,
+ worktree: data.worktree ?? "/",
+ vcs: data.vcs,
+ name: data.name ?? undefined,
+ icon_url: data.icon?.url,
+ icon_color: data.icon?.color,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ time_initialized: data.time?.initialized,
+ sandboxes: data.sandboxes ?? [],
+ commands: data.commands,
+ })
+ }
+ stats.projects += insert(projectValues, ProjectTable, "project")
+ step("projects", end - i)
+ }
+ log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
+
+ // Migrate sessions (depends on projects)
+ const sessionIds = new Set<string>()
+ const sessionValues = [] as any[]
+ for (let i = 0; i < sessionFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, sessionFiles.length)
+ const batch = await read(sessionFiles, i, end)
+ sessionValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ if (!data?.id || !data?.projectID) {
+ errs.push(`session missing id or projectID: ${sessionFiles[i + j]}`)
+ continue
+ }
+ if (!projectIds.has(data.projectID)) {
+ orphans.sessions++
+ continue
+ }
+ sessionIds.add(data.id)
+ sessionValues.push({
+ id: data.id,
+ project_id: data.projectID,
+ parent_id: 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: data.revert ?? null,
+ permission: data.permission ?? null,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ time_compacting: data.time?.compacting ?? null,
+ time_archived: data.time?.archived ?? null,
+ })
+ }
+ stats.sessions += insert(sessionValues, SessionTable, "session")
+ step("sessions", end - i)
+ }
+ log.info("migrated sessions", { count: stats.sessions })
+ if (orphans.sessions > 0) {
+ log.warn("skipped orphaned sessions", { count: orphans.sessions })
+ }
+
+ // Migrate messages using pre-scanned file map
+ const allMessageFiles = [] as string[]
+ const allMessageSessions = [] as string[]
+ const messageSessions = new Map<string, string>()
+ for (const file of messageFiles) {
+ const sessionID = path.basename(path.dirname(file))
+ if (!sessionIds.has(sessionID)) continue
+ allMessageFiles.push(file)
+ allMessageSessions.push(sessionID)
+ }
+
+ for (let i = 0; i < allMessageFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, allMessageFiles.length)
+ const batch = await read(allMessageFiles, i, end)
+ const values = new Array(batch.length)
+ let count = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const file = allMessageFiles[i + j]
+ const id = data.id ?? path.basename(file, ".json")
+ if (!id) {
+ errs.push(`message missing id: ${file}`)
+ continue
+ }
+ const sessionID = allMessageSessions[i + j]
+ messageSessions.set(id, sessionID)
+ const rest = data
+ delete rest.id
+ delete rest.sessionID
+ values[count++] = {
+ id,
+ session_id: sessionID,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ data: rest,
+ }
+ }
+ values.length = count
+ stats.messages += insert(values, MessageTable, "message")
+ step("messages", end - i)
+ }
+ log.info("migrated messages", { count: stats.messages })
+
+ // Migrate parts using pre-scanned file map
+ for (let i = 0; i < partFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, partFiles.length)
+ const batch = await read(partFiles, i, end)
+ const values = new Array(batch.length)
+ let count = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const file = partFiles[i + j]
+ const id = data.id ?? path.basename(file, ".json")
+ const messageID = data.messageID ?? path.basename(path.dirname(file))
+ if (!id || !messageID) {
+ errs.push(`part missing id/messageID/sessionID: ${file}`)
+ continue
+ }
+ const sessionID = messageSessions.get(messageID)
+ if (!sessionID) {
+ errs.push(`part missing message session: ${file}`)
+ continue
+ }
+ if (!sessionIds.has(sessionID)) continue
+ const rest = data
+ delete rest.id
+ delete rest.messageID
+ delete rest.sessionID
+ values[count++] = {
+ id,
+ message_id: messageID,
+ session_id: sessionID,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ data: rest,
+ }
+ }
+ values.length = count
+ stats.parts += insert(values, PartTable, "part")
+ step("parts", end - i)
+ }
+ log.info("migrated parts", { count: stats.parts })
+
+ // Migrate todos
+ const todoSessions = todoFiles.map((file) => path.basename(file, ".json"))
+ for (let i = 0; i < todoFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, todoFiles.length)
+ const batch = await read(todoFiles, i, end)
+ const values = [] as any[]
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const sessionID = todoSessions[i + j]
+ if (!sessionIds.has(sessionID)) {
+ orphans.todos++
+ continue
+ }
+ if (!Array.isArray(data)) {
+ errs.push(`todo not an array: ${todoFiles[i + j]}`)
+ continue
+ }
+ for (let position = 0; position < data.length; position++) {
+ const todo = data[position]
+ if (!todo?.content || !todo?.status || !todo?.priority) continue
+ values.push({
+ session_id: sessionID,
+ content: todo.content,
+ status: todo.status,
+ priority: todo.priority,
+ position,
+ time_created: now,
+ time_updated: now,
+ })
+ }
+ }
+ stats.todos += insert(values, TodoTable, "todo")
+ step("todos", end - i)
+ }
+ log.info("migrated todos", { count: stats.todos })
+ if (orphans.todos > 0) {
+ log.warn("skipped orphaned todos", { count: orphans.todos })
+ }
+
+ // Migrate permissions
+ const permProjects = permFiles.map((file) => path.basename(file, ".json"))
+ const permValues = [] as any[]
+ for (let i = 0; i < permFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, permFiles.length)
+ const batch = await read(permFiles, i, end)
+ permValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const projectID = permProjects[i + j]
+ if (!projectIds.has(projectID)) {
+ orphans.permissions++
+ continue
+ }
+ permValues.push({ project_id: projectID, data })
+ }
+ stats.permissions += insert(permValues, PermissionTable, "permission")
+ step("permissions", end - i)
+ }
+ log.info("migrated permissions", { count: stats.permissions })
+ if (orphans.permissions > 0) {
+ log.warn("skipped orphaned permissions", { count: orphans.permissions })
+ }
+
+ // Migrate session shares
+ const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
+ const shareValues = [] as any[]
+ for (let i = 0; i < shareFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, shareFiles.length)
+ const batch = await read(shareFiles, i, end)
+ shareValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const sessionID = shareSessions[i + j]
+ if (!sessionIds.has(sessionID)) {
+ orphans.shares++
+ continue
+ }
+ if (!data?.id || !data?.secret || !data?.url) {
+ errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`)
+ continue
+ }
+ shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url })
+ }
+ stats.shares += insert(shareValues, SessionShareTable, "session_share")
+ step("shares", end - i)
+ }
+ log.info("migrated session shares", { count: stats.shares })
+ if (orphans.shares > 0) {
+ log.warn("skipped orphaned session shares", { count: orphans.shares })
+ }
+
+ sqlite.exec("COMMIT")
+
+ log.info("json migration complete", {
+ projects: stats.projects,
+ sessions: stats.sessions,
+ messages: stats.messages,
+ parts: stats.parts,
+ todos: stats.todos,
+ permissions: stats.permissions,
+ shares: stats.shares,
+ errorCount: stats.errors.length,
+ duration: Math.round(performance.now() - start),
+ })
+
+ if (stats.errors.length > 0) {
+ log.warn("migration errors", { errors: stats.errors.slice(0, 20) })
+ }
+
+ progress?.({ current: total, total, label: "complete" })
+
+ return stats
+ }
+}
diff --git a/packages/opencode/src/storage/schema.sql.ts b/packages/opencode/src/storage/schema.sql.ts
new file mode 100644
index 000000000..ead3518de
--- /dev/null
+++ b/packages/opencode/src/storage/schema.sql.ts
@@ -0,0 +1,10 @@
+import { integer } from "drizzle-orm/sqlite-core"
+
+export const Timestamps = {
+ time_created: integer()
+ .notNull()
+ .$default(() => Date.now()),
+ time_updated: integer()
+ .notNull()
+ .$onUpdate(() => Date.now()),
+}
diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts
new file mode 100644
index 000000000..7961b0e38
--- /dev/null
+++ b/packages/opencode/src/storage/schema.ts
@@ -0,0 +1,4 @@
+export { ControlAccountTable } from "../control/control.sql"
+export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
+export { SessionShareTable } from "../share/share.sql"
+export { ProjectTable } from "../project/project.sql"
diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts
index 0cc6d8d5c..55643dc6a 100644
--- a/packages/opencode/src/util/lazy.ts
+++ b/packages/opencode/src/util/lazy.ts
@@ -4,9 +4,14 @@ export function lazy<T>(fn: () => T) {
const result = (): T => {
if (loaded) return value as T
- loaded = true
- value = fn()
- return value as T
+ try {
+ value = fn()
+ loaded = true
+ return value as T
+ } catch (e) {
+ // Don't mark as loaded if initialization failed
+ throw e
+ }
}
result.reset = () => {
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 85d7f6d0e..d85a0843f 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -7,7 +7,8 @@ import { Global } from "../global"
import { Instance } from "../project/instance"
import { InstanceBootstrap } from "../project/bootstrap"
import { Project } from "../project/project"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { ProjectTable } from "../project/project.sql"
import { fn } from "../util/fn"
import { Log } from "../util/log"
import { BusEvent } from "@/bus/bus-event"
@@ -307,7 +308,8 @@ export namespace Worktree {
}
async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) {
- const project = await Storage.read<Project.Info>(["project", input.projectID]).catch(() => undefined)
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
+ const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = await runStartScript(directory, startup, "project")
if (!ok) return false
diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts
index 8e139ff59..1145a1357 100644
--- a/packages/opencode/test/acp/event-subscription.test.ts
+++ b/packages/opencode/test/acp/event-subscription.test.ts
@@ -122,12 +122,20 @@ function createFakeAgent() {
messages: async () => {
return { data: [] }
},
- message: async () => {
+ message: async (params?: any) => {
+ // Return a message with parts that can be looked up by partID
return {
data: {
info: {
role: "assistant",
},
+ parts: [
+ {
+ id: params?.messageID ? `${params.messageID}_part` : "part_1",
+ type: "text",
+ text: "",
+ },
+ ],
},
}
},
@@ -193,7 +201,7 @@ function createFakeAgent() {
}
describe("acp.agent event subscription", () => {
- test("routes message.part.updated by the event sessionID (no cross-session pollution)", async () => {
+ test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -207,14 +215,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionB,
- messageID: "msg_1",
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionB,
+ messageID: "msg_1",
+ partID: "msg_1_part",
+ field: "text",
delta: "hello",
},
},
@@ -230,7 +236,7 @@ describe("acp.agent event subscription", () => {
})
})
- test("keeps concurrent sessions isolated when message.part.updated events are interleaved", async () => {
+ test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -248,14 +254,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionId,
- messageID,
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionId,
+ messageID,
+ partID: `${messageID}_part`,
+ field: "text",
delta,
},
},
@@ -402,14 +406,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionB,
- messageID: "msg_b",
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionB,
+ messageID: "msg_b",
+ partID: "msg_b_part",
+ field: "text",
delta: "session_b_message",
},
},
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index 29f1efa40..add333204 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -2,7 +2,6 @@ import { test, expect } from "bun:test"
import os from "os"
import { PermissionNext } from "../../src/permission/next"
import { Instance } from "../../src/project/instance"
-import { Storage } from "../../src/storage/storage"
import { tmpdir } from "../fixture/fixture"
// fromConfig tests
diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts
index f0d0272af..e94ed8bf1 100644
--- a/packages/opencode/test/preload.ts
+++ b/packages/opencode/test/preload.ts
@@ -1,63 +1,70 @@
// IMPORTANT: Set env vars BEFORE any imports from src/ directory
// xdg-basedir reads env vars at import time, so we must set these first
-import os from "os"
-import path from "path"
-import fs from "fs/promises"
-import fsSync from "fs"
-import { afterAll } from "bun:test"
-
-const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
-await fs.mkdir(dir, { recursive: true })
+import os from "os";
+import path from "path";
+import fs from "fs/promises";
+import fsSync from "fs";
+import { afterAll } from "bun:test";
+
+// Set XDG env vars FIRST, before any src/ imports
+const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid);
+await fs.mkdir(dir, { recursive: true });
afterAll(() => {
- fsSync.rmSync(dir, { recursive: true, force: true })
-})
+ fsSync.rmSync(dir, { recursive: true, force: true });
+});
+
+process.env["XDG_DATA_HOME"] = path.join(dir, "share");
+process.env["XDG_CACHE_HOME"] = path.join(dir, "cache");
+process.env["XDG_CONFIG_HOME"] = path.join(dir, "config");
+process.env["XDG_STATE_HOME"] = path.join(dir, "state");
+process.env["OPENCODE_MODELS_PATH"] = path.join(
+ import.meta.dir,
+ "tool",
+ "fixtures",
+ "models-api.json",
+);
+
// Set test home directory to isolate tests from user's actual home directory
// This prevents tests from picking up real user configs/skills from ~/.claude/skills
-const testHome = path.join(dir, "home")
-await fs.mkdir(testHome, { recursive: true })
-process.env["OPENCODE_TEST_HOME"] = testHome
+const testHome = path.join(dir, "home");
+await fs.mkdir(testHome, { recursive: true });
+process.env["OPENCODE_TEST_HOME"] = testHome;
// Set test managed config directory to isolate tests from system managed settings
-const testManagedConfigDir = path.join(dir, "managed")
-process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
-
-process.env["XDG_DATA_HOME"] = path.join(dir, "share")
-process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
-process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
-process.env["XDG_STATE_HOME"] = path.join(dir, "state")
-process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
+const testManagedConfigDir = path.join(dir, "managed");
+process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir;
// Write the cache version file to prevent global/index.ts from clearing the cache
-const cacheDir = path.join(dir, "cache", "opencode")
-await fs.mkdir(cacheDir, { recursive: true })
-await fs.writeFile(path.join(cacheDir, "version"), "14")
+const cacheDir = path.join(dir, "cache", "opencode");
+await fs.mkdir(cacheDir, { recursive: true });
+await fs.writeFile(path.join(cacheDir, "version"), "14");
// Clear provider env vars to ensure clean test state
-delete process.env["ANTHROPIC_API_KEY"]
-delete process.env["OPENAI_API_KEY"]
-delete process.env["GOOGLE_API_KEY"]
-delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"]
-delete process.env["AZURE_OPENAI_API_KEY"]
-delete process.env["AWS_ACCESS_KEY_ID"]
-delete process.env["AWS_PROFILE"]
-delete process.env["AWS_REGION"]
-delete process.env["AWS_BEARER_TOKEN_BEDROCK"]
-delete process.env["OPENROUTER_API_KEY"]
-delete process.env["GROQ_API_KEY"]
-delete process.env["MISTRAL_API_KEY"]
-delete process.env["PERPLEXITY_API_KEY"]
-delete process.env["TOGETHER_API_KEY"]
-delete process.env["XAI_API_KEY"]
-delete process.env["DEEPSEEK_API_KEY"]
-delete process.env["FIREWORKS_API_KEY"]
-delete process.env["CEREBRAS_API_KEY"]
-delete process.env["SAMBANOVA_API_KEY"]
+delete process.env["ANTHROPIC_API_KEY"];
+delete process.env["OPENAI_API_KEY"];
+delete process.env["GOOGLE_API_KEY"];
+delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"];
+delete process.env["AZURE_OPENAI_API_KEY"];
+delete process.env["AWS_ACCESS_KEY_ID"];
+delete process.env["AWS_PROFILE"];
+delete process.env["AWS_REGION"];
+delete process.env["AWS_BEARER_TOKEN_BEDROCK"];
+delete process.env["OPENROUTER_API_KEY"];
+delete process.env["GROQ_API_KEY"];
+delete process.env["MISTRAL_API_KEY"];
+delete process.env["PERPLEXITY_API_KEY"];
+delete process.env["TOGETHER_API_KEY"];
+delete process.env["XAI_API_KEY"];
+delete process.env["DEEPSEEK_API_KEY"];
+delete process.env["FIREWORKS_API_KEY"];
+delete process.env["CEREBRAS_API_KEY"];
+delete process.env["SAMBANOVA_API_KEY"];
// Now safe to import from src/
-const { Log } = await import("../src/util/log")
+const { Log } = await import("../src/util/log");
Log.init({
print: false,
dev: true,
level: "DEBUG",
-})
+});
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 581c63b56..19f9821c4 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, mock, test } from "bun:test"
-import type { Project as ProjectNS } from "../../src/project/project"
+import { Project } from "../../src/project/project"
import { Log } from "../../src/util/log"
-import { Storage } from "../../src/storage/storage"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
+import { GlobalBus } from "../../src/bus/global"
Log.init({ print: false })
@@ -152,38 +152,51 @@ describe("Project.fromDirectory with worktrees", () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
- const worktreePath = path.join(tmp.path, "..", "worktree-test")
- await $`git worktree add ${worktreePath} -b test-branch`.cwd(tmp.path).quiet()
+ const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
+ try {
+ await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
- const { project, sandbox } = await p.fromDirectory(worktreePath)
+ const { project, sandbox } = await p.fromDirectory(worktreePath)
- expect(project.worktree).toBe(tmp.path)
- expect(sandbox).toBe(worktreePath)
- expect(project.sandboxes).toContain(worktreePath)
- expect(project.sandboxes).not.toContain(tmp.path)
-
- await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet()
+ expect(project.worktree).toBe(tmp.path)
+ expect(sandbox).toBe(worktreePath)
+ expect(project.sandboxes).toContain(worktreePath)
+ expect(project.sandboxes).not.toContain(tmp.path)
+ } finally {
+ await $`git worktree remove ${worktreePath}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ }
})
test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
- const worktree1 = path.join(tmp.path, "..", "worktree-1")
- const worktree2 = path.join(tmp.path, "..", "worktree-2")
- await $`git worktree add ${worktree1} -b branch-1`.cwd(tmp.path).quiet()
- await $`git worktree add ${worktree2} -b branch-2`.cwd(tmp.path).quiet()
-
- await p.fromDirectory(worktree1)
- const { project } = await p.fromDirectory(worktree2)
+ const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
+ const worktree2 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt2")
+ try {
+ await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
+ await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
- expect(project.worktree).toBe(tmp.path)
- expect(project.sandboxes).toContain(worktree1)
- expect(project.sandboxes).toContain(worktree2)
- expect(project.sandboxes).not.toContain(tmp.path)
+ await p.fromDirectory(worktree1)
+ const { project } = await p.fromDirectory(worktree2)
- await $`git worktree remove ${worktree1}`.cwd(tmp.path).quiet()
- await $`git worktree remove ${worktree2}`.cwd(tmp.path).quiet()
+ expect(project.worktree).toBe(tmp.path)
+ expect(project.sandboxes).toContain(worktree1)
+ expect(project.sandboxes).toContain(worktree2)
+ expect(project.sandboxes).not.toContain(tmp.path)
+ } finally {
+ await $`git worktree remove ${worktree1}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ await $`git worktree remove ${worktree2}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ }
})
})
@@ -198,11 +211,12 @@ describe("Project.discover", () => {
await p.discover(project)
- const updated = await Storage.read<ProjectNS.Info>(["project", project.id])
- expect(updated.icon).toBeDefined()
- expect(updated.icon?.url).toStartWith("data:")
- expect(updated.icon?.url).toContain("base64")
- expect(updated.icon?.color).toBeUndefined()
+ const updated = Project.get(project.id)
+ expect(updated).toBeDefined()
+ expect(updated!.icon).toBeDefined()
+ expect(updated!.icon?.url).toStartWith("data:")
+ expect(updated!.icon?.url).toContain("base64")
+ expect(updated!.icon?.color).toBeUndefined()
})
test("should not discover non-image files", async () => {
@@ -214,7 +228,120 @@ describe("Project.discover", () => {
await p.discover(project)
- const updated = await Storage.read<ProjectNS.Info>(["project", project.id])
- expect(updated.icon).toBeUndefined()
+ const updated = Project.get(project.id)
+ expect(updated).toBeDefined()
+ expect(updated!.icon).toBeUndefined()
+ })
+})
+
+describe("Project.update", () => {
+ test("should update name", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ name: "New Project Name",
+ })
+
+ expect(updated.name).toBe("New Project Name")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.name).toBe("New Project Name")
+ })
+
+ test("should update icon url", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ icon: { url: "https://example.com/icon.png" },
+ })
+
+ expect(updated.icon?.url).toBe("https://example.com/icon.png")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
+ })
+
+ test("should update icon color", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ icon: { color: "#ff0000" },
+ })
+
+ expect(updated.icon?.color).toBe("#ff0000")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.icon?.color).toBe("#ff0000")
+ })
+
+ test("should update commands", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ commands: { start: "npm run dev" },
+ })
+
+ expect(updated.commands?.start).toBe("npm run dev")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.commands?.start).toBe("npm run dev")
+ })
+
+ test("should throw error when project not found", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await expect(
+ Project.update({
+ projectID: "nonexistent-project-id",
+ name: "Should Fail",
+ }),
+ ).rejects.toThrow("Project not found: nonexistent-project-id")
+ })
+
+ test("should emit GlobalBus event on update", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ let eventFired = false
+ let eventPayload: any = null
+
+ GlobalBus.on("event", (data) => {
+ eventFired = true
+ eventPayload = data
+ })
+
+ await Project.update({
+ projectID: project.id,
+ name: "Updated Name",
+ })
+
+ expect(eventFired).toBe(true)
+ expect(eventPayload.payload.type).toBe("project.updated")
+ expect(eventPayload.payload.properties.name).toBe("Updated Name")
+ })
+
+ test("should update multiple fields at once", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ name: "Multi Update",
+ icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
+ commands: { start: "make start" },
+ })
+
+ expect(updated.name).toBe("Multi Update")
+ expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
+ expect(updated.icon?.color).toBe("#00ff00")
+ expect(updated.commands?.start).toBe("make start")
})
})
diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts
new file mode 100644
index 000000000..ff05d6d05
--- /dev/null
+++ b/packages/opencode/test/storage/json-migration.test.ts
@@ -0,0 +1,687 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test"
+import { Database } from "bun:sqlite"
+import { drizzle } from "drizzle-orm/bun-sqlite"
+import { migrate } from "drizzle-orm/bun-sqlite/migrator"
+import path from "path"
+import fs from "fs/promises"
+import { readFileSync, readdirSync } from "fs"
+import { JsonMigration } from "../../src/storage/json-migration"
+import { Global } from "../../src/global"
+import { ProjectTable } from "../../src/project/project.sql"
+import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
+import { SessionShareTable } from "../../src/share/share.sql"
+
+// Test fixtures
+const fixtures = {
+ project: {
+ id: "proj_test123abc",
+ name: "Test Project",
+ worktree: "/test/path",
+ vcs: "git" as const,
+ sandboxes: [],
+ },
+ session: {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "test-session",
+ directory: "/test/path",
+ title: "Test Session",
+ version: "1.0.0",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ },
+ message: {
+ id: "msg_test789ghi",
+ sessionID: "ses_test456def",
+ role: "user" as const,
+ agent: "default",
+ model: { providerID: "openai", modelID: "gpt-4" },
+ time: { created: 1700000000000 },
+ },
+ part: {
+ id: "prt_testabc123",
+ messageID: "msg_test789ghi",
+ sessionID: "ses_test456def",
+ type: "text" as const,
+ text: "Hello, world!",
+ },
+}
+
+// Helper to create test storage directory structure
+async function setupStorageDir() {
+ const storageDir = path.join(Global.Path.data, "storage")
+ await fs.rm(storageDir, { recursive: true, force: true })
+ await fs.mkdir(path.join(storageDir, "project"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session_diff"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "todo"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "permission"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session_share"), { recursive: true })
+ // Create legacy marker to indicate JSON storage exists
+ await Bun.write(path.join(storageDir, "migration"), "1")
+ return storageDir
+}
+
+async function writeProject(storageDir: string, project: Record<string, unknown>) {
+ await Bun.write(path.join(storageDir, "project", `${project.id}.json`), JSON.stringify(project))
+}
+
+async function writeSession(storageDir: string, projectID: string, session: Record<string, unknown>) {
+ await Bun.write(path.join(storageDir, "session", projectID, `${session.id}.json`), JSON.stringify(session))
+}
+
+// Helper to create in-memory test database with schema
+function createTestDb() {
+ const sqlite = new Database(":memory:")
+ sqlite.exec("PRAGMA foreign_keys = ON")
+
+ // Apply schema migrations using drizzle migrate
+ const dir = path.join(import.meta.dirname, "../../migration")
+ const entries = readdirSync(dir, { withFileTypes: true })
+ const migrations = entries
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => ({
+ sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
+ timestamp: Number(entry.name.split("_")[0]),
+ }))
+ .sort((a, b) => a.timestamp - b.timestamp)
+ migrate(drizzle({ client: sqlite }), migrations)
+
+ return sqlite
+}
+
+describe("JSON to SQLite migration", () => {
+ let storageDir: string
+ let sqlite: Database
+
+ beforeEach(async () => {
+ storageDir = await setupStorageDir()
+ sqlite = createTestDb()
+ })
+
+ afterEach(async () => {
+ sqlite.close()
+ await fs.rm(storageDir, { recursive: true, force: true })
+ })
+
+ test("migrates project", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Test Project",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: ["/test/sandbox"],
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_test123abc")
+ expect(projects[0].worktree).toBe("/test/path")
+ expect(projects[0].name).toBe("Test Project")
+ expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
+ })
+
+ test("migrates project with commands", async () => {
+ await writeProject(storageDir, {
+ id: "proj_with_commands",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Project With Commands",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: ["/test/sandbox"],
+ commands: { start: "npm run dev" },
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_with_commands")
+ expect(projects[0].commands).toEqual({ start: "npm run dev" })
+ })
+
+ test("migrates project without commands field", async () => {
+ await writeProject(storageDir, {
+ id: "proj_no_commands",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Project Without Commands",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: [],
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_no_commands")
+ expect(projects[0].commands).toBeNull()
+ })
+
+ test("migrates session with individual columns", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/test/path",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ await writeSession(storageDir, "proj_test123abc", {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "test-session",
+ directory: "/test/dir",
+ title: "Test Session Title",
+ version: "1.0.0",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ summary: { additions: 10, deletions: 5, files: 3 },
+ share: { url: "https://example.com/share" },
+ })
+
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const sessions = db.select().from(SessionTable).all()
+ expect(sessions.length).toBe(1)
+ expect(sessions[0].id).toBe("ses_test456def")
+ 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)
+ expect(sessions[0].summary_deletions).toBe(5)
+ expect(sessions[0].share_url).toBe("https://example.com/share")
+ })
+
+ test("migrates messages and parts", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
+ JSON.stringify({ ...fixtures.message }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
+ JSON.stringify({ ...fixtures.part }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.messages).toBe(1)
+ expect(stats?.parts).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const messages = db.select().from(MessageTable).all()
+ expect(messages.length).toBe(1)
+ expect(messages[0].id).toBe("msg_test789ghi")
+
+ const parts = db.select().from(PartTable).all()
+ expect(parts.length).toBe(1)
+ expect(parts[0].id).toBe("prt_testabc123")
+ })
+
+ test("migrates legacy parts without ids in body", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
+ JSON.stringify({
+ role: "user",
+ agent: "default",
+ model: { providerID: "openai", modelID: "gpt-4" },
+ time: { created: 1700000000000 },
+ }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
+ JSON.stringify({
+ type: "text",
+ text: "Hello, world!",
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.messages).toBe(1)
+ expect(stats?.parts).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const messages = db.select().from(MessageTable).all()
+ expect(messages.length).toBe(1)
+ expect(messages[0].id).toBe("msg_test789ghi")
+ expect(messages[0].session_id).toBe("ses_test456def")
+ expect(messages[0].data).not.toHaveProperty("id")
+ expect(messages[0].data).not.toHaveProperty("sessionID")
+
+ const parts = db.select().from(PartTable).all()
+ expect(parts.length).toBe(1)
+ expect(parts[0].id).toBe("prt_testabc123")
+ expect(parts[0].message_id).toBe("msg_test789ghi")
+ expect(parts[0].session_id).toBe("ses_test456def")
+ expect(parts[0].data).not.toHaveProperty("id")
+ expect(parts[0].data).not.toHaveProperty("messageID")
+ expect(parts[0].data).not.toHaveProperty("sessionID")
+ })
+
+ test("skips orphaned sessions (no parent project)", async () => {
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
+ JSON.stringify({
+ id: "ses_orphan",
+ projectID: "proj_nonexistent",
+ slug: "orphan",
+ directory: "/",
+ title: "Orphan",
+ version: "1.0.0",
+ time: { created: Date.now(), updated: Date.now() },
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.sessions).toBe(0)
+ })
+
+ test("is idempotent (running twice doesn't duplicate)", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ await JsonMigration.run(sqlite)
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
+ })
+
+ test("migrates todos", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ // Create todo file (named by sessionID, contains array of todos)
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ {
+ id: "todo_1",
+ content: "First todo",
+ status: "pending",
+ priority: "high",
+ },
+ {
+ id: "todo_2",
+ content: "Second todo",
+ status: "completed",
+ priority: "medium",
+ },
+ ]),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.todos).toBe(2)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+ expect(todos.length).toBe(2)
+ expect(todos[0].content).toBe("First todo")
+ expect(todos[0].status).toBe("pending")
+ expect(todos[0].priority).toBe("high")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("Second todo")
+ expect(todos[1].position).toBe(1)
+ })
+
+ test("todos are ordered by position", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "Third", status: "pending", priority: "low" },
+ { content: "First", status: "pending", priority: "high" },
+ { content: "Second", status: "in_progress", priority: "medium" },
+ ]),
+ )
+
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+
+ expect(todos.length).toBe(3)
+ expect(todos[0].content).toBe("Third")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("First")
+ expect(todos[1].position).toBe(1)
+ expect(todos[2].content).toBe("Second")
+ expect(todos[2].position).toBe(2)
+ })
+
+ test("migrates permissions", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ // Create permission file (named by projectID, contains array of rules)
+ const permissionData = [
+ { permission: "file.read", pattern: "/test/file1.ts", action: "allow" as const },
+ { permission: "file.write", pattern: "/test/file2.ts", action: "ask" as const },
+ { permission: "command.run", pattern: "npm install", action: "deny" as const },
+ ]
+ await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.permissions).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const permissions = db.select().from(PermissionTable).all()
+ expect(permissions.length).toBe(1)
+ expect(permissions[0].project_id).toBe("proj_test123abc")
+ expect(permissions[0].data).toEqual(permissionData)
+ })
+
+ test("migrates session shares", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ // Create session share file (named by sessionID)
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({
+ id: "share_123",
+ secret: "supersecretkey",
+ url: "https://share.example.com/ses_test456def",
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.shares).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const shares = db.select().from(SessionShareTable).all()
+ expect(shares.length).toBe(1)
+ expect(shares[0].session_id).toBe("ses_test456def")
+ expect(shares[0].id).toBe("share_123")
+ expect(shares[0].secret).toBe("supersecretkey")
+ expect(shares[0].url).toBe("https://share.example.com/ses_test456def")
+ })
+
+ test("returns empty stats when storage directory does not exist", async () => {
+ await fs.rm(storageDir, { recursive: true, force: true })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.projects).toBe(0)
+ expect(stats.sessions).toBe(0)
+ expect(stats.messages).toBe(0)
+ expect(stats.parts).toBe(0)
+ expect(stats.todos).toBe(0)
+ expect(stats.permissions).toBe(0)
+ expect(stats.shares).toBe(0)
+ expect(stats.errors).toEqual([])
+ })
+
+ test("continues when a JSON file is unreadable and records an error", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.projects).toBe(1)
+ expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_test123abc")
+ })
+
+ test("skips invalid todo entries while preserving source positions", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "keep-0", status: "pending", priority: "high" },
+ { content: "drop-1", priority: "low" },
+ { content: "keep-2", status: "completed", priority: "medium" },
+ ]),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+ expect(stats.todos).toBe(2)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+ expect(todos.length).toBe(2)
+ expect(todos[0].content).toBe("keep-0")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("keep-2")
+ expect(todos[1].position).toBe(2)
+ })
+
+ test("skips orphaned todos, permissions, and shares", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([{ content: "valid", status: "pending", priority: "high" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_missing.json"),
+ JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_test123abc.json"),
+ JSON.stringify([{ permission: "file.read" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_missing.json"),
+ JSON.stringify([{ permission: "file.write" }]),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_missing.json"),
+ JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.todos).toBe(1)
+ expect(stats.permissions).toBe(1)
+ expect(stats.shares).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ expect(db.select().from(TodoTable).all().length).toBe(1)
+ expect(db.select().from(PermissionTable).all().length).toBe(1)
+ expect(db.select().from(SessionShareTable).all().length).toBe(1)
+ })
+
+ test("handles mixed corruption and partial validity in one migration run", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/ok",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: [],
+ })
+ await Bun.write(
+ path.join(storageDir, "project", "proj_missing_id.json"),
+ JSON.stringify({ worktree: "/bad", sandboxes: [] }),
+ )
+ await Bun.write(path.join(storageDir, "project", "proj_broken.json"), "{ nope")
+
+ await writeSession(storageDir, "proj_test123abc", {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "ok",
+ directory: "/ok",
+ title: "Ok",
+ version: "1",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ })
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_missing_project.json"),
+ JSON.stringify({
+ id: "ses_missing_project",
+ slug: "bad",
+ directory: "/bad",
+ title: "Bad",
+ version: "1",
+ }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
+ JSON.stringify({
+ id: "ses_orphan",
+ projectID: "proj_missing",
+ slug: "orphan",
+ directory: "/bad",
+ title: "Orphan",
+ version: "1",
+ }),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_ok.json"),
+ JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
+ )
+ await Bun.write(path.join(storageDir, "message", "ses_test456def", "msg_broken.json"), "{ nope")
+ await Bun.write(
+ path.join(storageDir, "message", "ses_missing", "msg_orphan.json"),
+ JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "part", "msg_ok", "part_ok.json"),
+ JSON.stringify({ type: "text", text: "ok" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_missing", "part_missing_message.json"),
+ JSON.stringify({ type: "text", text: "bad" }),
+ )
+ await Bun.write(path.join(storageDir, "part", "msg_ok", "part_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "ok", status: "pending", priority: "high" },
+ { content: "skip", status: "pending" },
+ ]),
+ )
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_missing.json"),
+ JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
+ )
+ await Bun.write(path.join(storageDir, "todo", "ses_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_test123abc.json"),
+ JSON.stringify([{ permission: "file.read" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_missing.json"),
+ JSON.stringify([{ permission: "file.write" }]),
+ )
+ await Bun.write(path.join(storageDir, "permission", "proj_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_missing.json"),
+ JSON.stringify({ id: "share_orphan", secret: "secret", url: "https://missing.example.com" }),
+ )
+ await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.projects).toBe(1)
+ expect(stats.sessions).toBe(1)
+ expect(stats.messages).toBe(1)
+ expect(stats.parts).toBe(1)
+ expect(stats.todos).toBe(1)
+ expect(stats.permissions).toBe(1)
+ expect(stats.shares).toBe(1)
+ expect(stats.errors.length).toBeGreaterThanOrEqual(6)
+
+ const db = drizzle({ client: sqlite })
+ expect(db.select().from(ProjectTable).all().length).toBe(1)
+ expect(db.select().from(SessionTable).all().length).toBe(1)
+ expect(db.select().from(MessageTable).all().length).toBe(1)
+ expect(db.select().from(PartTable).all().length).toBe(1)
+ expect(db.select().from(TodoTable).all().length).toBe(1)
+ expect(db.select().from(PermissionTable).all().length).toBe(1)
+ expect(db.select().from(SessionShareTable).all().length).toBe(1)
+ })
+})
diff --git a/packages/sdk/js/openapi.json b/packages/sdk/js/openapi.json
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/packages/sdk/js/openapi.json
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index b22b7e9af..efb7e202e 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -525,7 +525,17 @@ export type EventMessagePartUpdated = {
type: "message.part.updated"
properties: {
part: Part
- delta?: string
+ }
+}
+
+export type EventMessagePartDelta = {
+ type: "message.part.delta"
+ properties: {
+ sessionID: string
+ messageID: string
+ partID: string
+ field: string
+ delta: string
}
}
@@ -695,10 +705,6 @@ export type Todo = {
* Priority level of the task: high, medium, low
*/
priority: string
- /**
- * Unique identifier for the todo item
- */
- id: string
}
export type EventTodoUpdated = {
@@ -948,6 +954,7 @@ export type Event =
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
+ | EventMessagePartDelta
| EventMessagePartRemoved
| EventPermissionAsked
| EventPermissionReplied
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 70596431b..85a1af9d7 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -7338,12 +7338,40 @@
"properties": {
"part": {
"$ref": "#/components/schemas/Part"
+ }
+ },
+ "required": ["part"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
+ "Event.message.part.delta": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "message.part.delta"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "sessionID": {
+ "type": "string"
+ },
+ "messageID": {
+ "type": "string"
+ },
+ "partID": {
+ "type": "string"
+ },
+ "field": {
+ "type": "string"
},
"delta": {
"type": "string"
}
},
- "required": ["part"]
+ "required": ["sessionID", "messageID", "partID", "field", "delta"]
}
},
"required": ["type", "properties"]
@@ -7757,13 +7785,9 @@
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string"
- },
- "id": {
- "description": "Unique identifier for the todo item",
- "type": "string"
}
},
- "required": ["content", "status", "priority", "id"]
+ "required": ["content", "status", "priority"]
},
"Event.todo.updated": {
"type": "object",
@@ -8435,6 +8459,9 @@
"$ref": "#/components/schemas/Event.message.part.updated"
},
{
+ "$ref": "#/components/schemas/Event.message.part.delta"
+ },
+ {
"$ref": "#/components/schemas/Event.message.part.removed"
},
{