summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-15 23:30:49 -0400
committerGitHub <[email protected]>2026-04-15 23:30:49 -0400
commitf24207844f84d43536b1ac5655e6f3cb80237f9f (patch)
tree8265aebeefffaae0ad035ed747ccb3ad5172c503
parent1ca257e356e404a659d6ee39d5e26a41e729ca54 (diff)
downloadopencode-f24207844f84d43536b1ac5655e6f3cb80237f9f.tar.gz
opencode-f24207844f84d43536b1ac5655e6f3cb80237f9f.zip
feat: unwrap storage namespaces to flat exports + barrel (#22747)
-rw-r--r--packages/opencode/src/account/repo.ts2
-rw-r--r--packages/opencode/src/cli/cmd/db.ts4
-rw-r--r--packages/opencode/src/cli/cmd/import.ts2
-rw-r--r--packages/opencode/src/cli/cmd/stats.ts2
-rw-r--r--packages/opencode/src/control-plane/workspace.ts8
-rw-r--r--packages/opencode/src/effect/app-runtime.ts2
-rw-r--r--packages/opencode/src/index.ts4
-rw-r--r--packages/opencode/src/node.ts4
-rw-r--r--packages/opencode/src/permission/permission.ts2
-rw-r--r--packages/opencode/src/project/project.ts2
-rw-r--r--packages/opencode/src/server/error.ts2
-rw-r--r--packages/opencode/src/server/fence.ts2
-rw-r--r--packages/opencode/src/server/instance/pty.ts2
-rw-r--r--packages/opencode/src/server/instance/sync.ts2
-rw-r--r--packages/opencode/src/server/middleware.ts2
-rw-r--r--packages/opencode/src/server/projectors.ts2
-rw-r--r--packages/opencode/src/session/compaction.ts2
-rw-r--r--packages/opencode/src/session/message-v2.ts2
-rw-r--r--packages/opencode/src/session/projectors.ts2
-rw-r--r--packages/opencode/src/session/revert.ts2
-rw-r--r--packages/opencode/src/session/session.ts6
-rw-r--r--packages/opencode/src/session/summary.ts2
-rw-r--r--packages/opencode/src/session/todo.ts2
-rw-r--r--packages/opencode/src/share/share-next.ts2
-rw-r--r--packages/opencode/src/storage/db.ts254
-rw-r--r--packages/opencode/src/storage/index.ts26
-rw-r--r--packages/opencode/src/storage/json-migration.ts728
-rw-r--r--packages/opencode/src/storage/storage.ts572
-rw-r--r--packages/opencode/src/sync/sync-event.ts2
-rw-r--r--packages/opencode/src/worktree/worktree.ts2
-rw-r--r--packages/opencode/test/account/repo.test.ts2
-rw-r--r--packages/opencode/test/account/service.test.ts2
-rw-r--r--packages/opencode/test/fixture/db.ts2
-rw-r--r--packages/opencode/test/preload.ts2
-rw-r--r--packages/opencode/test/project/migrate-global.test.ts4
-rw-r--r--packages/opencode/test/share/share-next.test.ts2
-rw-r--r--packages/opencode/test/storage/db.test.ts2
-rw-r--r--packages/opencode/test/storage/json-migration.test.ts2
-rw-r--r--packages/opencode/test/storage/storage.test.ts2
-rw-r--r--packages/opencode/test/sync/index.test.ts2
40 files changed, 845 insertions, 825 deletions
diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts
index b2b084c08..5d8a8e33f 100644
--- a/packages/opencode/src/account/repo.ts
+++ b/packages/opencode/src/account/repo.ts
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, Context } from "effect"
-import { Database } from "@/storage/db"
+import { Database } from "@/storage"
import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
import { normalizeServerUrl } from "./url"
diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts
index d2a2ca570..235b59793 100644
--- a/packages/opencode/src/cli/cmd/db.ts
+++ b/packages/opencode/src/cli/cmd/db.ts
@@ -1,11 +1,11 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
-import { Database } from "../../storage/db"
+import { Database } from "../../storage"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
-import { JsonMigration } from "../../storage/json-migration"
+import { JsonMigration } from "../../storage"
import { EOL } from "os"
import { errorMessage } from "../../util/error"
diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts
index 38d2376bc..8da254f15 100644
--- a/packages/opencode/src/cli/cmd/import.ts
+++ b/packages/opencode/src/cli/cmd/import.ts
@@ -4,7 +4,7 @@ import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
-import { Database } from "../../storage/db"
+import { Database } from "../../storage"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share"
diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts
index d66ac252f..34af56ad7 100644
--- a/packages/opencode/src/cli/cmd/stats.ts
+++ b/packages/opencode/src/cli/cmd/stats.ts
@@ -2,7 +2,7 @@ import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
-import { Database } from "../../storage/db"
+import { Database } from "../../storage"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project"
import { Instance } from "../../project/instance"
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index f38b27e6f..b43fe848b 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -1,7 +1,7 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
-import { Database, asc, eq, inArray } from "@/storage/db"
+import { Database, asc, eq, inArray } from "@/storage"
import { Project } from "@/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
@@ -114,7 +114,7 @@ export namespace Workspace {
await adaptor.create(config)
- void startSync(info)
+ startSync(info)
await waitEvent({
timeout: TIMEOUT,
@@ -294,7 +294,7 @@ export namespace Workspace {
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
- for (const space of spaces) void startSync(space)
+ for (const space of spaces) startSync(space)
return spaces
}
@@ -307,7 +307,7 @@ export namespace Workspace {
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
- void startSync(space)
+ startSync(space)
return space
})
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index bd27df343..3e2818344 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/packages/opencode/src/effect/app-runtime.ts
@@ -12,7 +12,7 @@ import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
-import { Storage } from "@/storage/storage"
+import { Storage } from "@/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider"
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index ab3ccb712..d9f4651fb 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -31,8 +31,8 @@ import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path"
import { Global } from "./global"
-import { JsonMigration } from "./storage/json-migration"
-import { Database } from "./storage/db"
+import { JsonMigration } from "./storage"
+import { Database } from "./storage"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts
index a30783fb2..1cb30d808 100644
--- a/packages/opencode/src/node.ts
+++ b/packages/opencode/src/node.ts
@@ -2,5 +2,5 @@ export { Config } from "./config"
export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap"
export { Log } from "./util"
-export { Database } from "./storage/db"
-export { JsonMigration } from "./storage/json-migration"
+export { Database } from "./storage"
+export { JsonMigration } from "./storage"
diff --git a/packages/opencode/src/permission/permission.ts b/packages/opencode/src/permission/permission.ts
index a8463510c..fe7fb8545 100644
--- a/packages/opencode/src/permission/permission.ts
+++ b/packages/opencode/src/permission/permission.ts
@@ -5,7 +5,7 @@ import { InstanceState } from "@/effect"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
-import { Database, eq } from "@/storage/db"
+import { Database, eq } from "@/storage"
import { zod } from "@/util/effect-zod"
import { Log } from "@/util"
import { withStatics } from "@/util/schema"
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 99fe88ff1..050951a60 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -1,5 +1,5 @@
import z from "zod"
-import { and, Database, eq } from "../storage/db"
+import { and, Database, eq } from "../storage"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { Log } from "../util"
diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts
index cc5fa9618..73d28e735 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 { NotFoundError } from "../storage/db"
+import { NotFoundError } from "../storage"
export const ERRORS = {
400: {
diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts
index 87771745c..b461a9dac 100644
--- a/packages/opencode/src/server/fence.ts
+++ b/packages/opencode/src/server/fence.ts
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"
-import { Database, inArray } from "@/storage/db"
+import { Database, inArray } from "@/storage"
import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
diff --git a/packages/opencode/src/server/instance/pty.ts b/packages/opencode/src/server/instance/pty.ts
index 3cb8dbfe2..794372512 100644
--- a/packages/opencode/src/server/instance/pty.ts
+++ b/packages/opencode/src/server/instance/pty.ts
@@ -6,7 +6,7 @@ import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
-import { NotFoundError } from "../../storage/db"
+import { NotFoundError } from "../../storage"
import { errors } from "../error"
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
diff --git a/packages/opencode/src/server/instance/sync.ts b/packages/opencode/src/server/instance/sync.ts
index 2513e519e..633e77f10 100644
--- a/packages/opencode/src/server/instance/sync.ts
+++ b/packages/opencode/src/server/instance/sync.ts
@@ -2,7 +2,7 @@ import z from "zod"
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { SyncEvent } from "@/sync"
-import { Database, asc, and, not, or, lte, eq } from "@/storage/db"
+import { Database, asc, and, not, or, lte, eq } from "@/storage"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import { Log } from "@/util"
diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts
index e0958196a..b67d15f55 100644
--- a/packages/opencode/src/server/middleware.ts
+++ b/packages/opencode/src/server/middleware.ts
@@ -1,6 +1,6 @@
import { Provider } from "../provider"
import { NamedError } from "@opencode-ai/shared/util/error"
-import { NotFoundError } from "../storage/db"
+import { NotFoundError } from "../storage"
import { Session } from "../session"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import type { ErrorHandler, MiddlewareHandler } from "hono"
diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts
index eb85a8017..cfecce526 100644
--- a/packages/opencode/src/server/projectors.ts
+++ b/packages/opencode/src/server/projectors.ts
@@ -3,7 +3,7 @@ import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session"
import { SessionTable } from "@/session/session.sql"
-import { Database, eq } from "@/storage/db"
+import { Database, eq } from "@/storage"
export function initProjectors() {
SyncEvent.init({
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 72b996321..5ad80b6b0 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -11,7 +11,7 @@ import { SessionProcessor } from "./processor"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config"
-import { NotFoundError } from "@/storage/db"
+import { NotFoundError } from "@/storage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index f4a7235e1..5dcf0dcd1 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -6,7 +6,7 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { SyncEvent } from "../sync"
-import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
+import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage"
import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts
index 1e092b07e..9a36ef5b3 100644
--- a/packages/opencode/src/session/projectors.ts
+++ b/packages/opencode/src/session/projectors.ts
@@ -1,4 +1,4 @@
-import { NotFoundError, eq, and } from "../storage/db"
+import { NotFoundError, eq, and } from "../storage"
import { SyncEvent } from "@/sync"
import { Session } from "."
import { MessageV2 } from "./message-v2"
diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts
index 383fe08e8..93d0e6219 100644
--- a/packages/opencode/src/session/revert.ts
+++ b/packages/opencode/src/session/revert.ts
@@ -2,7 +2,7 @@ import z from "zod"
import { Effect, Layer, Context } from "effect"
import { Bus } from "../bus"
import { Snapshot } from "../snapshot"
-import { Storage } from "@/storage/storage"
+import { Storage } from "@/storage"
import { SyncEvent } from "../sync"
import { Log } from "../util"
import { Session } from "."
diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts
index a4bf446a1..9ebddf8de 100644
--- a/packages/opencode/src/session/session.ts
+++ b/packages/opencode/src/session/session.ts
@@ -8,12 +8,12 @@ import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "../flag/flag"
import { Installation } from "../installation"
-import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
+import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage"
import { SyncEvent } from "../sync"
-import type { SQL } from "../storage/db"
+import type { SQL } from "../storage"
import { PartTable, SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
-import { Storage } from "@/storage/storage"
+import { Storage } from "@/storage"
import { Log } from "../util"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts
index 2c973c5df..21203c326 100644
--- a/packages/opencode/src/session/summary.ts
+++ b/packages/opencode/src/session/summary.ts
@@ -2,7 +2,7 @@ import z from "zod"
import { Effect, Layer, Context } from "effect"
import { Bus } from "@/bus"
import { Snapshot } from "@/snapshot"
-import { Storage } from "@/storage/storage"
+import { Storage } from "@/storage"
import { Session } from "."
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID } from "./schema"
diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts
index 1fd9cbaa5..eec2bb3a3 100644
--- a/packages/opencode/src/session/todo.ts
+++ b/packages/opencode/src/session/todo.ts
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
-import { Database, eq, asc } from "../storage/db"
+import { Database, eq, asc } from "../storage"
import { TodoTable } from "./session.sql"
export namespace Todo {
diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts
index a7656e840..1991e75ff 100644
--- a/packages/opencode/src/share/share-next.ts
+++ b/packages/opencode/src/share/share-next.ts
@@ -9,7 +9,7 @@ import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
import type { SessionID } from "@/session/schema"
-import { Database, eq } from "@/storage/db"
+import { Database, eq } from "@/storage"
import { Config } from "@/config"
import { Log } from "@/util"
import { SessionShareTable } from "./share.sql"
diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts
index 7acd458dc..1b6b2d9b3 100644
--- a/packages/opencode/src/storage/db.ts
+++ b/packages/opencode/src/storage/db.ts
@@ -27,148 +27,146 @@ export const NotFoundError = NamedError.create(
const log = Log.create({ service: "db" })
-export namespace Database {
- export function getChannelPath() {
- if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
- return path.join(Global.Path.data, "opencode.db")
- const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
- return path.join(Global.Path.data, `opencode-${safe}.db`)
- }
+export function getChannelPath() {
+ if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
+ return path.join(Global.Path.data, "opencode.db")
+ const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
+ return path.join(Global.Path.data, `opencode-${safe}.db`)
+}
- export const Path = iife(() => {
- if (Flag.OPENCODE_DB) {
- if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
- return path.join(Global.Path.data, Flag.OPENCODE_DB)
- }
- return getChannelPath()
- })
-
- export type Transaction = SQLiteTransaction<"sync", void>
-
- type Client = SQLiteBunDatabase
-
- type Journal = { sql: string; timestamp: number; name: string }[]
-
- 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]),
- )
+export const Path = iife(() => {
+ if (Flag.OPENCODE_DB) {
+ if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
+ return path.join(Global.Path.data, Flag.OPENCODE_DB)
}
+ return getChannelPath()
+})
+
+export type Transaction = SQLiteTransaction<"sync", void>
+
+type Client = SQLiteBunDatabase
+
+type Journal = { sql: string; timestamp: number; name: string }[]
+
+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 (!existsSync(file)) return
- return {
- sql: readFileSync(file, "utf-8"),
- timestamp: time(name),
- name,
- }
- })
- .filter(Boolean) as Journal
-
- return sql.sort((a, b) => a.timestamp - b.timestamp)
- }
+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 (!existsSync(file)) return
+ return {
+ sql: readFileSync(file, "utf-8"),
+ timestamp: time(name),
+ name,
+ }
+ })
+ .filter(Boolean) as Journal
+
+ return sql.sort((a, b) => a.timestamp - b.timestamp)
+}
- export const Client = lazy(() => {
- log.info("opening database", { path: Path })
-
- const db = init(Path)
-
- db.run("PRAGMA journal_mode = WAL")
- db.run("PRAGMA synchronous = NORMAL")
- db.run("PRAGMA busy_timeout = 5000")
- db.run("PRAGMA cache_size = -64000")
- db.run("PRAGMA foreign_keys = ON")
- db.run("PRAGMA wal_checkpoint(PASSIVE)")
-
- // 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",
- })
- if (Flag.OPENCODE_SKIP_MIGRATIONS) {
- for (const item of entries) {
- item.sql = "select 1;"
- }
+export const Client = lazy(() => {
+ log.info("opening database", { path: Path })
+
+ const db = init(Path)
+
+ db.run("PRAGMA journal_mode = WAL")
+ db.run("PRAGMA synchronous = NORMAL")
+ db.run("PRAGMA busy_timeout = 5000")
+ db.run("PRAGMA cache_size = -64000")
+ db.run("PRAGMA foreign_keys = ON")
+ db.run("PRAGMA wal_checkpoint(PASSIVE)")
+
+ // 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",
+ })
+ if (Flag.OPENCODE_SKIP_MIGRATIONS) {
+ for (const item of entries) {
+ item.sql = "select 1;"
}
- migrate(db, entries)
}
+ migrate(db, entries)
+ }
- return db
- })
+ return db
+})
- export function close() {
- Client().$client.close()
- Client.reset()
- }
+export function close() {
+ Client().$client.close()
+ Client.reset()
+}
- export type TxOrDb = Transaction | Client
-
- const ctx = LocalContext.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 LocalContext.NotFound) {
- const effects: (() => void | Promise<void>)[] = []
- const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
- for (const effect of effects) void effect()
- return result
- }
- throw err
+export type TxOrDb = Transaction | Client
+
+const ctx = LocalContext.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 LocalContext.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>) {
- const bound = InstanceState.bind(fn)
- try {
- ctx.use().effects.push(bound)
- } catch {
- void bound()
- }
+export function effect(fn: () => any | Promise<any>) {
+ const bound = InstanceState.bind(fn)
+ try {
+ ctx.use().effects.push(bound)
+ } catch {
+ bound()
}
+}
- type NotPromise<T> = T extends Promise<any> ? never : T
-
- export function transaction<T>(
- callback: (tx: TxOrDb) => NotPromise<T>,
- options?: {
- behavior?: "deferred" | "immediate" | "exclusive"
- },
- ): NotPromise<T> {
- try {
- return callback(ctx.use().tx)
- } catch (err) {
- if (err instanceof LocalContext.NotFound) {
- const effects: (() => void | Promise<void>)[] = []
- const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
- const result = Client().transaction(txCallback, { behavior: options?.behavior })
- for (const effect of effects) void effect()
- return result as NotPromise<T>
- }
- throw err
+type NotPromise<T> = T extends Promise<any> ? never : T
+
+export function transaction<T>(
+ callback: (tx: TxOrDb) => NotPromise<T>,
+ options?: {
+ behavior?: "deferred" | "immediate" | "exclusive"
+ },
+): NotPromise<T> {
+ try {
+ return callback(ctx.use().tx)
+ } catch (err) {
+ if (err instanceof LocalContext.NotFound) {
+ const effects: (() => void | Promise<void>)[] = []
+ const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
+ const result = Client().transaction(txCallback, { behavior: options?.behavior })
+ for (const effect of effects) effect()
+ return result as NotPromise<T>
}
+ throw err
}
}
diff --git a/packages/opencode/src/storage/index.ts b/packages/opencode/src/storage/index.ts
new file mode 100644
index 000000000..212c9eecf
--- /dev/null
+++ b/packages/opencode/src/storage/index.ts
@@ -0,0 +1,26 @@
+export * as JsonMigration from "./json-migration"
+export * as Database from "./db"
+export * as Storage from "./storage"
+export {
+ asc,
+ eq,
+ and,
+ or,
+ inArray,
+ desc,
+ not,
+ sql,
+ isNull,
+ isNotNull,
+ count,
+ like,
+ exists,
+ between,
+ gt,
+ gte,
+ lt,
+ lte,
+ ne,
+} from "drizzle-orm"
+export type { SQL } from "drizzle-orm"
+export { NotFoundError } from "./storage"
diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts
index 4bf75f5a1..4803d452f 100644
--- a/packages/opencode/src/storage/json-migration.ts
+++ b/packages/opencode/src/storage/json-migration.ts
@@ -10,47 +10,24 @@ import { existsSync } from "fs"
import { Filesystem } from "../util"
import { Glob } from "@opencode-ai/shared/util/glob"
-export namespace JsonMigration {
- const log = Log.create({ service: "json-migration" })
+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(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<any, any>, 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[],
- }
- }
+export type Progress = {
+ current: number
+ total: number
+ label: string
+}
- log.info("starting json to sqlite migration", { storageDir })
- const start = performance.now()
+type Options = {
+ progress?: (event: Progress) => void
+}
- // const db = drizzle({ client: sqlite })
+export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<any, any>, options?: Options) {
+ const storageDir = path.join(Global.Path.data, "storage")
- // Optimize SQLite for bulk inserts
- db.run("PRAGMA journal_mode = WAL")
- db.run("PRAGMA synchronous = OFF")
- db.run("PRAGMA cache_size = 10000")
- db.run("PRAGMA temp_store = MEMORY")
- const stats = {
+ if (!existsSync(storageDir)) {
+ log.info("storage directory does not exist, skipping migration")
+ return {
projects: 0,
sessions: 0,
messages: 0,
@@ -60,370 +37,391 @@ export namespace JsonMigration {
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()
+ log.info("starting json to sqlite migration", { storageDir })
+ const start = performance.now()
- async function list(pattern: string) {
- return Glob.scan(pattern, { cwd: storageDir, absolute: true })
- }
+ // const db = drizzle({ client: sqlite })
- async function read(files: string[], start: number, end: number) {
- const count = end - start
- // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
- const tasks = new Array(count)
- for (let i = 0; i < count; i++) {
- tasks[i] = Filesystem.readJson(files[start + i])
- }
- const results = await Promise.allSettled(tasks)
- // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
- 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}`)
+ // Optimize SQLite for bulk inserts
+ db.run("PRAGMA journal_mode = WAL")
+ db.run("PRAGMA synchronous = OFF")
+ db.run("PRAGMA cache_size = 10000")
+ db.run("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) {
+ return Glob.scan(pattern, { cwd: storageDir, absolute: true })
+ }
+
+ async function read(files: string[], start: number, end: number) {
+ const count = end - start
+ // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
+ const tasks = new Array(count)
+ for (let i = 0; i < count; i++) {
+ tasks[i] = Filesystem.readJson(files[start + i])
+ }
+ const results = await Promise.allSettled(tasks)
+ // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
+ 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
}
- return items
+ 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
- }
+ 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"),
- ])
+ // 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,
- })
+ 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 })
- }
+ 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" })
+ progress?.({ current, total, label: "starting" })
- db.run("BEGIN TRANSACTION")
+ db.run("BEGIN TRANSACTION")
- // Migrate projects first (no FK deps)
- // Derive all IDs from file paths, not JSON content
- 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
- const id = path.basename(projectFiles[i + j], ".json")
- projectIds.add(id)
- projectValues.push({
- 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)
+ // Migrate projects first (no FK deps)
+ // Derive all IDs from file paths, not JSON content
+ 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
+ const id = path.basename(projectFiles[i + j], ".json")
+ projectIds.add(id)
+ projectValues.push({
+ 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,
+ })
}
- log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
+ 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)
- // Derive all IDs from directory/file paths, not JSON content, since earlier
- // migrations may have moved sessions to new directories without updating the JSON
- const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file)))
- 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
- const id = path.basename(sessionFiles[i + j], ".json")
- const projectID = sessionProjects[i + j]
- if (!projectIds.has(projectID)) {
- orphans.sessions++
- continue
- }
- sessionIds.add(id)
- sessionValues.push({
- id,
- project_id: 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,
- })
+ // Migrate sessions (depends on projects)
+ // Derive all IDs from directory/file paths, not JSON content, since earlier
+ // migrations may have moved sessions to new directories without updating the JSON
+ const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file)))
+ 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
+ const id = path.basename(sessionFiles[i + j], ".json")
+ const projectID = sessionProjects[i + j]
+ if (!projectIds.has(projectID)) {
+ orphans.sessions++
+ continue
}
- 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 })
+ sessionIds.add(id)
+ sessionValues.push({
+ id,
+ project_id: 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)
- }
+ // 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)
- // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
- 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 = path.basename(file, ".json")
- 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,
- }
+ for (let i = 0; i < allMessageFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, allMessageFiles.length)
+ const batch = await read(allMessageFiles, i, end)
+ // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
+ 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 = path.basename(file, ".json")
+ 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 })
+ 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)
- // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
- 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 = path.basename(file, ".json")
- const messageID = path.basename(path.dirname(file))
- 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,
- }
+ // 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)
+ // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
+ 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 = path.basename(file, ".json")
+ const messageID = path.basename(path.dirname(file))
+ 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 })
+ 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,
- })
- }
+ // 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 })
}
+ 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 })
+ // 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
}
- 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 })
+ 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 })
+ // 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
}
- 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 })
+ 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 })
+ }
- db.run("COMMIT")
+ db.run("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),
- })
+ 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) })
- }
+ if (stats.errors.length > 0) {
+ log.warn("migration errors", { errors: stats.errors.slice(0, 20) })
+ }
- progress?.({ current: total, total, label: "complete" })
+ progress?.({ current: total, total, label: "complete" })
- return stats
- }
+ return stats
}
diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts
index f4793c620..b1685e689 100644
--- a/packages/opencode/src/storage/storage.ts
+++ b/packages/opencode/src/storage/storage.ts
@@ -7,327 +7,325 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
import { Git } from "@/git"
-export namespace Storage {
- const log = Log.create({ service: "storage" })
-
- type Migration = (
- dir: string,
- fs: AppFileSystem.Interface,
- git: Git.Interface,
- ) => Effect.Effect<void, AppFileSystem.Error>
-
- export const NotFoundError = NamedError.create(
- "NotFoundError",
- z.object({
- message: z.string(),
+const log = Log.create({ service: "storage" })
+
+type Migration = (
+ dir: string,
+ fs: AppFileSystem.Interface,
+ git: Git.Interface,
+) => Effect.Effect<void, AppFileSystem.Error>
+
+export const NotFoundError = NamedError.create(
+ "NotFoundError",
+ z.object({
+ message: z.string(),
+ }),
+)
+
+export type Error = AppFileSystem.Error | InstanceType<typeof NotFoundError>
+
+const RootFile = Schema.Struct({
+ path: Schema.optional(
+ Schema.Struct({
+ root: Schema.optional(Schema.String),
}),
- )
+ ),
+})
+
+const SessionFile = Schema.Struct({
+ id: Schema.String,
+})
+
+const MessageFile = Schema.Struct({
+ id: Schema.String,
+})
+
+const DiffFile = Schema.Struct({
+ additions: Schema.Number,
+ deletions: Schema.Number,
+})
+
+const SummaryFile = Schema.Struct({
+ id: Schema.String,
+ projectID: Schema.String,
+ summary: Schema.Struct({ diffs: Schema.Array(DiffFile) }),
+})
+
+const decodeRoot = Schema.decodeUnknownOption(RootFile)
+const decodeSession = Schema.decodeUnknownOption(SessionFile)
+const decodeMessage = Schema.decodeUnknownOption(MessageFile)
+const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
+
+export interface Interface {
+ readonly remove: (key: string[]) => Effect.Effect<void, AppFileSystem.Error>
+ readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
+ readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
+ readonly write: <T>(key: string[], content: T) => Effect.Effect<void, AppFileSystem.Error>
+ readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
+}
- export type Error = AppFileSystem.Error | InstanceType<typeof NotFoundError>
+export class Service extends Context.Service<Service, Interface>()("@opencode/Storage") {}
- const RootFile = Schema.Struct({
- path: Schema.optional(
- Schema.Struct({
- root: Schema.optional(Schema.String),
- }),
- ),
- })
-
- const SessionFile = Schema.Struct({
- id: Schema.String,
- })
-
- const MessageFile = Schema.Struct({
- id: Schema.String,
- })
-
- const DiffFile = Schema.Struct({
- additions: Schema.Number,
- deletions: Schema.Number,
- })
-
- const SummaryFile = Schema.Struct({
- id: Schema.String,
- projectID: Schema.String,
- summary: Schema.Struct({ diffs: Schema.Array(DiffFile) }),
- })
-
- const decodeRoot = Schema.decodeUnknownOption(RootFile)
- const decodeSession = Schema.decodeUnknownOption(SessionFile)
- const decodeMessage = Schema.decodeUnknownOption(MessageFile)
- const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
-
- export interface Interface {
- readonly remove: (key: string[]) => Effect.Effect<void, AppFileSystem.Error>
- readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
- readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
- readonly write: <T>(key: string[], content: T) => Effect.Effect<void, AppFileSystem.Error>
- readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
- }
-
- export class Service extends Context.Service<Service, Interface>()("@opencode/Storage") {}
+function file(dir: string, key: string[]) {
+ return path.join(dir, ...key) + ".json"
+}
- function file(dir: string, key: string[]) {
- return path.join(dir, ...key) + ".json"
+function missing(err: unknown) {
+ if (!err || typeof err !== "object") return false
+ if ("code" in err && err.code === "ENOENT") return true
+ if ("reason" in err && err.reason && typeof err.reason === "object" && "_tag" in err.reason) {
+ return err.reason._tag === "NotFound"
}
+ return false
+}
- function missing(err: unknown) {
- if (!err || typeof err !== "object") return false
- if ("code" in err && err.code === "ENOENT") return true
- if ("reason" in err && err.reason && typeof err.reason === "object" && "_tag" in err.reason) {
- return err.reason._tag === "NotFound"
- }
- return false
- }
+function parseMigration(text: string) {
+ const value = Number.parseInt(text, 10)
+ return Number.isNaN(value) ? 0 : value
+}
- function parseMigration(text: string) {
- const value = Number.parseInt(text, 10)
- return Number.isNaN(value) ? 0 : value
- }
+const MIGRATIONS: Migration[] = [
+ Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
+ const project = path.resolve(dir, "../project")
+ if (!(yield* fs.isDir(project))) return
+ const projectDirs = yield* fs.glob("*", {
+ cwd: project,
+ include: "all",
+ })
+ for (const projectDir of projectDirs) {
+ const full = path.join(project, projectDir)
+ if (!(yield* fs.isDir(full))) continue
+ log.info(`migrating project ${projectDir}`)
+ let projectID = projectDir
+ let worktree = "/"
+
+ if (projectID !== "global") {
+ for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", {
+ cwd: full,
+ absolute: true,
+ })) {
+ const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" })
+ const root = Option.isSome(json) ? json.value.path?.root : undefined
+ if (!root) continue
+ worktree = root
+ break
+ }
+ if (!worktree) continue
+ if (!(yield* fs.isDir(worktree))) continue
+ const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], {
+ cwd: worktree,
+ })
+ const [id] = result
+ .text()
+ .split("\n")
+ .filter(Boolean)
+ .map((x) => x.trim())
+ .toSorted()
+ if (!id) continue
+ projectID = id
- const MIGRATIONS: Migration[] = [
- Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
- const project = path.resolve(dir, "../project")
- if (!(yield* fs.isDir(project))) return
- const projectDirs = yield* fs.glob("*", {
- cwd: project,
- include: "all",
- })
- for (const projectDir of projectDirs) {
- const full = path.join(project, projectDir)
- if (!(yield* fs.isDir(full))) continue
- log.info(`migrating project ${projectDir}`)
- let projectID = projectDir
- let worktree = "/"
-
- if (projectID !== "global") {
- for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", {
- cwd: full,
- absolute: true,
- })) {
- const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" })
- const root = Option.isSome(json) ? json.value.path?.root : undefined
- if (!root) continue
- worktree = root
- break
- }
- if (!worktree) continue
- if (!(yield* fs.isDir(worktree))) continue
- const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], {
- cwd: worktree,
- })
- const [id] = result
- .text()
- .split("\n")
- .filter(Boolean)
- .map((x) => x.trim())
- .toSorted()
- if (!id) continue
- projectID = id
-
- yield* fs.writeWithDirs(
- path.join(dir, "project", projectID + ".json"),
- JSON.stringify(
- {
- id,
- vcs: "git",
- worktree,
- time: {
- created: Date.now(),
- initialized: Date.now(),
- },
+ yield* fs.writeWithDirs(
+ path.join(dir, "project", projectID + ".json"),
+ JSON.stringify(
+ {
+ id,
+ vcs: "git",
+ worktree,
+ time: {
+ created: Date.now(),
+ initialized: Date.now(),
},
- null,
- 2,
- ),
- )
+ },
+ null,
+ 2,
+ ),
+ )
- log.info(`migrating sessions for project ${projectID}`)
- for (const sessionFile of yield* fs.glob("storage/session/info/*.json", {
+ log.info(`migrating sessions for project ${projectID}`)
+ for (const sessionFile of yield* fs.glob("storage/session/info/*.json", {
+ cwd: full,
+ absolute: true,
+ })) {
+ const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
+ log.info("copying", { sessionFile, dest })
+ const session = yield* fs.readJson(sessionFile)
+ const info = decodeSession(session, { onExcessProperty: "preserve" })
+ yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2))
+ if (Option.isNone(info)) continue
+ log.info(`migrating messages for session ${info.value.id}`)
+ for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, {
cwd: full,
absolute: true,
})) {
- const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
- log.info("copying", { sessionFile, dest })
- const session = yield* fs.readJson(sessionFile)
- const info = decodeSession(session, { onExcessProperty: "preserve" })
- yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2))
- if (Option.isNone(info)) continue
- log.info(`migrating messages for session ${info.value.id}`)
- for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, {
+ const next = path.join(dir, "message", info.value.id, path.basename(msgFile))
+ log.info("copying", {
+ msgFile,
+ dest: next,
+ })
+ const message = yield* fs.readJson(msgFile)
+ const item = decodeMessage(message, { onExcessProperty: "preserve" })
+ yield* fs.writeWithDirs(next, JSON.stringify(message, null, 2))
+ if (Option.isNone(item)) continue
+
+ log.info(`migrating parts for message ${item.value.id}`)
+ for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, {
cwd: full,
absolute: true,
})) {
- const next = path.join(dir, "message", info.value.id, path.basename(msgFile))
+ const out = path.join(dir, "part", item.value.id, path.basename(partFile))
+ const part = yield* fs.readJson(partFile)
log.info("copying", {
- msgFile,
- dest: next,
+ partFile,
+ dest: out,
})
- const message = yield* fs.readJson(msgFile)
- const item = decodeMessage(message, { onExcessProperty: "preserve" })
- yield* fs.writeWithDirs(next, JSON.stringify(message, null, 2))
- if (Option.isNone(item)) continue
-
- log.info(`migrating parts for message ${item.value.id}`)
- for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, {
- cwd: full,
- absolute: true,
- })) {
- const out = path.join(dir, "part", item.value.id, path.basename(partFile))
- const part = yield* fs.readJson(partFile)
- log.info("copying", {
- partFile,
- dest: out,
- })
- yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2))
- }
+ yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2))
}
}
}
}
- }),
- Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) {
- for (const item of yield* fs.glob("session/*/*.json", {
- cwd: dir,
- absolute: true,
- })) {
- const raw = yield* fs.readJson(item)
- const session = decodeSummary(raw, { onExcessProperty: "preserve" })
- if (Option.isNone(session)) continue
- const diffs = session.value.summary.diffs
- yield* fs.writeWithDirs(
- path.join(dir, "session_diff", session.value.id + ".json"),
- JSON.stringify(diffs, null, 2),
- )
- yield* fs.writeWithDirs(
- path.join(dir, "session", session.value.projectID, session.value.id + ".json"),
- JSON.stringify(
- {
- ...(raw as Record<string, unknown>),
- summary: {
- additions: diffs.reduce((sum, x) => sum + x.additions, 0),
- deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
- },
+ }
+ }),
+ Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) {
+ for (const item of yield* fs.glob("session/*/*.json", {
+ cwd: dir,
+ absolute: true,
+ })) {
+ const raw = yield* fs.readJson(item)
+ const session = decodeSummary(raw, { onExcessProperty: "preserve" })
+ if (Option.isNone(session)) continue
+ const diffs = session.value.summary.diffs
+ yield* fs.writeWithDirs(
+ path.join(dir, "session_diff", session.value.id + ".json"),
+ JSON.stringify(diffs, null, 2),
+ )
+ yield* fs.writeWithDirs(
+ path.join(dir, "session", session.value.projectID, session.value.id + ".json"),
+ JSON.stringify(
+ {
+ ...(raw as Record<string, unknown>),
+ summary: {
+ additions: diffs.reduce((sum, x) => sum + x.additions, 0),
+ deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
},
- null,
- 2,
- ),
+ },
+ null,
+ 2,
+ ),
+ )
+ }
+ }),
+]
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const git = yield* Git.Service
+ const locks = yield* RcMap.make({
+ lookup: () => TxReentrantLock.make(),
+ idleTimeToLive: 0,
+ })
+ const state = yield* Effect.cached(
+ Effect.gen(function* () {
+ const dir = path.join(Global.Path.data, "storage")
+ const marker = path.join(dir, "migration")
+ const migration = yield* fs.readFileString(marker).pipe(
+ Effect.map(parseMigration),
+ Effect.catchIf(missing, () => Effect.succeed(0)),
+ Effect.orElseSucceed(() => 0),
)
- }
- }),
- ]
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const git = yield* Git.Service
- const locks = yield* RcMap.make({
- lookup: () => TxReentrantLock.make(),
- idleTimeToLive: 0,
- })
- const state = yield* Effect.cached(
- Effect.gen(function* () {
- const dir = path.join(Global.Path.data, "storage")
- const marker = path.join(dir, "migration")
- const migration = yield* fs.readFileString(marker).pipe(
- Effect.map(parseMigration),
- Effect.catchIf(missing, () => Effect.succeed(0)),
- Effect.orElseSucceed(() => 0),
- )
- for (let i = migration; i < MIGRATIONS.length; i++) {
- log.info("running migration", { index: i })
- const step = MIGRATIONS[i]!
- const exit = yield* Effect.exit(step(dir, fs, git))
- if (Exit.isFailure(exit)) {
- log.error("failed to run migration", { index: i, cause: exit.cause })
- break
- }
- yield* fs.writeWithDirs(marker, String(i + 1))
+ for (let i = migration; i < MIGRATIONS.length; i++) {
+ log.info("running migration", { index: i })
+ const step = MIGRATIONS[i]!
+ const exit = yield* Effect.exit(step(dir, fs, git))
+ if (Exit.isFailure(exit)) {
+ log.error("failed to run migration", { index: i, cause: exit.cause })
+ break
}
- return { dir }
- }),
- )
-
- const fail = (target: string): Effect.Effect<never, InstanceType<typeof NotFoundError>> =>
- Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
+ yield* fs.writeWithDirs(marker, String(i + 1))
+ }
+ return { dir }
+ }),
+ )
- const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
- body.pipe(Effect.catchIf(missing, () => fail(target)))
+ const fail = (target: string): Effect.Effect<never, InstanceType<typeof NotFoundError>> =>
+ Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
- const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
- yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2))
- })
+ const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
+ body.pipe(Effect.catchIf(missing, () => fail(target)))
- const withResolved = <A, E>(
- key: string[],
- fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
- ): Effect.Effect<A, E | AppFileSystem.Error> =>
- Effect.scoped(
- Effect.gen(function* () {
- const target = file((yield* state).dir, key)
- return yield* fn(target, yield* RcMap.get(locks, target))
- }),
- )
+ const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
+ yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2))
+ })
- const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) {
- yield* withResolved(key, (target, rw) =>
- TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))),
- )
- })
-
- const read: Interface["read"] = <T>(key: string[]) =>
+ const withResolved = <A, E>(
+ key: string[],
+ fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
+ ): Effect.Effect<A, E | AppFileSystem.Error> =>
+ Effect.scoped(
Effect.gen(function* () {
- const value = yield* withResolved(key, (target, rw) =>
- TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))),
- )
- return value as T
- })
+ const target = file((yield* state).dir, key)
+ return yield* fn(target, yield* RcMap.get(locks, target))
+ }),
+ )
- const update: Interface["update"] = <T>(key: string[], fn: (draft: T) => void) =>
- Effect.gen(function* () {
- const value = yield* withResolved(key, (target, rw) =>
- TxReentrantLock.withWriteLock(
- rw,
- Effect.gen(function* () {
- const content = yield* wrap(target, fs.readJson(target))
- fn(content as T)
- yield* writeJson(target, content)
- return content
- }),
- ),
- )
- return value as T
- })
+ const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) {
+ yield* withResolved(key, (target, rw) =>
+ TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))),
+ )
+ })
- const write: Interface["write"] = (key: string[], content: unknown) =>
- Effect.gen(function* () {
- yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content)))
- })
+ const read: Interface["read"] = <T>(key: string[]) =>
+ Effect.gen(function* () {
+ const value = yield* withResolved(key, (target, rw) =>
+ TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))),
+ )
+ return value as T
+ })
- const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) {
- const dir = (yield* state).dir
- const cwd = path.join(dir, ...prefix)
- const result = yield* fs
- .glob("**/*", {
- cwd,
- include: "file",
- })
- .pipe(Effect.catch(() => Effect.succeed<string[]>([])))
- return result
- .map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)])
- .toSorted((a, b) => a.join("/").localeCompare(b.join("/")))
+ const update: Interface["update"] = <T>(key: string[], fn: (draft: T) => void) =>
+ Effect.gen(function* () {
+ const value = yield* withResolved(key, (target, rw) =>
+ TxReentrantLock.withWriteLock(
+ rw,
+ Effect.gen(function* () {
+ const content = yield* wrap(target, fs.readJson(target))
+ fn(content as T)
+ yield* writeJson(target, content)
+ return content
+ }),
+ ),
+ )
+ return value as T
})
- return Service.of({
- remove,
- read,
- update,
- write,
- list,
+ const write: Interface["write"] = (key: string[], content: unknown) =>
+ Effect.gen(function* () {
+ yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content)))
})
- }),
- )
- export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
-}
+ const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) {
+ const dir = (yield* state).dir
+ const cwd = path.join(dir, ...prefix)
+ const result = yield* fs
+ .glob("**/*", {
+ cwd,
+ include: "file",
+ })
+ .pipe(Effect.catch(() => Effect.succeed<string[]>([])))
+ return result
+ .map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)])
+ .toSorted((a, b) => a.join("/").localeCompare(b.join("/")))
+ })
+
+ return Service.of({
+ remove,
+ read,
+ update,
+ write,
+ list,
+ })
+ }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
diff --git a/packages/opencode/src/sync/sync-event.ts b/packages/opencode/src/sync/sync-event.ts
index d4ad86040..db487ddd2 100644
--- a/packages/opencode/src/sync/sync-event.ts
+++ b/packages/opencode/src/sync/sync-event.ts
@@ -1,6 +1,6 @@
import z from "zod"
import type { ZodObject } from "zod"
-import { Database, eq } from "@/storage/db"
+import { Database, eq } from "@/storage"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
diff --git a/packages/opencode/src/worktree/worktree.ts b/packages/opencode/src/worktree/worktree.ts
index 8eea6445a..d4fab2030 100644
--- a/packages/opencode/src/worktree/worktree.ts
+++ b/packages/opencode/src/worktree/worktree.ts
@@ -4,7 +4,7 @@ import { Global } from "../global"
import { Instance } from "../project/instance"
import { InstanceBootstrap } from "../project/bootstrap"
import { Project } from "../project"
-import { Database, eq } from "../storage/db"
+import { Database, eq } from "../storage"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import { Log } from "../util"
diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts
index 2f17d1b22..93d048152 100644
--- a/packages/opencode/test/account/repo.test.ts
+++ b/packages/opencode/test/account/repo.test.ts
@@ -3,7 +3,7 @@ import { Effect, Layer, Option } from "effect"
import { AccountRepo } from "../../src/account/repo"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
-import { Database } from "../../src/storage/db"
+import { Database } from "../../src/storage"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
index 28592a098..053fd2a0e 100644
--- a/packages/opencode/test/account/service.test.ts
+++ b/packages/opencode/test/account/service.test.ts
@@ -15,7 +15,7 @@ import {
RefreshToken,
UserCode,
} from "../../src/account/schema"
-import { Database } from "../../src/storage/db"
+import { Database } from "../../src/storage"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts
index f11f0b903..581739a6f 100644
--- a/packages/opencode/test/fixture/db.ts
+++ b/packages/opencode/test/fixture/db.ts
@@ -1,6 +1,6 @@
import { rm } from "fs/promises"
import { Instance } from "../../src/project/instance"
-import { Database } from "../../src/storage/db"
+import { Database } from "../../src/storage"
export async function resetDatabase() {
await Instance.disposeAll().catch(() => undefined)
diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts
index 7c6f04c79..a2592286a 100644
--- a/packages/opencode/test/preload.ts
+++ b/packages/opencode/test/preload.ts
@@ -10,7 +10,7 @@ import { afterAll } from "bun:test"
const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
await fs.mkdir(dir, { recursive: true })
afterAll(async () => {
- const { Database } = await import("../src/storage/db")
+ const { Database } = await import("../src/storage")
Database.close()
const busy = (error: unknown) =>
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts
index a63ac1cd9..8c9982afb 100644
--- a/packages/opencode/test/project/migrate-global.test.ts
+++ b/packages/opencode/test/project/migrate-global.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Project } from "../../src/project"
-import { Database, eq } from "../../src/storage/db"
+import { Database, eq } from "../../src/storage"
import { SessionTable } from "../../src/session/session.sql"
import { ProjectTable } from "../../src/project/project.sql"
import { ProjectID } from "../../src/project/schema"
@@ -10,7 +10,7 @@ import { $ } from "bun"
import { tmpdir } from "../fixture/fixture"
import { Effect } from "effect"
-void Log.init({ print: false })
+Log.init({ print: false })
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
return Effect.runPromise(
diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts
index ac3f7b79e..2359f06a3 100644
--- a/packages/opencode/test/share/share-next.test.ts
+++ b/packages/opencode/test/share/share-next.test.ts
@@ -14,7 +14,7 @@ import { Session } from "../../src/session"
import type { SessionID } from "../../src/session/schema"
import { ShareNext } from "../../src/share"
import { SessionShareTable } from "../../src/share/share.sql"
-import { Database, eq } from "../../src/storage/db"
+import { Database, eq } from "../../src/storage"
import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
import { testEffect } from "../lib/effect"
diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts
index f6b605559..7edc862c4 100644
--- a/packages/opencode/test/storage/db.test.ts
+++ b/packages/opencode/test/storage/db.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import path from "path"
import { Global } from "../../src/global"
import { Installation } from "../../src/installation"
-import { Database } from "../../src/storage/db"
+import { Database } from "../../src/storage"
describe("Database.Path", () => {
test("returns database path for the current channel", () => {
diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts
index e76401ae7..019faf061 100644
--- a/packages/opencode/test/storage/json-migration.test.ts
+++ b/packages/opencode/test/storage/json-migration.test.ts
@@ -5,7 +5,7 @@ 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 { JsonMigration } from "../../src/storage"
import { Global } from "../../src/global"
import { ProjectTable } from "../../src/project/project.sql"
import { ProjectID } from "../../src/project/schema"
diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts
index 60b458bb3..c35244bb7 100644
--- a/packages/opencode/test/storage/storage.test.ts
+++ b/packages/opencode/test/storage/storage.test.ts
@@ -5,7 +5,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Git } from "../../src/git"
import { Global } from "../../src/global"
-import { Storage } from "../../src/storage/storage"
+import { Storage } from "../../src/storage"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts
index 5304f4ea8..2ba716cac 100644
--- a/packages/opencode/test/sync/index.test.ts
+++ b/packages/opencode/test/sync/index.test.ts
@@ -4,7 +4,7 @@ import z from "zod"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { SyncEvent } from "../../src/sync"
-import { Database } from "../../src/storage/db"
+import { Database } from "../../src/storage"
import { EventTable } from "../../src/sync/event.sql"
import { Identifier } from "../../src/id/id"
import { Flag } from "../../src/flag/flag"