mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(desktop): persist renderer state in SQLite instead of electron-store (#47695)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user