fix(desktop): persist renderer state in SQLite instead of electron-store (#47695)

This commit is contained in:
Luke Parker
2026-09-07 10:51:46 +10:00
committed by GitHub
parent 69b30acfd4
commit 5e3100a46a
33 changed files with 1119 additions and 429 deletions
+1
View File
@@ -432,6 +432,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
+1
View File
@@ -3,6 +3,7 @@
- Follow Solid best practices, leave a comment when violating this: https://www.brenelz.com/posts/solid-js-best-practices/
- Renderer process should only call `window.api` from `src/preload`.
- Main process should register IPC handlers in `src/main/ipc.ts`.
- Avoid FS operations where possible. For any desktop persistence prefer sqlite in most cases, as performance and EPERM and many other things, especially on windows can be quite painful. Using anything other than sqlite should come with strong reasons.
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for native menus, picker titles, dialogs, buttons, accessible labels, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "sqlite",
schema: "./src/main/storage/schema.ts",
out: "./src/main/storage/migration",
})
+2
View File
@@ -13,6 +13,7 @@
"typecheck": "tsgo -b",
"test": "bun test --timeout 30000",
"dev": "bun ./scripts/dev.ts",
"migration": "bun ./scripts/migration.ts",
"prebuild": "bun ./scripts/prebuild.ts",
"build": "electron-vite build",
"preview": "electron-vite preview",
@@ -46,6 +47,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bun
// Renders the drizzle-kit output under src/main/storage/migration into migration.gen.ts so the
// main bundle and Bun tests import plain TypeScript instead of reading SQL files at runtime.
//
// bun run migration --name <change> generate a migration from schema.ts, then render
// bun run migration render only
// bun run migration --check fail when the rendered registry is stale
import { $ } from "bun"
import path from "node:path"
import { parseArgs } from "node:util"
const root = path.resolve(import.meta.dirname, "..")
const directory = path.join(root, "src/main/storage/migration")
const output = path.join(root, "src/main/storage/migration.gen.ts")
const args = parseArgs({
args: process.argv.slice(2),
options: {
check: { type: "boolean" },
name: { type: "string" },
},
})
if (args.values.name) await $`bun drizzle-kit generate --name ${args.values.name}`.cwd(root)
const rendered = await render()
if (!args.values.check) {
await Bun.write(output, rendered)
process.exit(0)
}
if ((await Bun.file(output).text()) !== rendered) {
throw new Error("Desktop storage migrations are stale. Run `bun run migration` from packages/desktop.")
}
async function render() {
const ids = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
.map((file) => path.dirname(file))
.sort()
const migrations = await Promise.all(
ids.map(async (id) => ({
id,
// Normalize so a CRLF checkout renders the same registry as an LF one.
statements: (await Bun.file(path.join(directory, id, "migration.sql")).text())
.replaceAll("\r\n", "\n")
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0),
})),
)
const source = `// Generated by scripts/migration.ts from src/main/storage/migration. Do not edit.
export const migrations = ${JSON.stringify(migrations, null, 2)}
`
const prettier = await import("prettier")
return prettier.format(source, { parser: "typescript", semi: false, printWidth: 120 })
}
@@ -6,24 +6,17 @@ export const storageHandlers = StorageRpcs.toLayer(
Effect.gen(function* () {
const storage = yield* DesktopStorage.Service
return StorageRpcs.of({
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
StorageDelete: ({ name, key }) => storage.deleteValue(name, key).pipe(Effect.orDie),
StorageClear: ({ name }) => storage.clear(name).pipe(Effect.orDie),
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
StorageGet: ({ name, key }) => Effect.sync(() => storage.state.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.state.set(name, key, value)),
StorageDelete: ({ name, key }) => Effect.sync(() => storage.state.delete(name, key)),
StorageClear: ({ name }) => Effect.sync(() => storage.state.clear(name)),
StorageKeys: ({ name }) => Effect.sync(() => storage.state.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.state.length(name)),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
DraftsPutBlob: ({ data }) =>
Effect.sync(() =>
storage.drafts.putBlob(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer),
),
DraftsGetBlob: ({ id }) =>
Effect.sync(() => {
const data = storage.drafts.getBlob(id)
return data ? new Uint8Array(data) : null
}),
DraftsPutBlob: ({ data }) => Effect.sync(() => storage.drafts.putBlob(data)),
DraftsGetBlob: ({ id }) => Effect.sync(() => storage.drafts.getBlob(id)),
})
}),
)
+1 -2
View File
@@ -19,12 +19,11 @@ import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { Wsl } from "./wsl/start"
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer)
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer)
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
@@ -10,7 +10,6 @@ import {
prepareApplicationEnvironment,
prepareDesktop,
} from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
export interface Interface {
readonly version: string
@@ -23,7 +22,6 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
yield* loadProxyEnvironment
@@ -8,7 +8,6 @@ import { Effect, FileSystem, Path } from "effect"
import { CHANNEL } from "../constants"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { cleanupStoreFiles } from "../storage/cleanup"
import { registerRendererProtocol, setDockIcon } from "../windows"
const appNames: Record<string, string> = {
@@ -74,14 +73,6 @@ export const preferApplicationEnvironment = Effect.gen(function* () {
export const prepareDesktop = Effect.gen(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
yield* cleanupStoreFiles(app.getPath("userData")).pipe(
Effect.tap((result) =>
result.deleted.length === 0
? Effect.void
: Effect.logInfo("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }),
),
Effect.catch((error) => Effect.logWarning("failed to clean scoped store files", { error })),
)
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
yield* registerRendererProtocol()
+12 -1
View File
@@ -6,9 +6,11 @@ import { Context, Effect, Layer } from "effect"
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { acquireApplicationLock, configureApplication } from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
export interface Interface {
@@ -143,13 +145,22 @@ const runtime = Layer.effect(
}),
)
const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
// Storage opens after configureApplication has set userData and before windows exist, so window
// teardown can clear a window's persisted state and every renderer request finds it ready.
const platform = Layer.mergeAll(
DesktopLogging.layer,
Shutdown.layer,
DesktopStorage.layer.pipe(Layer.provide(DesktopLogging.layer)),
)
export const layer = Layer.unwrap(
Effect.gen(function* () {
// Electron scopes the single-instance lock to userData.
yield* configureApplication()
if (!acquireApplicationLock()) return yield* Effect.interrupt
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
// otherwise read as evidence of an earlier launch on a fresh install.
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
return runtime.pipe(Layer.provideMerge(platform))
}),
)
@@ -1,141 +0,0 @@
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import { Effect, FileSystem, Layer, Path } from "effect"
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./cleanup"
const roots: string[] = []
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
Effect.runPromise(effect.pipe(Effect.provide(platform)))
const tempRoot = Effect.fn("StorageTest.tempRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-store-cleanup-" })
roots.push(root)
return root
})
const writeStore = Effect.fn("StorageTest.writeStore")(function* (
root: string,
name: string,
value: string,
modified: Date,
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(root, name), value)
yield* fs.utimes(path.join(root, name), modified, modified)
})
afterEach(() =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
concurrency: "unbounded",
discard: true,
})
}),
),
)
describe("store cleanup", () => {
test("removes empty scoped stores and leaves global stores alone", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* writeStore(root, "opencode.draft.empty.dat", "{}", now)
yield* writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
yield* writeStore(root, "opencode.global.dat", "{}", now)
yield* writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
const result = yield* cleanupStoreFiles(root, now.getTime())
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
expect((yield* fs.readDirectory(root)).sort()).toEqual([
"opencode.global.dat",
"opencode.workspace.empty.dat.json",
])
}),
),
)
test("removes stale drafts by age without removing non-empty workspace stores", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* writeStore(
root,
"opencode.draft.old.dat",
'{"draft:prompt":"hello"}',
new Date("2026-05-01T00:00:00.000Z"),
)
yield* writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
yield* writeStore(
root,
"opencode.workspace.old.dat",
'{"workspace:layout":"wide"}',
new Date("2025-01-01T00:00:00.000Z"),
)
yield* writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
const result = yield* cleanupStoreFiles(root, now.getTime())
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
expect((yield* fs.readDirectory(root)).sort()).toEqual([
"opencode.draft.recent.dat",
"opencode.workspace.old.dat",
"opencode.workspace.recent.dat",
])
}),
),
)
test("caps scoped stores by recency", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* Effect.forEach(
Array.from({ length: 102 }, (_, index) => index),
(index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
'{"draft:prompt":"hello"}',
new Date(now.getTime() - index * 1000),
),
{ concurrency: "unbounded", discard: true },
)
const result = yield* cleanupStoreFiles(root, now.getTime())
const remaining = yield* fs.readDirectory(root)
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
expect(remaining).toHaveLength(100)
}),
),
)
test("removes a scoped store immediately when it becomes empty", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
yield* writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
yield* writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
expect(yield* deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
expect(yield* deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
expect(yield* fs.readDirectory(root)).toEqual(["opencode.global.dat"])
}),
),
)
})
@@ -1,104 +0,0 @@
import { Effect, FileSystem, Option, Path } from "effect"
const EMPTY_STORE_MAX_BYTES = 128
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DRAFT_KEEP_RECENT = 100
type StoreKind = "draft" | "workspace"
type StoreCandidate = {
name: string
path: string
kind: StoreKind
modified: number
empty: boolean
}
export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function* (
userDataPath: string,
now = Date.now(),
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.orElseSucceed(() => []))
const candidates = (yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
const kind = storeKind(entry)
if (!kind) return
const file = path.join(userDataPath, entry)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return
return {
name: entry,
path: file,
kind,
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
empty: yield* isEmptyStore(file, stats.size),
}
}),
{ concurrency: 5 },
)).filter((candidate) => !!candidate)
const stale = new Set<StoreCandidate>()
for (const candidate of candidates) {
if (candidate.empty) stale.add(candidate)
if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
}
candidates
.filter((candidate) => candidate.kind === "draft" && !candidate.empty)
.sort((a, b) => b.modified - a.modified)
.slice(DRAFT_KEEP_RECENT)
.forEach((candidate) => stale.add(candidate))
const deleted = yield* Effect.forEach(
stale,
Effect.fnUntraced(function* (candidate) {
yield* fs.remove(candidate.path, { force: true })
return candidate.name
}),
{ concurrency: "unbounded" },
)
return { scanned: candidates.length, deleted }
})
export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty")(function* (
userDataPath: string,
name: string,
) {
if (!storeKind(name)) return false
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const file = path.join(userDataPath, name)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return false
if (!(yield* isEmptyStore(file, stats.size))) return false
yield* fs.remove(file, { force: true })
return true
})
function storeKind(name: string): StoreKind | undefined {
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
}
const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string, size: FileSystem.Size) {
if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false
const fs = yield* FileSystem.FileSystem
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (raw === undefined) return false
if (raw.trim() === "") return true
try {
const parsed = JSON.parse(raw) as unknown
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
} catch {
return false
}
})
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { DatabaseSync } from "node:sqlite"
import path from "node:path"
import { sql } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { migrate, openDatabase } from "./database"
import { migrations } from "./migration.gen"
const tables = (db: ReturnType<typeof drizzle>) =>
db
.all<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
.map((row) => row.name)
describe("database", () => {
test("bootstraps every table on a fresh database and is idempotent", () => {
const database = openDatabase(":memory:")
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
expect(migrate(database.db)).toEqual([])
database.close()
})
test("adopts a drafts.sqlite created before the journal existed", () => {
const native = new DatabaseSync(":memory:")
native.exec(
"CREATE TABLE document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE blob (id TEXT PRIMARY KEY, data BLOB NOT NULL); INSERT INTO document VALUES ('k', 'v')",
)
const db = drizzle({ client: native })
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
expect(migrate(db)).toEqual([])
})
test("rendered registry matches the drizzle-kit output on disk", async () => {
const directory = path.join(import.meta.dirname, "migration")
const ids = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
.map((file) => path.dirname(file))
.sort()
expect(migrations.map((migration) => migration.id)).toEqual(ids)
for (const migration of migrations) {
const source = (await Bun.file(path.join(directory, migration.id, "migration.sql")).text()).replaceAll(
"\r\n",
"\n",
)
for (const statement of migration.statements) expect(source).toContain(statement)
}
})
})
@@ -0,0 +1,48 @@
import { DatabaseSync } from "node:sqlite"
import { sql } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { migrations } from "./migration.gen"
export type Database = ReturnType<typeof drizzle>
// Owned by the runner rather than schema.ts so drizzle-kit never tries to migrate the journal itself.
const journal = sqliteTable("migration", {
id: text().primaryKey(),
time_completed: integer().notNull(),
})
export function openDatabase(filename: string) {
const native = new DatabaseSync(filename)
// WAL keeps readers off the writer. NORMAL fsyncs at checkpoints only, which survives an app
// crash but not power loss; the right trade for UI state and far cheaper on Windows.
native.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY")
const db = drizzle({ client: native })
migrate(db)
return { db, close: () => native.close() }
}
export function migrate(db: Database) {
db.run(sql`CREATE TABLE IF NOT EXISTS ${journal} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
const applied = new Set(
db
.select({ id: journal.id })
.from(journal)
.all()
.map((row) => row.id),
)
// drafts.sqlite predates the journal: its tables were created by hand, so the migration that
// would create them is recorded as applied instead of run.
const legacy =
applied.size === 0 &&
db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'document'`) !== undefined
const pending = migrations.filter((migration) => !applied.has(migration.id))
if (pending.length === 0) return []
db.transaction((tx) => {
pending.forEach((migration, index) => {
if (!(legacy && index === 0)) migration.statements.forEach((statement) => tx.run(sql.raw(statement)))
tx.insert(journal).values({ id: migration.id, time_completed: Date.now() }).run()
})
})
return pending.map((migration) => migration.id)
}
@@ -1,29 +1,32 @@
import { expect, test } from "bun:test"
import { createDesktopDraftStore } from "./drafts"
import { describe, expect, test } from "bun:test"
import { sql } from "drizzle-orm"
import { openDatabase } from "./database"
import { createDraftStore } from "./drafts"
test("flushes the latest buffered draft and stores blobs", () => {
const store = createDesktopDraftStore(":memory:")
store.set("prompt", "first")
store.set("prompt", "latest")
expect(store.get("prompt")).toBe("latest")
store.flush()
expect(store.get("prompt")).toBe("latest")
describe("draft store", () => {
test("queues documents and reads them back before and after flush", () => {
const database = openDatabase(":memory:")
const drafts = createDraftStore(database.db, { delay: 1_000 })
drafts.set("a:draft:prompt", "{}")
expect(drafts.get("a:draft:prompt")).toBe("{}")
drafts.flush()
expect(drafts.get("a:draft:prompt")).toBe("{}")
drafts.set("a:draft:prompt", null)
expect(drafts.get("a:draft:prompt")).toBeNull()
drafts.flush()
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
})
const bytes = new TextEncoder().encode("image")
const id = store.putBlob(bytes)
expect(store.getBlob(id)).toEqual(bytes)
store.close()
})
test("allows repeated flushes until closing", () => {
const store = createDesktopDraftStore(":memory:")
store.set("prompt", "first")
store.flush()
store.set("prompt", "draft")
store.flush()
expect(store.get("prompt")).toBe("draft")
store.close()
expect(() => store.flush()).not.toThrow()
expect(() => store.close()).not.toThrow()
test("stores blobs by content hash and collects unreferenced ones on open", () => {
const database = openDatabase(":memory:")
const first = createDraftStore(database.db, { delay: 1_000 })
const used = first.putBlob(new Uint8Array([1, 2, 3]))
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
first.flush()
const second = createDraftStore(database.db, { delay: 1_000 })
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
expect(second.getBlob(unused)).toBeNull()
})
})
+46 -70
View File
@@ -1,72 +1,39 @@
import { createHash } from "node:crypto"
import { DatabaseSync } from "node:sqlite"
import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { blob, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { eq, sql } from "drizzle-orm"
import type { Database } from "./database"
import { blobs, document } from "./schema"
import { createWriteBehind } from "./write-behind"
const documents = sqliteTable("document", {
key: text().primaryKey(),
value: text().notNull(),
})
const blobs = sqliteTable("blob", {
id: text().primaryKey(),
data: blob({ mode: "buffer" }).notNull(),
})
export type DraftStore = ReturnType<typeof createDraftStore>
export function createDesktopDraftStore(filename: string) {
const native = new DatabaseSync(filename)
native.exec(
"PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS blob (id TEXT PRIMARY KEY, data BLOB NOT NULL);",
)
const db = drizzle({ client: native })
const used = new Set<string>()
db.select({ value: documents.value })
.from(documents)
.all()
.forEach(({ value }) =>
JSON.parse(value, (_key, item) => {
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
return item
export function createDraftStore(db: Database, input: { delay?: number; onError?: (error: unknown) => void } = {}) {
collectBlobs(db)
const byKey = eq(document.key, sql.placeholder("key"))
const read = db.select({ value: document.value }).from(document).where(byKey).prepare()
const remove = db.delete(document).where(byKey).prepare()
const upsert = db
.insert(document)
.values({ key: sql.placeholder("key"), value: sql.placeholder("value") })
.onConflictDoUpdate({ target: document.key, set: { value: sql.placeholder("value") } })
.prepare()
const writer = createWriteBehind<string | null>({
delay: input.delay ?? 500,
onError: input.onError,
write: (batch) =>
db.transaction(() => {
for (const [key, value] of batch) {
if (value === null) remove.run({ key })
else upsert.run({ key, value })
}
}),
)
db.select({ id: blobs.id })
.from(blobs)
.all()
.filter(({ id }) => !used.has(id))
.forEach(({ id }) => db.delete(blobs).where(eq(blobs.id, id)).run())
const pending = new Map<string, string | null>()
let timer: ReturnType<typeof setTimeout> | undefined
let closed = false
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (closed) return
const writes = [...pending]
pending.clear()
if (!writes.length) return
db.transaction((tx) => {
writes.forEach(([key, value]) => {
if (value === null) tx.delete(documents).where(eq(documents.key, key)).run()
else
tx.insert(documents)
.values({ key, value })
.onConflictDoUpdate({ target: documents.key, set: { value } })
.run()
})
})
}
const schedule = () => {
if (!timer) timer = setTimeout(flush, 500)
}
})
return {
get: (key: string) =>
pending.has(key)
? (pending.get(key) ?? null)
: (db.select({ value: documents.value }).from(documents).where(eq(documents.key, key)).get()?.value ?? null),
set(key: string, value: string | null) {
pending.set(key, value)
schedule()
get(key: string) {
if (writer.has(key)) return writer.get(key) ?? null
return read.get({ key })?.value ?? null
},
set: (key: string, value: string | null) => writer.set(key, value),
putBlob(data: Uint8Array) {
const id = createHash("sha256").update(data).digest("hex")
db.insert(blobs)
@@ -75,13 +42,22 @@ export function createDesktopDraftStore(filename: string) {
.run()
return id
},
getBlob: (id: string) => db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null,
flush,
close() {
if (closed) return
flush()
closed = true
native.close()
getBlob(id: string): Uint8Array | null {
return db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null
},
flush: writer.flush,
close: writer.close,
}
}
// Blobs are content-addressed and shared; drop the ones no document references anymore. SQLite
// walks the JSON itself, so startup does not parse every draft and history entry in JavaScript.
function collectBlobs(db: Database) {
db.run(sql`
DELETE FROM ${blobs} WHERE ${blobs.id} NOT IN (
SELECT json_extract(node.value, '$.id')
FROM ${document}, json_tree(${document.value}) AS node
WHERE json_valid(${document.value}) AND node.key = 'blob' AND node.type = 'object'
)
`)
}
+37 -43
View File
@@ -2,8 +2,10 @@ export * as DesktopStorage from "./index"
import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { createDesktopDraftStore } from "./drafts"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { openDatabase } from "./database"
import { createDraftStore } from "./drafts"
import { importLegacyStores } from "./legacy"
import { createStateStore } from "./state"
export type Interface = ReturnType<typeof make>
@@ -13,60 +15,52 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const path = yield* Path.Path
const storage = make(path.join(app.getPath("userData"), "drafts.sqlite"))
const flush = () => storage.drafts.flush()
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", flush)
app.on("before-quit", flush)
const runFork = Effect.runForkWith(yield* Effect.context())
const userData = app.getPath("userData")
const storage = make(path.join(userData, "drafts.sqlite"), (error) =>
runFork(Effect.logError("storage flush failed", { error })),
)
yield* importLegacyStores(storage.db, userData).pipe(
Effect.tap((result) =>
result.removed.length === 0
? Effect.void
: Effect.logInfo("imported legacy store files", { imported: result.imported, files: result.removed }),
),
Effect.catch((error) => Effect.logWarning("failed to import legacy store files", { error })),
)
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", storage.flush)
app.on("before-quit", storage.flush)
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
app.off("before-quit", flush)
app.off("before-quit", storage.flush)
app.off("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", flush))
storage.drafts.close()
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", storage.flush))
storage.close()
}),
)
return Service.of(storage)
}),
)
function make(draftFile: string) {
const drafts = createDesktopDraftStore(draftFile)
const deleteValue = Effect.fn("DesktopStorage.delete")(function* (name: string, key: string) {
getStore(name).delete(key)
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
})
const clear = Effect.fn("DesktopStorage.clear")(function* (name: string) {
getStore(name).clear()
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
})
// The file keeps its historical name; renaming it would mean moving the drafts it already holds.
export function make(filename: string, onError?: (error: unknown) => void) {
const database = openDatabase(filename)
const state = createStateStore(database.db, { onError })
const drafts = createDraftStore(database.db, { onError })
return {
get(name: string, key: string) {
try {
const value = getStore(name).get(key)
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
} catch {
return null
}
db: database.db,
state,
drafts,
flush() {
state.flush()
drafts.flush()
},
set: (name: string, key: string, value: string) => getStore(name).set(key, value),
deleteValue,
clear,
keys: (name: string) => Object.keys(getStore(name).store),
length: (name: string) => Object.keys(getStore(name).store).length,
drafts: {
get: (key: string) => drafts.get(key),
set: (key: string, value: string | null) => drafts.set(key, value),
putBlob: (data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)),
getBlob(id: string) {
const data = drafts.getBlob(id)
return data ? new Uint8Array(data).buffer : null
},
flush: drafts.flush,
close: drafts.close,
close() {
state.close()
drafts.close()
database.close()
},
}
}
@@ -13,6 +13,7 @@ describe("hasExistingAppState", () => {
test("recognizes state written by an earlier OpenCode launch", () => {
expect(hasExistingAppState([file("opencode.settings")])).toBe(true)
expect(hasExistingAppState([file("opencode.global.dat")])).toBe(true)
expect(hasExistingAppState([file("drafts.sqlite")])).toBe(true)
expect(hasExistingAppState([file("window-state-abc.json")])).toBe(true)
expect(hasExistingAppState([directory("opencode")])).toBe(true)
})
@@ -2,6 +2,7 @@ export function hasExistingAppState(entries: Array<{ name: string; directory: bo
return entries.some((entry) => {
if (entry.name === "opencode.settings") return true
if (entry.name.endsWith(".dat")) return true
if (entry.name === "drafts.sqlite") return true
if (/^window-state-.+\.json$/.test(entry.name)) return true
return entry.directory && entry.name === "opencode"
})
@@ -0,0 +1,148 @@
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import { sql } from "drizzle-orm"
import { Effect, FileSystem, Layer, Path } from "effect"
import { openDatabase } from "./database"
import { importLegacyStores } from "./legacy"
const roots: string[] = []
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
Effect.runPromise(effect.pipe(Effect.provide(platform)))
const now = new Date("2026-07-01T00:00:00.000Z")
const day = 24 * 60 * 60 * 1000
const tempRoot = Effect.fn("LegacyTest.tempRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-legacy-store-" })
roots.push(root)
return root
})
const writeStore = Effect.fn("LegacyTest.writeStore")(function* (
root: string,
name: string,
value: string,
modified = now,
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(root, name), value)
yield* fs.utimes(path.join(root, name), modified, modified)
})
const listing = Effect.fn("LegacyTest.listing")(function* (root: string) {
const fs = yield* FileSystem.FileSystem
return (yield* fs.readDirectory(root)).sort()
})
const rows = (db: ReturnType<typeof openDatabase>["db"]) =>
db.all<{ name: string; key: string; value: string }>(sql`SELECT name, key, value FROM state ORDER BY name, key`)
afterEach(() =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
concurrency: "unbounded",
discard: true,
})
}),
),
)
describe("legacy store import", () => {
test("copies every namespace into state and removes the files", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.global.dat", JSON.stringify({ model: "m", layout: { sidebar: 1 } }))
yield* writeStore(root, "opencode.window.w1.dat", JSON.stringify({ tabs: "[]" }))
yield* writeStore(root, "default.dat", JSON.stringify({ "settings.v3": "{}" }))
yield* writeStore(root, "opencode.settings", JSON.stringify({ keep: true }))
yield* writeStore(root, "unrelated.txt", "x")
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(4)
expect(rows(database.db)).toEqual([
{ name: "default.dat", key: "settings.v3", value: "{}" },
{ name: "opencode.global.dat", key: "layout", value: '{"sidebar":1}' },
{ name: "opencode.global.dat", key: "model", value: "m" },
{ name: "opencode.window.w1.dat", key: "tabs", value: "[]" },
])
expect(yield* listing(root)).toEqual(["opencode.settings", "unrelated.txt"])
}),
))
test("does not overwrite state that already exists", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
database.db.run(sql`INSERT INTO state VALUES ('opencode.global.dat', 'model', 'new', 0)`)
yield* writeStore(root, "opencode.global.dat", JSON.stringify({ model: "old" }))
yield* importLegacyStores(database.db, root, now.getTime())
expect(rows(database.db)).toEqual([{ name: "opencode.global.dat", key: "model", value: "new" }])
expect(yield* listing(root)).toEqual([])
}),
))
test("leaves unreadable files in place", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.global.dat", "{not json")
yield* writeStore(root, "opencode.workspace.x.dat", JSON.stringify({ terminal: "{}" }))
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(1)
expect(yield* listing(root)).toEqual(["opencode.global.dat"])
}),
))
test("applies draft retention: skips empty, stale, and excess draft files", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.draft.empty.dat", "{}")
yield* writeStore(
root,
"opencode.draft.stale.dat",
JSON.stringify({ "draft:prompt": "old" }),
new Date(now.getTime() - 31 * day),
)
yield* Effect.forEach(
Array.from({ length: 102 }, (_, index) => index),
(index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
JSON.stringify({ "draft:prompt": `${index}` }),
new Date(now.getTime() - index * 1_000),
),
{ concurrency: 5 },
)
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(100)
const names = rows(database.db).map((row) => row.name)
expect(names).toContain("opencode.draft.0.dat")
expect(names).toContain("opencode.draft.99.dat")
expect(names).not.toContain("opencode.draft.100.dat")
expect(names).not.toContain("opencode.draft.101.dat")
expect(names).not.toContain("opencode.draft.stale.dat")
expect(names).not.toContain("opencode.draft.empty.dat")
expect(yield* listing(root)).toEqual([])
}),
))
})
@@ -0,0 +1,84 @@
import { Effect, FileSystem, Option, Path, Schema } from "effect"
import type { Database } from "./database"
import { state } from "./schema"
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DRAFT_KEEP_RECENT = 100
const Entries = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decode = Schema.decodeUnknownOption(Entries)
type Candidate = { name: string; path: string; modified: number; entries: Record<string, unknown> }
// Before the state table existed, every namespace the renderer persisted was an electron-store
// JSON file in userData. Copy them into SQLite once and remove them; this is the only place the
// storage layer touches the filesystem, and it runs before the first renderer request.
export const importLegacyStores = Effect.fn("DesktopStorage.importLegacyStores")(function* (
db: Database,
userData: string,
now = Date.now(),
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const names = (yield* fs.readDirectory(userData).pipe(Effect.orElseSucceed(() => []))).filter(isStoreFile)
const files = yield* Effect.forEach(
names,
Effect.fnUntraced(function* (name) {
const file = path.join(userData, name)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (raw === undefined) return
const entries = decode(raw)
if (Option.isNone(entries)) {
yield* Effect.logWarning("legacy store is not readable, leaving it in place", { name })
return
}
return {
name,
path: file,
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
entries: entries.value,
} satisfies Candidate
}),
{ concurrency: 5 },
)
const candidates = files.filter((file) => !!file)
const kept = new Set(
candidates
.filter((file) => isDraft(file.name) && Object.keys(file.entries).length > 0)
.filter((file) => now - file.modified <= DRAFT_RETENTION_MS)
.sort((a, b) => b.modified - a.modified)
.slice(0, DRAFT_KEEP_RECENT)
.map((file) => file.name),
)
const rows = candidates
.filter((file) => !isDraft(file.name) || kept.has(file.name))
.flatMap((file) =>
Object.entries(file.entries).map(([key, value]) => ({
name: file.name,
key,
value: typeof value === "string" ? value : JSON.stringify(value),
updated_at: file.modified,
})),
)
// Existing rows win: a file left behind by an interrupted import must not overwrite newer state.
if (rows.length > 0) {
db.transaction((tx) => {
rows.forEach((row) => tx.insert(state).values(row).onConflictDoNothing().run())
})
}
yield* Effect.forEach(candidates, (file) => fs.remove(file.path, { force: true }), {
concurrency: "unbounded",
discard: true,
})
return { imported: rows.length, removed: candidates.map((file) => file.name) }
})
function isStoreFile(name: string) {
return name === "default.dat" || /^opencode\..+\.dat$/.test(name)
}
function isDraft(name: string) {
return name.startsWith("opencode.draft.")
}
@@ -0,0 +1,17 @@
// Generated by scripts/migration.ts from src/main/storage/migration. Do not edit.
export const migrations = [
{
id: "20260906234153_drafts",
statements: [
"CREATE TABLE `blob` (\n\t`id` text PRIMARY KEY,\n\t`data` blob NOT NULL\n);",
"CREATE TABLE `document` (\n\t`key` text PRIMARY KEY,\n\t`value` text NOT NULL\n);",
],
},
{
id: "20260906234220_state",
statements: [
"CREATE TABLE `state` (\n\t`name` text NOT NULL,\n\t`key` text NOT NULL,\n\t`value` text NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `state_pk` PRIMARY KEY(`name`, `key`)\n);",
],
},
]
@@ -0,0 +1,9 @@
CREATE TABLE `blob` (
`id` text PRIMARY KEY,
`data` blob NOT NULL
);
--> statement-breakpoint
CREATE TABLE `document` (
`key` text PRIMARY KEY,
`value` text NOT NULL
);
@@ -0,0 +1,77 @@
{
"version": "7",
"dialect": "sqlite",
"id": "463be56f-5f29-4831-9d00-1b9c6bd957e3",
"prevIds": [
"00000000-0000-0000-0000-000000000000"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1,7 @@
CREATE TABLE `state` (
`name` text NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT `state_pk` PRIMARY KEY(`name`, `key`)
);
@@ -0,0 +1,131 @@
{
"version": "7",
"dialect": "sqlite",
"id": "5a2f8b7e-2765-4a0b-8877-a74ee2f29b20",
"prevIds": [
"463be56f-5f29-4831-9d00-1b9c6bd957e3"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"name": "state",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "state"
},
{
"columns": [
"name",
"key"
],
"nameExplicit": false,
"name": "state_pk",
"entityType": "pks",
"table": "state"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1,27 @@
import { blob, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
// Prompt drafts and history. `key` is `${storage}:${key}` as the renderer draft store writes it.
export const document = sqliteTable("document", {
key: text().primaryKey(),
value: text().notNull(),
})
// Images referenced from documents by content hash.
export const blobs = sqliteTable("blob", {
id: text().primaryKey(),
data: blob({ mode: "buffer" }).notNull(),
})
// Everything the renderer persists through `platform.storage(name)`. `name` is the storage
// namespace the app chooses (still spelled like the `.dat` file it used to be) and `key` the
// entry within it, so the app's relocation and alias logic keeps working unchanged.
export const state = sqliteTable(
"state",
{
name: text().notNull(),
key: text().notNull(),
value: text().notNull(),
updated_at: integer().notNull(),
},
(table) => [primaryKey({ columns: [table.name, table.key] })],
)
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { sql } from "drizzle-orm"
import { openDatabase } from "./database"
import { createStateStore } from "./state"
const roots: string[] = []
// Bun's node:sqlite shim keeps prepared statements alive after close(), which pins the WAL files
// on Windows. Node (and so Electron) finalizes them; tolerate the leftover here only.
afterEach(() =>
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }).catch(() => undefined))),
)
const open = () => {
const database = openDatabase(":memory:")
return { db: database.db, store: createStateStore(database.db, { delay: 1_000 }) }
}
const rows = (db: ReturnType<typeof openDatabase>["db"]) =>
db.all<{ name: string; key: string; value: string }>(sql`SELECT name, key, value FROM state ORDER BY name, key`)
describe("state store", () => {
test("reads its own queued writes before they reach the database", () => {
const { db, store } = open()
store.set("global", "model", "a")
expect(store.get("global", "model")).toBe("a")
expect(rows(db)).toEqual([])
store.flush()
expect(rows(db)).toEqual([{ name: "global", key: "model", value: "a" }])
expect(store.get("global", "model")).toBe("a")
})
test("delete is visible immediately and removes the row on flush", () => {
const { db, store } = open()
store.set("global", "model", "a")
store.flush()
store.delete("global", "model")
expect(store.get("global", "model")).toBeNull()
expect(store.keys("global")).toEqual([])
store.flush()
expect(rows(db)).toEqual([])
})
test("keys and length merge stored rows with queued changes", () => {
const { store } = open()
store.set("w", "tabs", "[]")
store.set("w", "recent", "{}")
store.flush()
store.set("w", "info", "{}")
store.delete("w", "recent")
expect(store.keys("w").sort()).toEqual(["info", "tabs"])
expect(store.length("w")).toBe(2)
expect(store.keys("other")).toEqual([])
})
test("clear drops a namespace including queued writes and leaves others alone", () => {
const { db, store } = open()
store.set("w1", "tabs", "[]")
store.set("w2", "tabs", "[]")
store.flush()
store.set("w1", "recent", "{}")
store.clear("w1")
expect(store.get("w1", "recent")).toBeNull()
store.flush()
expect(rows(db)).toEqual([{ name: "w2", key: "tabs", value: "[]" }])
})
test("a failed flush keeps every acknowledged write until a later flush succeeds", () => {
const database = openDatabase(":memory:")
const errors: unknown[] = []
const store = createStateStore(database.db, { delay: 1_000, onError: (error) => errors.push(error) })
store.set("w", "tabs", "[1]")
store.set("w", "recent", "{}")
database.db.run(sql`DROP TABLE state`)
store.flush()
expect(errors).toHaveLength(1)
expect(store.get("w", "tabs")).toBe("[1]")
database.db.run(
sql`CREATE TABLE state (name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (name, key))`,
)
store.set("w", "info", "{}")
store.flush()
expect(rows(database.db)).toEqual([
{ name: "w", key: "info", value: "{}" },
{ name: "w", key: "recent", value: "{}" },
{ name: "w", key: "tabs", value: "[1]" },
])
})
test("survives close and reopen on disk", async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-state-"))
roots.push(root)
const file = path.join(root, "drafts.sqlite")
const first = openDatabase(file)
const store = createStateStore(first.db, { delay: 1_000 })
store.set("global", "model", "a")
store.close()
first.close()
const second = openDatabase(file)
expect(createStateStore(second.db).get("global", "model")).toBe("a")
second.close()
})
test("a burst of writes to one key lands as a single upsert", () => {
const { db, store } = open()
for (let index = 0; index < 100; index++) store.set("global", "layout", `${index}`)
store.flush()
expect(rows(db)).toEqual([{ name: "global", key: "layout", value: "99" }])
})
})
@@ -0,0 +1,78 @@
import { and, eq, sql } from "drizzle-orm"
import type { Database } from "./database"
import { state } from "./schema"
import { createWriteBehind } from "./write-behind"
export type StateStore = ReturnType<typeof createStateStore>
type Row = { name: string; key: string; value: string | null }
// Reads hit SQLite directly: they happen at mount time and a point lookup on the primary key
// costs microseconds, so a second in-memory copy would only duplicate the renderer's cache.
export function createStateStore(db: Database, input: { delay?: number; onError?: (error: unknown) => void } = {}) {
// Prepared once; the flush loop then only binds values instead of rebuilding SQL per row.
const byKey = and(eq(state.name, sql.placeholder("name")), eq(state.key, sql.placeholder("key")))
const read = db.select({ value: state.value }).from(state).where(byKey).prepare()
const remove = db.delete(state).where(byKey).prepare()
const upsert = db
.insert(state)
.values({
name: sql.placeholder("name"),
key: sql.placeholder("key"),
value: sql.placeholder("value"),
updated_at: sql.placeholder("updated_at"),
})
.onConflictDoUpdate({
target: [state.name, state.key],
set: { value: sql.placeholder("value"), updated_at: sql.placeholder("updated_at") },
})
.prepare()
const writer = createWriteBehind<Row>({
delay: input.delay ?? 250,
onError: input.onError,
write: (batch) =>
db.transaction(() => {
const updated_at = Date.now()
for (const row of batch.values()) {
if (row.value === null) remove.run({ name: row.name, key: row.key })
else upsert.run({ name: row.name, key: row.key, value: row.value, updated_at })
}
}),
})
const id = (name: string, key: string) => `${name}\0${key}`
const keys = (name: string) => {
const result = new Set(
db
.select({ key: state.key })
.from(state)
.where(eq(state.name, name))
.all()
.map((row) => row.key),
)
for (const row of writer.entries()) {
if (row.name !== name) continue
if (row.value === null) result.delete(row.key)
else result.add(row.key)
}
return [...result]
}
return {
get(name: string, key: string) {
const queued = writer.get(id(name, key))
if (queued) return queued.value
return read.get({ name, key })?.value ?? null
},
set: (name: string, key: string, value: string) => writer.set(id(name, key), { name, key, value }),
delete: (name: string, key: string) => writer.set(id(name, key), { name, key, value: null }),
keys,
length: (name: string) => keys(name).length,
// Rare (window closed for good, explicit clear) so it goes straight to the database.
clear(name: string) {
writer.drop((row) => row.name === name)
db.delete(state).where(eq(state.name, name)).run()
},
flush: writer.flush,
close: writer.close,
}
}
+5 -10
View File
@@ -1,12 +1,15 @@
import Store from "electron-store"
import electron from "electron"
import { Effect } from "effect"
import { deleteStoreFileIfEmpty } from "./cleanup"
import { SETTINGS_STORE } from "./keys"
const cache = new Map<string, Store>()
// Main-process settings only (onboarding, default server, window list, appearance, updater).
// These are read synchronously before the storage layer exists and written on user action, so
// electron-store's synchronous file write is acceptable here. Renderer state goes through
// DesktopStorage instead.
//
// We cannot instantiate the electron-store at module load time because
// module import hoisting causes this to run before app.setPath("userData", ...)
// in index.ts has executed, which would result in files being written to the default directory
@@ -23,11 +26,3 @@ export function getStore(name = SETTINGS_STORE) {
cache.set(name, next)
return next
}
export const removeStoreFileIfEmpty = Effect.fn("DesktopStorage.removeStoreFileIfEmpty")(function* (name: string) {
if (yield* deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
})
export function forgetStore(name: string) {
cache.delete(name)
}
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { createWriteBehind } from "./write-behind"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
describe("write-behind", () => {
test("coalesces a burst into one batch with the latest value per key", async () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 10, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.set("b", 1)
writer.set("a", 2)
expect(writer.get("a")).toBe(2)
expect(batches).toHaveLength(0)
await wait(30)
expect(batches).toHaveLength(1)
expect([...batches[0]!]).toEqual([
["a", 2],
["b", 1],
])
expect(writer.has("a")).toBe(false)
})
test("flush writes immediately and close stops accepting writes", () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 1_000, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.flush()
expect(batches).toHaveLength(1)
writer.set("b", 2)
writer.close()
expect(batches).toHaveLength(2)
writer.set("c", 3)
writer.flush()
expect(batches).toHaveLength(2)
})
test("drop removes queued entries matching a predicate", () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 1_000, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.set("b", 2)
writer.drop((value) => value === 1)
writer.flush()
expect([...batches[0]!]).toEqual([["b", 2]])
})
test("keeps a failed batch queued and retries it on the next flush", () => {
const errors: unknown[] = []
let fail = true
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({
delay: 1_000,
onError: (error) => errors.push(error),
write: (batch) => {
if (fail) throw new Error("disk full")
batches.push(batch)
},
})
writer.set("a", 1)
writer.set("b", 1)
writer.flush()
expect(errors).toHaveLength(1)
expect(writer.get("a")).toBe(1)
fail = false
writer.set("a", 2)
writer.flush()
expect([...batches[0]!].sort()).toEqual([
["a", 2],
["b", 1],
])
})
})
@@ -0,0 +1,47 @@
// Coalesces a burst of writes into one batch. The latest value per key wins, so a store that
// re-serializes on every mutation costs one row write per flush instead of one per change.
export function createWriteBehind<T>(input: {
delay: number
write: (batch: Map<string, T>) => void
onError?: (error: unknown) => void
}) {
const pending = new Map<string, T>()
let timer: ReturnType<typeof setTimeout> | undefined
let closed = false
const flush = () => {
clearTimeout(timer)
timer = undefined
if (closed || pending.size === 0) return
const batch = new Map(pending)
pending.clear()
try {
input.write(batch)
} catch (error) {
// The renderer already saw these writes succeed. Keep them queued so the next flush retries
// them; anything written for the same key since then takes precedence.
for (const [key, value] of batch) if (!pending.has(key)) pending.set(key, value)
if (!input.onError) throw error
input.onError(error)
}
}
return {
get: (key: string) => pending.get(key),
has: (key: string) => pending.has(key),
entries: () => pending.values(),
set(key: string, value: T) {
if (closed) return
pending.set(key, value)
timer ??= setTimeout(flush, input.delay)
},
drop(predicate: (value: T) => boolean) {
for (const [key, value] of pending) if (predicate(value)) pending.delete(key)
},
flush,
close() {
flush()
closed = true
},
}
}
+7 -6
View File
@@ -5,7 +5,8 @@ import { Effect, FileSystem, Path } from "effect"
import { openExternalURL } from "../files"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { forgetStore, getStore } from "../storage/store"
import { DesktopStorage } from "../storage"
import { getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowIDArgument } from "../../shared/window-bootstrap"
import {
@@ -77,6 +78,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const storage = yield* DesktopStorage.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const wireWindowRecovery = yield* makeWindowRecovery
@@ -141,14 +143,12 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
win.on("session-end", () => registry.setQuitting())
win.on("closed", () => {
if (!registry.closed(id)) return
const data = windowDataFile(id)
runFork(
Effect.gen(function* () {
yield* Effect.try(() => storage.state.clear(windowDataFile(id)))
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
yield* fs.remove(path.join(app.getPath("userData"), data), { force: true })
}).pipe(
Effect.tap(() => Effect.sync(() => forgetStore(data))),
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window files", { id, error }))),
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window state", { id, error }))),
),
)
})
@@ -161,7 +161,8 @@ function windowStateFile(id: string) {
return `window-state-${safeWindowID(id)}.json`
}
// Mirrors windowStorage() in packages/app/src/utils/persist.ts.
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
// namespace the renderer persists this window's tabs under.
function windowDataFile(id: string) {
return `opencode.window.${safeWindowID(id)}.dat`
}