refactor(web): migrate domain storage files to typed-storage factory (LUM-2046)#32645
Conversation
…(LUM-2046) Migrate 5 domain storage files to use the typed-storage factory from LUM-2044, eliminating copy-pasted SSR guards, try/catch blocks, JSON parsing, and maxEntries trimming across the codebase. Files migrated: - app-pin-storage.ts → createStorageAccessor<PinnedAppEntry[]> - last-viewed-conversation-storage.ts → createKeyedStorageAccessor<string|null> - context-window-storage.ts → createRecordStorageAccessor<ContextWindowUsage> - dismissed-surfaces-storage.ts → createRecordStorageAccessor<string[]> - sidebar-group-collapse-storage.ts → 2x createKeyedStorageAccessor<string[]> Also: - Extract shared MemoryStorage test helper to DRY up 3 test files - Fix pinApp() to not mutate the cached array returned by load() - Fix pre-existing TS error: inline DEFAULT_TOOL_EXECUTION_TIMEOUT_SEC (missing from generated @vellumai/assistant-api package) Closes LUM-2046 Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 574bf38661
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…onical import - Move last-viewed-conversation-storage.test.ts from domains/chat/utils/ to utils/ to colocate with its source file (CONVENTIONS.md item 16) - Extract shared isStringArray/parseStringArray to storage-validators.ts (duplicated across dismissed-surfaces-storage and sidebar-group-collapse-storage) - Restore DEFAULT_TOOL_EXECUTION_TIMEOUT_SEC import from @vellumai/assistant-api (generated package was stale locally; regenerating via postinstall fixes it) Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
There was a problem hiding this comment.
Approve — PR C of the LUM-2042 chain, clean migration of 5 storage files to the typed-storage factory
7/7 CI green. Boss-authored via Devin. Two commits: 574bf38661 (migration) → 9b79810717 (audit fixes).
What I verified
Factory adoption (6/6 accessors, correct factory per shape):
app-pin-storage.ts→createStorageAccessor<PinnedAppEntry[]>(static key, needs snapshot caching foruseValue)last-viewed-conversation-storage.ts→createKeyedStorageAccessor<string | null>(per-assistant, imperative-only consumers)context-window-storage.ts→createRecordStorageAccessor<ContextWindowUsage>(Map-like,maxEntries: 200)dismissed-surfaces-storage.ts→createRecordStorageAccessor<string[]>(Map-like,maxEntries: 200)sidebar-group-collapse-storage.ts→ 2×createKeyedStorageAccessor<string[]>(separate keys per Radix root — correct, would have been a regression to share)
Behavioral equivalence — line-by-line diff against parent (6f961ed1):
- SSR guards: old
typeof window === "undefined"checks → factory'sreadRaw. ✓ - Empty-string handling on
last-viewed-conversation: oldraw && raw.length > 0 ? raw : null→ newparse: (raw) => (raw.length > 0 ? raw : null), fallback: null. Equivalent (factory short-circuits onraw === null). ✓ MAX_IDS_PER_CONVERSATION = 500cap on dismissed-surfaces preserved at call site (factorymaxEntries: 200is record-level, the 500 is entry-level — two-level cap correctly retained). ✓loadOpenCategoriesstale-key filter viaOPEN_CATEGORY_KEYSset preserved. ✓context-windowmaxEntriestrim — numeric-key caveat in factory docstring N/A here (conversation IDs are UUIDs). ✓
pinApp() mutation fix — real bug caught. Old: entries.push(...) mutated the cached fallback array [] shared across loadPinnedApps() calls. Latent before (each call returned a fresh JSON.parse array), observable now because the static accessor snapshot-caches the parsed value to keep useSyncExternalStore references stable. Spread ([...entries, newEntry]) is the right fix. PR body calls it out correctly.
Cleanup coverage: all 6 keys are vellum:* prefixed → handled by #32621's prefix sweep in session-cleanup.ts. No allowlist additions needed. ✓
Tests: 59 tests, all green. installMemoryStorage helper adopted by all 3 consumer test files (sidebar/app-pin/last-viewed), eliminating 3× duplicated MemoryStorage class + window mocking boilerplate. afterAll correctly restores via captured getOwnPropertyDescriptor.
Bot findings — closed
Codex P2 at 574bf38661 on sanitize-display-messages.ts:DEFAULT_TOOL_EXECUTION_TIMEOUT_SEC (inlined = 120 would drift from canonical API contract). → Devin reverted in 9b79810717: inline const deleted, canonical import { DEFAULT_TOOL_EXECUTION_TIMEOUT_SEC } from "@vellumai/assistant-api" restored (the inline was only present because Devin's local generated package was stale; bun run postinstall resolved it). Closed at HEAD. ✓
Devin Review surface flagged 1 inline issue (same as Codex above — also closed) + "3 additional findings" not visible via REST API. PR body's 20-point audit checklist passes. A 30-second look at https://app.devin.ai/review/vellum-ai/vellum-assistant/pull/32645 before merge would close the gate on those 3 cleanly.
Non-blocking observations
createKeyedStorageAccessorhas nouseValuehook by design (factory docstring: "compose withuseSyncExternalStoreat the call site"). Both consumers here use imperativeload/saveonly, so fine. Worth bookmarking: the next per-entity consumer wanting reactive subscription will needcreateRecordStorageAccessoreven if not record-shaped, or the factory gains a keyeduseValue.saveLastViewedConversationIdcan't clear — public API doesn't exposestorage.remove(assistantId), so once set there's no way to null via the module's public functions. Probably fine (callers overwrite), but worth a follow-upclearLastViewedConversationIdexport if a callsite needs it.createRecordStorageAccessor.loadreturns{ ...fallback }(fresh object each call), no snapshot caching. Means it's not safe foruseSyncExternalStoreconsumers without external memoization. Currently fine (both record consumers wrap innew Map/new Setimperatively). If a reactive record-shaped consumer arrives, factory will need the same snapshot-cache treatment ascreateStorageAccessor.
Carry-forward (procedure)
Snapshot caching changes the rules for callers. Once a factory caches parsed values for reference stability, any caller that previously mutated a loadFoo() return value becomes a live bug. The pinApp fix is the model: when migrating to a snapshot-caching accessor, audit every load*().push/splice/sort/assign for in-place mutation and rewrite as spread/non-mutating. Old code's tests still pass; the leak surfaces only at runtime when the cached reference is reused. Filing in pr-review-practices.
Merge gate: 7/7 CI ✓, Codex P2 substantively closed at HEAD ✓, Devin Review surface findings worth a 30s look. Second-approval criterion met when Codex re-reviews 9b79810717 (current bot pass predates the audit-fix commit).
E2E Test Results — localStorage Storage MigrationRan Playwright CDP route interception tests against Vite dev server ( 9/9 localStorage assertions passed. Test Results
Escalation: Pre-existing JS error (unrelated)
localStorage keys observed |
Prompt / plan
Part of the localStorage consolidation effort. This is PR C in the chain:
typed-storage.tsfactory utilities (LUM-2044) ✓ mergedlocalStoragecalls through shared utilities (LUM-2047)What changed
Migrates 5 domain storage files from hand-rolled SSR guards + try/catch + JSON parsing to the typed-storage factory created in LUM-2044:
app-pin-storage.tscreateStorageAccessor<PinnedAppEntry[]>last-viewed-conversation-storage.tscreateKeyedStorageAccessor<string|null>context-window-storage.tscreateRecordStorageAccessor<ContextWindowUsage>dismissed-surfaces-storage.tscreateRecordStorageAccessor<string[]>sidebar-group-collapse-storage.tscreateKeyedStorageAccessor<string[]>Net: -264 lines (including new shared test helper and storage validators).
Additional changes
Extract
MemoryStoragetest helper (memory-storage.test-helper.ts): DRYs up identicalMemoryStorageclass + window/localStorage mocking duplicated across 3 test files. NewinstallMemoryStorage()function handlesbeforeAll/afterAlllifecycle.Fix
pinApp()mutation bug:pinApp()usedentries.push()on the array returned bystorage.load(), which mutates the cached fallback array inside the accessor. Fixed to use spread ([...entries, newEntry]). This was latent in the old code too (the fallback was[]defined inline), but becomes observable with the factory's snapshot caching.Extract shared
storage-validators.ts:isStringArraytype guard was duplicated acrossdismissed-surfaces-storage.tsandsidebar-group-collapse-storage.ts. Extracted todomains/chat/utils/storage-validators.ts(both consumers are within the chat domain).Colocate misplaced test file:
last-viewed-conversation-storage.test.tswas indomains/chat/utils/but its source is inutils/(cross-domain shared utility used by bothconversations/andlogs/). Moved test toutils/to colocate per CONVENTIONS.md.Audit summary (20-point checklist)
All 20 items from the architectural audit checklist pass:
Why this is safe
maxEntriestrimming that each file implemented manually. The only behavioral difference is snapshot caching increateStorageAccessor(returns stable references for unchanged values), which is strictly better for React rendering.Alternatives not taken
local-settings.ts.Closes LUM-2046
Test plan
app-pin-storage,last-viewed-conversation-storage,sidebar-group-collapse-storage,typed-storage)bun run lintpassesbunx tsc --noEmitpassesLink to Devin session: https://app.devin.ai/sessions/4b3b130bbf274e5487da3b4992883093
Requested by: @ashleeradka