import { describe, expect, test } from "bun:test" import { Schema, SchemaGetter } from "effect" import { createComputed, createRoot } from "solid-js" import type { Platform } from "@/runtime/platform/platform" import { flushPersisted } from "@/runtime/persistence/persist" import { localStorageNoticePending, Persist, persisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" import { TabStorage } from "@/shell/tabs/schema" const Current = Schema.Struct({ enabled: Schema.Boolean, label: Schema.String, }) const initial = { enabled: true, label: "default" } const Stored = Persistence.migrate( Current, Persistence.legacy({ oldLabel: Schema.optional(Schema.String), label: Schema.optional(Schema.String) }).pipe( Schema.decode({ decode: SchemaGetter.transform((value) => value.oldLabel === undefined ? value : { ...value, label: value.oldLabel }, ), encode: SchemaGetter.passthrough(), }), ), ) const web: Platform = { platform: "web", openExternal: () => undefined, restart: async () => undefined, notify: async () => undefined, } function desktop() { const values = new Map() const platform: Platform = { ...web, platform: "desktop", windowID: "schema-test", openDirectoryPickerDialog: async () => null, storage: (name) => ({ getItem: async (key) => values.get(`${name}:${key}`) ?? null, setItem: async (key, value) => void values.set(`${name}:${key}`, value), removeItem: async (key) => void values.delete(`${name}:${key}`), }), } return { values, platform } } describe("schema-backed persistence", () => { test("clears the recent tab after restoring it from storage", () => { const target = Persist.global("schema-recent-clear") const key = `${target.storage}:${target.key}` localStorage.setItem(key, JSON.stringify({ key: "session-tab" })) createRoot((dispose) => { const [state, setState] = persisted(target, TabStorage.Recent, { key: undefined }, web) try { expect(state.key).toBe("session-tab") setState("key", undefined) expect(state.key).toBeUndefined() flushPersisted() expect(localStorage.getItem(key)).toBe("{}") } finally { dispose() } }) }) test("migrates sync storage and writes only the current representation", () => { const target = Persist.global("schema-sync") const key = `${target.storage}:${target.key}` localStorage.setItem(key, JSON.stringify({ oldLabel: "saved" })) createRoot((dispose) => { const [state, setState, , ready] = persisted(target, Stored, initial, web) expect(ready.promise).toBeUndefined() expect(state).toEqual({ enabled: true, label: "saved" }) expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: true, label: "saved" }) setState("enabled", false) flushPersisted() expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "saved" }) dispose() }) }) test("keeps valid current fields that the migration reader does not name", () => { const target = Persist.global("schema-unnamed-field") const key = `${target.storage}:${target.key}` localStorage.setItem(key, JSON.stringify({ enabled: false, label: "kept" })) createRoot((dispose) => { const [state] = persisted(target, Stored, initial, web) expect(state).toEqual({ enabled: false, label: "kept" }) expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "kept" }) dispose() }) }) test("recovers invalid fields and strips fields outside the schema", () => { const target = Persist.global("schema-invalid-field") localStorage.setItem( `${target.storage}:${target.key}`, JSON.stringify({ enabled: "false", label: "kept", extra: 1 }), ) createRoot((dispose) => { const [state] = persisted(target, Stored, initial, web) expect(state).toEqual({ enabled: true, label: "kept" }) dispose() }) }) test("malformed JSON falls back to a typed initial state", () => { const target = Persist.global("schema-invalid-json") localStorage.setItem(`${target.storage}:${target.key}`, '{"label":"\\x"}') createRoot((dispose) => { const [state] = persisted(target, Stored, { enabled: false, label: "initial" }, web) expect(state).toEqual({ enabled: false, label: "initial" }) expect(localStorage.getItem(`${target.storage}:${target.key}`)).toBeNull() dispose() }) }) test("relocates and canonicalizes desktop state before becoming ready", async () => { const storage = desktop() storage.values.set("undefined:old-schema", JSON.stringify({ oldLabel: "desktop" })) const root = createRoot((dispose) => ({ dispose, state: persisted( { ...Persist.global("schema-desktop"), previousKey: "old-schema" }, Stored, initial, storage.platform, ), })) try { expect(root.state[3]()).toBe(false) await root.state[3].promise expect(root.state[0]).toEqual({ enabled: true, label: "desktop" }) expect(storage.values.has("undefined:old-schema")).toBe(false) expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({ enabled: true, label: "desktop", }) root.state[1]("label", "changed") flushPersisted() expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({ enabled: true, label: "changed", }) } finally { root.dispose() } }) test("a late desktop read does not overwrite an edit made while loading", async () => { const pending = Promise.withResolvers() const storage = desktop() storage.platform.storage = () => ({ getItem: () => pending.promise, setItem: async () => undefined, removeItem: async () => undefined, }) const root = createRoot((dispose) => ({ dispose, state: persisted(Persist.global("schema-late"), Stored, initial, storage.platform), })) try { root.state[1]("label", "new edit") pending.resolve(JSON.stringify({ oldLabel: "old state" })) await root.state[3].promise expect(root.state[0].label).toBe("new edit") } finally { root.dispose() } }) test("a late desktop read does not write its migrated value over an edit saved while loading", async () => { const pending = Promise.withResolvers() const storage = desktop() const key = "opencode.global.dat:schema-late-migrate" const writes: string[] = [] storage.platform.storage = (name) => ({ getItem: () => pending.promise, setItem: async (item, value) => { writes.push(value) storage.values.set(`${name}:${item}`, value) }, removeItem: async () => undefined, }) const root = createRoot((dispose) => ({ dispose, state: persisted(Persist.global("schema-late-migrate"), Stored, initial, storage.platform), })) try { root.state[1]("label", "new edit") flushPersisted() pending.resolve(JSON.stringify({ oldLabel: "old state" })) await root.state[3].promise await Promise.resolve() expect(root.state[0].label).toBe("new edit") expect(JSON.parse(storage.values.get(key)!)).toEqual({ enabled: true, label: "new edit" }) expect(writes).toHaveLength(1) } finally { root.dispose() } }) test("a write that eviction cannot fit loses no saved value and does not stop later reads or writes", () => { const full = Persist.global("schema-storage-full") const saved = Persist.global("schema-storage-saved") const fullKey = `${full.storage}:${full.key}` const savedKey = `${saved.storage}:${saved.key}` const values = new Map([[savedKey, JSON.stringify({ enabled: false, label: "saved" })]]) const quota = { full: true } const storage: Storage = { get length() { return values.size }, key: (index) => [...values.keys()][index] ?? null, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { if (quota.full && key === fullKey) throw new DOMException("quota", "QuotaExceededError") values.set(key, value) }, removeItem: (key) => void values.delete(key), } const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage")! Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }) try { createRoot((dispose) => { const [, setFull] = persisted(full, Current, initial, web) setFull("label", "too big") flushPersisted() expect(values.has(fullKey)).toBe(false) expect(values.has(savedKey)).toBe(true) expect(localStorageNoticePending()).toBe(true) const [state] = persisted(saved, Current, initial, web) expect(state).toEqual({ enabled: false, label: "saved" }) quota.full = false setFull("label", "fits") flushPersisted() expect(values.get(fullKey)).toBe(JSON.stringify({ enabled: true, label: "fits" })) dispose() }) } finally { Object.defineProperty(globalThis, "localStorage", original) } }) test("cross-window updates use the same migration and validation boundary", async () => { const target = { ...Persist.global("schema-sync-channel"), sync: true } const channel = new BroadcastChannel(`opencode.persist:${target.storage}:${target.key}`) const received = Promise.withResolvers() const values: unknown[] = [] const root = createRoot((dispose) => { const [state] = persisted(target, Stored, initial, web) createComputed(() => { values.push({ enabled: state.enabled, label: state.label }) if (state.label === "from another window") received.resolve() }) return { dispose, state } }) try { channel.postMessage({ key: target.key, newValue: JSON.stringify({ enabled: "false", label: "recovered" }) }) channel.postMessage({ key: target.key, newValue: JSON.stringify({ oldLabel: "from another window" }) }) await received.promise expect(root.state).toEqual({ enabled: true, label: "from another window" }) expect(values).toContainEqual({ enabled: true, label: "recovered" }) expect(values).toContainEqual({ enabled: true, label: "from another window" }) } finally { channel.close() root.dispose() } }) })