diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index d8b822d856bb..a571ab1a3dd1 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -7,6 +7,7 @@ type RemovePersistedType = typeof import("./persist").removePersisted class MemoryStorage implements Storage { private values = new Map() + quotaBytes = Number.POSITIVE_INFINITY readonly events: string[] = [] readonly calls = { get: 0, set: 0, remove: 0 } @@ -34,6 +35,10 @@ class MemoryStorage implements Storage { this.events.push(`set:${key}`) if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError") if (key.startsWith("opencode.throw")) throw new Error("storage set failed") + const next = new Map(this.values) + next.set(key, value) + const bytes = Array.from(next.values()).reduce((total, item) => total + item.length, 0) + if (bytes > this.quotaBytes) throw new DOMException("quota", "QuotaExceededError") this.values.set(key, value) } @@ -68,6 +73,7 @@ beforeEach(() => { storage.calls.get = 0 storage.calls.set = 0 storage.calls.remove = 0 + storage.quotaBytes = Number.POSITIVE_INFINITY Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true, @@ -107,6 +113,37 @@ describe("persist localStorage resilience", () => { expect(storage.getItem("direct-value")).toBe('{"value":5}') }) + test("keeps the server registry while evicting a filler for another write", () => { + const global = persistTesting.localStorageWithPrefix("opencode.global.dat") + const registry = JSON.stringify({ projects: Array.from({ length: 20 }, (_, index) => `project-${index}`) }) + const filler = "filler".repeat(10) + const next = "next".repeat(10) + + global.setItem("server", registry) + storage.setItem("opencode.filler", filler) + storage.quotaBytes = registry.length + next.length + + global.setItem("next", next) + + expect(storage.getItem("opencode.global.dat:server")).toBe(registry) + expect(storage.getItem("opencode.filler")).toBeNull() + expect(storage.getItem("opencode.global.dat:next")).toBe(next) + }) + + test("preserves the server registry when its replacement is too large", () => { + const global = persistTesting.localStorageWithPrefix("opencode.global.dat") + const previous = JSON.stringify({ projects: ["project"] }) + const replacement = "replacement".repeat(20) + + global.setItem("server", previous) + storage.quotaBytes = previous.length + + global.setItem("server", replacement) + + expect(storage.getItem("opencode.global.dat:server")).toBe(previous) + expect(global.getItem("server")).toBe(previous) + }) + test("normalizer rejects malformed JSON payloads", () => { const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}') expect(result).toBeUndefined() diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index a2daae4866bc..34651e5e7564 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -25,6 +25,7 @@ type PersistTarget = { const LEGACY_STORAGE = "default.dat" const GLOBAL_STORAGE = "opencode.global.dat" +const SERVER_REGISTRY_KEY = `${GLOBAL_STORAGE}:server` const WINDOW_STORAGE = "opencode.window" const LOCAL_PREFIX = "opencode." const fallback = new Map() @@ -117,7 +118,7 @@ function evict(storage: Storage, keep: string, value: string) { const name = storage.key(index) if (!name) continue if (!name.startsWith(LOCAL_PREFIX)) continue - if (name === keep) continue + if (name === keep || name === SERVER_REGISTRY_KEY) continue const stored = storage.getItem(name) items.push({ key: name, size: stored?.length ?? 0 }) } @@ -149,14 +150,16 @@ function write(storage: Storage, key: string, value: string) { if (!quota(error)) throw error } - try { - storage.removeItem(key) - cacheDelete(key) - storage.setItem(key, value) - cacheSet(key, value) - return true - } catch (error) { - if (!quota(error)) throw error + if (key !== SERVER_REGISTRY_KEY) { + try { + storage.removeItem(key) + cacheDelete(key) + storage.setItem(key, value) + cacheSet(key, value) + return true + } catch (error) { + if (!quota(error)) throw error + } } const ok = evict(storage, key, value)