Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/desktop/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]
15 changes: 12 additions & 3 deletions packages/desktop/src/main/ipc-handlers/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ export const storageHandlers = StorageRpcs.toLayer(
const handoff = yield* IpcPortHandoff

return StorageRpcs.of({
StorageItems: ({ name }) => Effect.sync(() => storage.state.items(name)),
StorageItems: ({ name }) =>
Effect.gen(function* () {
yield* storage.ready
return storage.state.items(name)
}),
StorageUpdate: ({ name, insert, remove }, context) =>
Effect.sync(() => {
Effect.gen(function* () {
yield* storage.ready
const revision = storage.state.update(name, insert, remove)
// Other windows hold their own copy of this namespace; tell them what moved.
const origin = sender(handoff, context)
Expand All @@ -27,7 +32,11 @@ export const storageHandlers = StorageRpcs.toLayer(

return revision
}),
StorageClear: ({ name }) => Effect.sync(() => storage.state.clear(name)),
StorageClear: ({ name }) =>
Effect.gen(function* () {
yield* storage.ready
storage.state.clear(name)
}),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value, strict }) => Effect.sync(() => storage.drafts.set(key, value, strict)),
DraftsDelete: ({ key }) => Effect.sync(() => void storage.drafts.set(key, null)),
Expand Down
53 changes: 53 additions & 0 deletions packages/desktop/src/main/storage/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtempSync, writeFileSync } from "node:fs"
import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { WindowSnapshotChannel } from "../../shared/ipc-transport"
import { electron } from "../../../test/preload"
import { layer } from "./index"
import { registerStorageSnapshotHandler } from "./snapshot"

const roots: string[] = []

// Bun's node:sqlite shim can retain file handles on Windows after close; only these owned test directories are removed.
afterEach(() => Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }).catch(() => undefined))))

function tempRoot() {
const root = mkdtempSync(path.join(tmpdir(), "opencode-storage-layer-"))
roots.push(root)

return root
}

registerStorageSnapshotHandler()

describe("storage layer", () => {
test("the snapshot handler waits for the legacy import", async () => {
const root = tempRoot()
writeFileSync(path.join(root, "opencode.global.dat"), JSON.stringify({ model: "m" }))
electron.userData = root

// The preload asks for the namespaces its shell reads before the page runs. The answer must not
// race the legacy import: the renderer loads a namespace once, so an empty answer is final.
const result = await Effect.runPromise(
Effect.gen(function* () {
yield* Layer.build(layer.pipe(Layer.provide(Layer.merge(NodePath.layer, NodeFileSystem.layer))))
const handler = electron.handlers.get(WindowSnapshotChannel)

if (!handler) throw new Error("the snapshot handler was not registered")

return yield* Effect.promise(() =>
Promise.resolve(handler({ senderFrame: { url: "oc://renderer/index.html" } }, ["opencode.global.dat"])),
)
}).pipe(Effect.scoped),
)

expect(result).toEqual({
storage: { "opencode.global.dat": { items: { model: "m" }, revision: 0 } },
extensions: [],
})
})
})
28 changes: 21 additions & 7 deletions packages/desktop/src/main/storage/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * as DesktopStorage from "./index"

import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { Context, Deferred, Effect, Layer, Path } from "effect"
import { marks } from "../lifecycle/marks"
import { openDatabase } from "./database"
import { setStorageSnapshotProvider } from "./snapshot"
Expand All @@ -10,7 +10,7 @@ import { importLegacyStores } from "./legacy"
import { createStateStore } from "./state"
import { readEnableState } from "../extension/enable-state"

export type Interface = ReturnType<typeof make>
export type Interface = ReturnType<typeof make> & { readonly ready: Effect.Effect<void> }

export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopStorage") {}

Expand All @@ -19,19 +19,29 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const path = yield* Path.Path
const runFork = Effect.runForkWith(yield* Effect.context())
const runPromise = Effect.runPromiseWith(yield* Effect.context())
const userData = app.getPath("userData")

const storage = make(path.join(userData, "drafts.sqlite"), (error) =>
runFork(Effect.logError("storage flush failed", { error })),
)

// The import is a one-time migration of files the renderer no longer writes. The renderer's
// first request arrives over the IPC port, which is handed out after the layers, so the import
// runs in the background instead of holding the layers (and the port) back. Every read and
// write of the state table waits on `ready`: a snapshot or StorageItems answered first would
// serve an empty namespace the renderer keeps for its lifetime, and a write before the
// import's transaction would be kept by onConflictDoNothing while the import deletes the file.
const ready = yield* Deferred.make<void>()
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 })),
Effect.ensuring(Deferred.succeed(ready, undefined)),
Effect.forkScoped,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forking here lets the rest of this layer run before the import commits. setStorageSnapshotProvider is installed a few lines below, and from then on the snapshot handler no longer goes through readWindowSnapshot's "legacy files present → empty storage" guard. The IPC port, and with it StorageItems/StorageUpdate, is also handed out after the layers. So on the first launch with legacy .dat files:

  • StorageItems / snapshots can return empty namespaces, and the renderer loads each namespace only once, so it keeps that empty state.
  • Any StorageUpdate that flushes before the import's transaction creates rows that the import's onConflictDoNothing keeps. importLegacyStores then deletes the .dat file, so the user's old value is lost for good.

If the import has to leave the layer's critical path, the storage handlers and the snapshot provider need to wait for it, e.g. by awaiting a Deferred before answering StorageItems/StorageUpdate and before swapping the snapshot provider. Another option is a cheap synchronous check, so the import only blocks when legacy files actually exist. When there are none, which is the common case, the cost is just the readDirectory.

)
const wire = (_event: Electron.Event | undefined, win: BrowserWindow) => win.on("session-end", storage.flush)
app.on("before-quit", storage.flush)
Expand All @@ -45,13 +55,17 @@ export const layer = Layer.effect(
storage.close()
}),
)
setStorageSnapshotProvider((names) => ({
storage: Object.fromEntries(names.map((name) => [name, storage.state.items(name)])),
extensions: readEnableState(storage.db.$client),
}))
setStorageSnapshotProvider(async (names) => {
await runPromise(Deferred.await(ready))

return {
storage: Object.fromEntries(names.map((name) => [name, storage.state.items(name)])),
extensions: readEnableState(storage.db.$client),
}
})
marks.storage = Date.now()

return Service.of(storage)
return Service.of({ ...storage, ready: Deferred.await(ready) })
}),
)

Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/main/storage/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type Candidate = { name: string; path: string; modified: number; entries: Record

// 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.
// storage layer touches the filesystem, and it starts before the first renderer request.
export const importLegacyStores = Effect.fn("DesktopStorage.importLegacyStores")(function* (
db: Database,
userData: string,
Expand Down
5 changes: 3 additions & 2 deletions packages/desktop/src/main/storage/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { readWindowSnapshot } from "./window-snapshot"
// A window's preload asks for the namespaces its shell reads before the page runs, so the first
// render is the hydrated one. Until the storage layer is up (the first window asks before it exists)
// the answer comes from the database file; the layer takes over so later windows see queued writes.
type Provider = (names: ReadonlyArray<string>) => WindowSnapshot
// The layer's provider may answer asynchronously: it waits for the legacy import to commit.
type Provider = (names: ReadonlyArray<string>) => WindowSnapshot | Promise<WindowSnapshot>

let provider: Provider = (names) => readWindowSnapshot(app.getPath("userData"), names)

Expand All @@ -17,7 +18,7 @@ export function setStorageSnapshotProvider(next: Provider) {
export function registerStorageSnapshotHandler() {
// SAFETY: Electron delivers untrusted IPC arguments; accept only an array whose every element is a string here.
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- see SAFETY above
ipcMain.handle(WindowSnapshotChannel, (event, names: unknown): WindowSnapshot => {
ipcMain.handle(WindowSnapshotChannel, (event, names: unknown): WindowSnapshot | Promise<WindowSnapshot> => {
if (!isRendererUrl(event.senderFrame?.url)) return { storage: {} }

// SAFETY: this dependency-free boundary check establishes the complete array-of-strings contract.
Expand Down
23 changes: 23 additions & 0 deletions packages/desktop/test/preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { mock } from "bun:test"

// The main process imports Electron everywhere, and outside Electron the real module is only a path
// string, so tests stub the API surface they touch. `userData` and `handlers` are mutable: a test
// points the storage layer at its own directory and observes the handlers it registers.
export const electron = {
userData: "",
handlers: new Map<string, (event: unknown, names: unknown) => unknown>(),
app: {
getPath: () => electron.userData,
on: () => {},
off: () => {},
},
BrowserWindow: { getAllWindows: () => [] },
protocol: {},
ipcMain: {
handle: (channel: string, handler: (event: unknown, names: unknown) => unknown) => {
electron.handlers.set(channel, handler)
},
},
}

mock.module("electron", () => ({ ...electron, default: electron }))
Loading