Skip to content

Commit 592a08f

Browse files
committed
perf(desktop): debounce electron store persistence
1 parent 45cd8d7 commit 592a08f

3 files changed

Lines changed: 162 additions & 7 deletions

File tree

packages/desktop/src/main/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc"
4848
import { spawnWslSidecar } from "./wsl/sidecar"
4949
import { migrate } from "./migrate"
5050
import { cleanupStoreFiles } from "./store-cleanup"
51+
import { flushAllStores } from "./store"
5152

5253
const APP_NAMES: Record<string, string> = {
5354
dev: "OpenCode Dev",
@@ -221,11 +222,13 @@ const main = Effect.gen(function* () {
221222

222223
app.on("before-quit", () => {
223224
setAppQuitting()
225+
flushAllStores()
224226
void stopSidecars()
225227
})
226228

227229
app.on("will-quit", () => {
228230
setAppQuitting()
231+
flushAllStores()
229232
void stopSidecars()
230233
})
231234

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, test, beforeEach, afterEach, vi } from "bun:test"
2+
import { BufferedStore } from "./store"
3+
4+
class MemoryStore {
5+
public data = new Map<string, unknown>()
6+
get(key: string) {
7+
return this.data.get(key)
8+
}
9+
set(key: string, value: unknown) {
10+
this.data.set(key, value)
11+
}
12+
delete(key: string) {
13+
this.data.delete(key)
14+
}
15+
}
16+
17+
describe("BufferedStore", () => {
18+
test("returns pending value before flush", () => {
19+
const underlying = new MemoryStore()
20+
const store = new BufferedStore(underlying as any, 100)
21+
22+
store.set("theme", "dark")
23+
24+
// MemoryStore hasn't been updated yet before flush
25+
expect(underlying.data.get("theme")).toBeUndefined()
26+
27+
// But BufferedStore returns the pending write immediately
28+
expect(store.get("theme")).toBe("dark")
29+
})
30+
31+
test("flushes pending writes to underlying store on flush()", () => {
32+
const underlying = new MemoryStore()
33+
const store = new BufferedStore(underlying as any, 100)
34+
35+
store.set("key1", "value1")
36+
store.set("key2", "value2")
37+
store.flush()
38+
39+
expect(underlying.data.get("key1")).toBe("value1")
40+
expect(underlying.data.get("key2")).toBe("value2")
41+
})
42+
43+
test("buffers delete operations until flush()", () => {
44+
const underlying = new MemoryStore()
45+
underlying.set("key1", "value1")
46+
47+
const store = new BufferedStore(underlying as any, 100)
48+
store.delete("key1")
49+
50+
// Still in underlying before flush
51+
expect(underlying.data.get("key1")).toBe("value1")
52+
// Buffered as deleted
53+
expect(store.get("key1")).toBeUndefined()
54+
55+
store.flush()
56+
expect(underlying.data.has("key1")).toBe(false)
57+
})
58+
})

packages/desktop/src/main/store.ts

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,124 @@ import { join } from "node:path"
66
import { SETTINGS_STORE } from "./store-keys"
77
import { deleteStoreFileIfEmpty } from "./store-cleanup"
88

9-
const cache = new Map<string, Store>()
9+
const DELETED = Symbol("DELETED")
1010

11-
// We cannot instantiate the electron-store at module load time because
12-
// module import hoisting causes this to run before app.setPath("userData", ...)
13-
// in index.ts has executed, which would result in files being written to the default directory
14-
// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
15-
export function getStore(name = SETTINGS_STORE) {
11+
export class BufferedStore {
12+
private pending = new Map<string, unknown>()
13+
private cleared = false
14+
private timer: ReturnType<typeof setTimeout> | null = null
15+
16+
constructor(
17+
private target: {
18+
get(key: string): unknown
19+
set(key: string, val: unknown): void
20+
delete(key: string): void
21+
clear(): void
22+
has(key: string): boolean
23+
store: Record<string, unknown>
24+
},
25+
private delay = 150,
26+
) {}
27+
28+
get store(): Record<string, unknown> {
29+
const base = this.cleared ? {} : { ...this.target.store }
30+
for (const [key, value] of this.pending) {
31+
if (value === DELETED) {
32+
delete base[key]
33+
} else {
34+
base[key] = value
35+
}
36+
}
37+
return base
38+
}
39+
40+
has(key: string): boolean {
41+
if (this.pending.has(key)) {
42+
return this.pending.get(key) !== DELETED
43+
}
44+
if (this.cleared) return false
45+
return this.target.has(key)
46+
}
47+
48+
get(key: string) {
49+
if (this.pending.has(key)) {
50+
const val = this.pending.get(key)
51+
return val === DELETED ? undefined : val
52+
}
53+
if (this.cleared) return undefined
54+
return this.target.get(key)
55+
}
56+
57+
set(key: string, value: unknown) {
58+
this.pending.set(key, value)
59+
this.scheduleFlush()
60+
}
61+
62+
delete(key: string) {
63+
this.pending.set(key, DELETED)
64+
this.scheduleFlush()
65+
}
66+
67+
clear() {
68+
this.cleared = true
69+
this.pending.clear()
70+
this.scheduleFlush()
71+
}
72+
73+
flush() {
74+
if (this.timer) {
75+
clearTimeout(this.timer)
76+
this.timer = null
77+
}
78+
if (this.cleared) {
79+
this.target.clear()
80+
this.cleared = false
81+
}
82+
for (const [key, value] of this.pending) {
83+
if (value === DELETED) {
84+
this.target.delete(key)
85+
} else {
86+
this.target.set(key, value)
87+
}
88+
}
89+
this.pending.clear()
90+
}
91+
92+
private scheduleFlush() {
93+
if (this.timer) return
94+
this.timer = setTimeout(() => this.flush(), this.delay)
95+
}
96+
}
97+
98+
const cache = new Map<string, BufferedStore>()
99+
100+
export function getStore(name = SETTINGS_STORE): BufferedStore {
16101
const cached = cache.get(name)
17102
if (cached) return cached
18-
const next = new Store({
103+
const underlying = new Store({
19104
name,
20105
cwd: electron.app.getPath("userData"),
21106
fileExtension: "",
22107
accessPropertiesByDotNotation: false,
23108
})
109+
const next = new BufferedStore(underlying)
24110
cache.set(name, next)
25111
return next
26112
}
27113

114+
export function flushAllStores() {
115+
for (const store of cache.values()) {
116+
store.flush()
117+
}
118+
}
119+
28120
export async function removeStoreFileIfEmpty(name: string) {
121+
cache.get(name)?.flush()
29122
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
30123
}
31124

32125
export function removeStoreFile(name: string) {
126+
cache.get(name)?.flush()
33127
rmSync(join(electron.app.getPath("userData"), name), { force: true })
34128
cache.delete(name)
35129
}

0 commit comments

Comments
 (0)