diff --git a/apps/web/src/utils/typed-storage.test.ts b/apps/web/src/utils/typed-storage.test.ts new file mode 100644 index 00000000000..eb99fd3f498 --- /dev/null +++ b/apps/web/src/utils/typed-storage.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + createKeyedStorageAccessor, + createRecordStorageAccessor, + createStorageAccessor, +} from "./typed-storage"; + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +// --------------------------------------------------------------------------- +// createStorageAccessor +// --------------------------------------------------------------------------- + +describe("createStorageAccessor", () => { + const accessor = createStorageAccessor({ + key: "vellum:test-items", + scope: "user", + parse: (raw) => { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as string[]) : null; + }, + serialize: JSON.stringify, + fallback: [], + }); + + test("load returns fallback when key is absent", () => { + expect(accessor.load()).toEqual([]); + }); + + test("save writes and load reads back", () => { + accessor.save(["a", "b"]); + expect(accessor.load()).toEqual(["a", "b"]); + expect(localStorage.getItem("vellum:test-items")).toBe('["a","b"]'); + }); + + test("remove deletes the key", () => { + accessor.save(["a"]); + accessor.remove(); + expect(accessor.load()).toEqual([]); + expect(localStorage.getItem("vellum:test-items")).toBeNull(); + }); + + test("load returns fallback on corrupted data", () => { + localStorage.setItem("vellum:test-items", "not-json"); + expect(accessor.load()).toEqual([]); + }); + + test("load returns fallback when parse returns null", () => { + localStorage.setItem("vellum:test-items", '"a string"'); + expect(accessor.load()).toEqual([]); + }); + + test("returns same reference when raw value unchanged (snapshot stability)", () => { + accessor.save(["x", "y"]); + const first = accessor.load(); + const second = accessor.load(); + expect(first).toBe(second); + }); + + test("returns new reference after value changes", () => { + accessor.save(["x"]); + const first = accessor.load(); + accessor.save(["x", "y"]); + const second = accessor.load(); + expect(first).not.toBe(second); + expect(second).toEqual(["x", "y"]); + }); + + test("exposes key and scope", () => { + expect(accessor.key).toBe("vellum:test-items"); + expect(accessor.scope).toBe("user"); + }); + + describe("boolean accessor", () => { + const boolAccessor = createStorageAccessor({ + key: "vellum:test-flag", + scope: "user", + parse: (raw) => { + if (raw === "true") return true; + if (raw === "false") return false; + return null; + }, + serialize: (v) => String(v), + fallback: false, + }); + + test("reads and writes booleans", () => { + boolAccessor.save(true); + expect(boolAccessor.load()).toBe(true); + + boolAccessor.save(false); + expect(boolAccessor.load()).toBe(false); + }); + + test("returns fallback on non-boolean string", () => { + localStorage.setItem("vellum:test-flag", "maybe"); + expect(boolAccessor.load()).toBe(false); + }); + }); + + describe("number accessor", () => { + const numAccessor = createStorageAccessor({ + key: "vellum:test-width", + scope: "device", + parse: (raw) => { + const n = Number(raw); + return Number.isFinite(n) ? n : null; + }, + serialize: String, + fallback: 300, + }); + + test("reads and writes numbers", () => { + numAccessor.save(420); + expect(numAccessor.load()).toBe(420); + }); + + test("returns fallback on NaN", () => { + localStorage.setItem("vellum:test-width", "abc"); + expect(numAccessor.load()).toBe(300); + }); + + test("scope is device", () => { + expect(numAccessor.scope).toBe("device"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// createKeyedStorageAccessor +// --------------------------------------------------------------------------- + +describe("createKeyedStorageAccessor", () => { + const keyed = createKeyedStorageAccessor({ + keyFn: (id) => `vellum:lastConvo:${id}`, + scope: "user", + parse: (raw) => (raw.length > 0 ? raw : null), + serialize: (v) => v, + fallback: "", + }); + + test("stores and retrieves per-entity values", () => { + keyed.save("asst-1", "conv-abc"); + keyed.save("asst-2", "conv-xyz"); + + expect(keyed.load("asst-1")).toBe("conv-abc"); + expect(keyed.load("asst-2")).toBe("conv-xyz"); + expect(keyed.load("asst-3")).toBe(""); + }); + + test("remove deletes per-entity key", () => { + keyed.save("asst-1", "conv-abc"); + keyed.remove("asst-1"); + expect(keyed.load("asst-1")).toBe(""); + }); + + test("exposes keyFn and scope", () => { + expect(keyed.keyFn("asst-1")).toBe("vellum:lastConvo:asst-1"); + expect(keyed.scope).toBe("user"); + }); +}); + +// --------------------------------------------------------------------------- +// createRecordStorageAccessor +// --------------------------------------------------------------------------- + +describe("createRecordStorageAccessor", () => { + interface TestEntry { + value: number; + label: string; + } + + function parseEntry(raw: unknown): TestEntry | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record; + if (typeof r.value !== "number" || typeof r.label !== "string") return null; + return { value: r.value as number, label: r.label as string }; + } + + const record = createRecordStorageAccessor({ + keyFn: (id) => `vellum:test-record:${id}`, + scope: "user", + parseValue: parseEntry, + fallback: {}, + maxEntries: 3, + }); + + test("load returns empty record when absent", () => { + expect(record.load("entity-1")).toEqual({}); + }); + + test("set and get individual entries", () => { + record.set("entity-1", "key-a", { value: 1, label: "A" }); + record.set("entity-1", "key-b", { value: 2, label: "B" }); + + expect(record.get("entity-1", "key-a")).toEqual({ value: 1, label: "A" }); + expect(record.get("entity-1", "key-b")).toEqual({ value: 2, label: "B" }); + expect(record.get("entity-1", "key-c")).toBeUndefined(); + }); + + test("entities are independent", () => { + record.set("entity-1", "key-a", { value: 1, label: "A" }); + record.set("entity-2", "key-a", { value: 99, label: "Z" }); + + expect(record.get("entity-1", "key-a")?.value).toBe(1); + expect(record.get("entity-2", "key-a")?.value).toBe(99); + }); + + test("trims oldest entries when exceeding maxEntries", () => { + record.set("entity-1", "k1", { value: 1, label: "first" }); + record.set("entity-1", "k2", { value: 2, label: "second" }); + record.set("entity-1", "k3", { value: 3, label: "third" }); + record.set("entity-1", "k4", { value: 4, label: "fourth" }); + + const data = record.load("entity-1"); + const keys = Object.keys(data); + expect(keys.length).toBe(3); + expect(data.k1).toBeUndefined(); + expect(data.k2).toEqual({ value: 2, label: "second" }); + expect(data.k4).toEqual({ value: 4, label: "fourth" }); + }); + + test("deleteEntry removes a single entry", () => { + record.set("entity-1", "key-a", { value: 1, label: "A" }); + record.set("entity-1", "key-b", { value: 2, label: "B" }); + record.deleteEntry("entity-1", "key-a"); + + expect(record.get("entity-1", "key-a")).toBeUndefined(); + expect(record.get("entity-1", "key-b")).toEqual({ value: 2, label: "B" }); + }); + + test("remove deletes the entire record", () => { + record.set("entity-1", "key-a", { value: 1, label: "A" }); + record.remove("entity-1"); + expect(record.load("entity-1")).toEqual({}); + }); + + test("load filters out invalid entries", () => { + localStorage.setItem( + "vellum:test-record:entity-1", + JSON.stringify({ + good: { value: 1, label: "ok" }, + bad: { value: "not-a-number", label: "fail" }, + ugly: "not-an-object", + }), + ); + + const data = record.load("entity-1"); + expect(Object.keys(data)).toEqual(["good"]); + expect(data.good).toEqual({ value: 1, label: "ok" }); + }); + + test("load returns fallback on corrupted JSON", () => { + localStorage.setItem("vellum:test-record:entity-1", "{{broken"); + expect(record.load("entity-1")).toEqual({}); + }); + + test("load returns fallback on non-object JSON", () => { + localStorage.setItem("vellum:test-record:entity-1", "[1,2,3]"); + expect(record.load("entity-1")).toEqual({}); + }); + + describe("without maxEntries", () => { + const unbounded = createRecordStorageAccessor({ + keyFn: (id) => `vellum:unbounded:${id}`, + scope: "user", + parseValue: parseEntry, + fallback: {}, + }); + + test("does not trim entries", () => { + for (let i = 0; i < 10; i++) { + unbounded.set("entity-1", `k${i}`, { value: i, label: `item-${i}` }); + } + expect(Object.keys(unbounded.load("entity-1")).length).toBe(10); + }); + }); +}); diff --git a/apps/web/src/utils/typed-storage.ts b/apps/web/src/utils/typed-storage.ts new file mode 100644 index 00000000000..9c24e5fbdb1 --- /dev/null +++ b/apps/web/src/utils/typed-storage.ts @@ -0,0 +1,372 @@ +/** + * Typed localStorage factory utilities. + * + * Provides `createStorageAccessor` for static-key storage and + * `createKeyedStorageAccessor` for per-entity keyed storage (e.g., + * per-assistant). Both return type-safe `load`/`save`/`remove` plus + * a `useValue` React hook via `useSyncExternalStore`. + * + * Built on `local-settings.ts` for consistent SSR guards, error + * swallowing, and same-tab change notifications. + * + * References: + * - {@link https://react.dev/reference/react/useSyncExternalStore} + * - {@link https://zustand.docs.pmnd.rs/integrations/persisting-store-data} + */ + +import { useSyncExternalStore } from "react"; + +import { + removeLocalSetting, + setLocalSetting, + watchSetting, +} from "@/utils/local-settings"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Cleanup scope determines behavior on logout. */ +export type StorageScope = "user" | "device"; + +export interface StorageAccessorConfig { + /** The full localStorage key (must include prefix, e.g. `vellum:pinnedApps`). */ + key: string; + /** `user` keys are cleared on logout; `device` keys are preserved. */ + scope: StorageScope; + /** Deserialize the raw string into a typed value. Return `null` for invalid data. */ + parse: (raw: string) => T | null; + /** Serialize the typed value to a string for storage. */ + serialize: (value: T) => string; + /** Value returned when the key is absent, unreadable, or fails validation. */ + fallback: T; +} + +export interface StorageAccessor { + /** Read the current value from localStorage. Returns `fallback` on any error. */ + load: () => T; + /** Write a value to localStorage. Fires same-tab change notifications. */ + save: (value: T) => void; + /** Remove the key from localStorage. Fires same-tab change notifications. */ + remove: () => void; + /** The localStorage key (useful for cleanup/testing). */ + key: string; + /** The declared scope. */ + scope: StorageScope; + /** + * React hook that subscribes to storage changes via `useSyncExternalStore`. + * Concurrent-rendering-safe, no initial null→value flicker. + */ + useValue: () => T; +} + +/** + * Config for per-entity keyed storage. Intentionally has no `useValue` + * hook — if you need React subscription for per-entity data, prefer + * `createRecordStorageAccessor` or compose with `useSyncExternalStore` + * at the call site. + */ +export interface KeyedStorageAccessorConfig { + /** Builds the localStorage key from an entity ID (e.g., assistantId). */ + keyFn: (id: string) => string; + /** `user` keys are cleared on logout; `device` keys are preserved. */ + scope: StorageScope; + /** Deserialize the raw string into a typed value. Return `null` for invalid data. */ + parse: (raw: string) => T | null; + /** Serialize the typed value to a string for storage. */ + serialize: (value: T) => string; + /** Value returned when the key is absent, unreadable, or fails validation. */ + fallback: T; +} + +export interface KeyedStorageAccessor { + /** Read the current value for a given entity ID. Returns `fallback` on any error. */ + load: (id: string) => T; + /** Write a value for a given entity ID. */ + save: (id: string, value: T) => void; + /** Remove the key for a given entity ID. */ + remove: (id: string) => void; + /** Build the localStorage key for a given entity ID. */ + keyFn: (id: string) => string; + /** The declared scope. */ + scope: StorageScope; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function readRaw(key: string): string | null { + if (typeof window === "undefined") return null; + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Static-key accessor +// --------------------------------------------------------------------------- + +/** + * Create a type-safe localStorage accessor for a single key. + * + * Export one accessor per key from a shared module; don't re-create at + * the call site (snapshot caching is per-instance). + * + * @example + * ```ts + * // utils/pinned-apps-storage.ts + * export const pinnedApps = createStorageAccessor({ + * key: "vellum:pinnedApps", + * scope: "user", + * parse: parsePinnedApps, + * serialize: JSON.stringify, + * fallback: [], + * }); + * + * // Imperative + * const apps = pinnedApps.load(); + * pinnedApps.save([...apps, newApp]); + * + * // React component + * function PinCount() { + * const apps = pinnedApps.useValue(); + * return {apps.length}; + * } + * ``` + */ +export function createStorageAccessor( + config: StorageAccessorConfig, +): StorageAccessor { + const { key, scope, parse, serialize, fallback } = config; + + // Cache the last raw string and parsed result so that `getSnapshot` + // returns the same object reference when the underlying data hasn't + // changed. Without this, `useSyncExternalStore` would see a new + // reference on every render call for non-primitive T (arrays, objects) + // and re-render indefinitely. + let cachedRaw: string | null | undefined; + let cachedValue: T = fallback; + + function load(): T { + const raw = readRaw(key); + if (raw === cachedRaw) return cachedValue; + cachedRaw = raw; + if (raw === null) { + cachedValue = fallback; + return fallback; + } + try { + const parsed = parse(raw); + cachedValue = parsed !== null ? parsed : fallback; + } catch { + cachedValue = fallback; + } + return cachedValue; + } + + function save(value: T): void { + setLocalSetting(key, serialize(value)); + } + + function remove(): void { + removeLocalSetting(key); + } + + function subscribe(onStoreChange: () => void): () => void { + return watchSetting(key, onStoreChange); + } + + function getSnapshot(): T { + return load(); + } + + function getServerSnapshot(): T { + return fallback; + } + + function useValue(): T { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + } + + return { load, save, remove, key, scope, useValue }; +} + +// --------------------------------------------------------------------------- +// Per-entity keyed accessor +// --------------------------------------------------------------------------- + +/** + * Create a type-safe localStorage accessor for per-entity keyed storage. + * + * Each entity ID maps to its own localStorage key via `keyFn`. For + * record-based storage with entry-level CRUD and `maxEntries` trimming, + * use `createRecordStorageAccessor` instead. + * + * @example + * ```ts + * const drafts = createKeyedStorageAccessor({ + * keyFn: (id) => `vellum:chatDrafts:${id}`, + * scope: "user", + * parse: parseDraftState, + * serialize: JSON.stringify, + * fallback: { text: "", attachments: [] }, + * }); + * + * const draft = drafts.load(assistantId); + * drafts.save(assistantId, { text: "hello", attachments: [] }); + * ``` + */ +export function createKeyedStorageAccessor( + config: KeyedStorageAccessorConfig, +): KeyedStorageAccessor { + const { keyFn, scope, parse, serialize, fallback } = config; + + function load(id: string): T { + const raw = readRaw(keyFn(id)); + if (raw === null) return fallback; + try { + const parsed = parse(raw); + return parsed !== null ? parsed : fallback; + } catch { + return fallback; + } + } + + function save(id: string, value: T): void { + setLocalSetting(keyFn(id), serialize(value)); + } + + function remove(id: string): void { + removeLocalSetting(keyFn(id)); + } + + return { load, save, remove, keyFn, scope }; +} + +// --------------------------------------------------------------------------- +// Record-based keyed accessor (for per-assistant Maps stored as objects) +// --------------------------------------------------------------------------- + +export interface RecordStorageAccessorConfig { + /** Builds the localStorage key from an entity ID. */ + keyFn: (id: string) => string; + /** `user` keys are cleared on logout; `device` keys are preserved. */ + scope: StorageScope; + /** Validate a single record value entry. Return `null` for invalid data. */ + parseValue: (raw: unknown) => V | null; + /** Value returned when the key is absent or unparseable. */ + fallback: Record; + /** + * Max entries to retain. Oldest are dropped on save by Object insertion + * order. Only works correctly with non-numeric string keys (numeric keys + * sort first per ES2015 spec, breaking oldest-first semantics). + */ + maxEntries?: number; +} + +export interface RecordStorageAccessor { + /** Load the full record for an entity. Returns a fresh object each call; safe to mutate. */ + load: (id: string) => Record; + /** Get a single entry from the record. */ + get: (id: string, entryKey: string) => V | undefined; + /** Set a single entry in the record (merge, not overwrite). */ + set: (id: string, entryKey: string, value: V) => void; + /** Remove a single entry from the record. */ + deleteEntry: (id: string, entryKey: string) => void; + /** Remove the entire record for an entity. */ + remove: (id: string) => void; + /** Build the localStorage key for a given entity ID. */ + keyFn: (id: string) => string; + /** The declared scope. */ + scope: StorageScope; +} + +/** + * Create a record-based (Map-like) storage accessor for per-entity data. + * + * Stores a `Record` per entity. Supports `maxEntries` trimming + * for bounded storage (oldest entries dropped first by insertion order). + * + * @example + * ```ts + * const ctxWindow = createRecordStorageAccessor({ + * keyFn: (id) => `vellum:ctxwindow:${id}`, + * scope: "user", + * parseValue: validateUsage, + * fallback: {}, + * maxEntries: 200, + * }); + * + * const usage = ctxWindow.get(assistantId, conversationId); + * ctxWindow.set(assistantId, conversationId, newUsage); + * ``` + */ +export function createRecordStorageAccessor( + config: RecordStorageAccessorConfig, +): RecordStorageAccessor { + const { keyFn, scope, parseValue, fallback, maxEntries } = config; + + function parseRecord(raw: string): Record | null { + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const result: Record = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + const validated = parseValue(v); + if (validated !== null) { + result[k] = validated; + } + } + return result; + } catch { + return null; + } + } + + function load(id: string): Record { + const raw = readRaw(keyFn(id)); + if (raw === null) return { ...fallback }; + return parseRecord(raw) ?? { ...fallback }; + } + + function get(id: string, entryKey: string): V | undefined { + return load(id)[entryKey]; + } + + function set(id: string, entryKey: string, value: V): void { + const existing = load(id); + existing[entryKey] = value; + + if (maxEntries !== undefined) { + const entries = Object.entries(existing); + if (entries.length > maxEntries) { + const trimmed = entries.slice(entries.length - maxEntries); + const trimmedRecord: Record = {}; + for (const [k, v] of trimmed) { + trimmedRecord[k] = v; + } + setLocalSetting(keyFn(id), JSON.stringify(trimmedRecord)); + return; + } + } + + setLocalSetting(keyFn(id), JSON.stringify(existing)); + } + + function deleteEntry(id: string, entryKey: string): void { + const existing = load(id); + delete existing[entryKey]; + setLocalSetting(keyFn(id), JSON.stringify(existing)); + } + + function remove(id: string): void { + removeLocalSetting(keyFn(id)); + } + + return { load, get, set, deleteEntry, remove, keyFn, scope }; +}