refactor: eliminate code duplication across storage adapters, persistence strategies, and React provider#104
refactor: eliminate code duplication across storage adapters, persistence strategies, and React provider#104Copilot wants to merge 3 commits into
Conversation
Agent-Logs-Url: https://github.com/passiveintent/core/sessions/b01972fe-1abc-4e16-a6c5-ea0640fe2ad2 Co-authored-by: purushpsm147 <19334242+purushpsm147@users.noreply.github.com>
Agent-Logs-Url: https://github.com/passiveintent/core/sessions/b01972fe-1abc-4e16-a6c5-ea0640fe2ad2 Co-authored-by: purushpsm147 <19334242+purushpsm147@users.noreply.github.com>
…ace isolation, isQuotaError message branch, and getConfidence thresholds Agent-Logs-Url: https://github.com/passiveintent/core/sessions/9a9c8eeb-53e7-488b-b026-15c3406371ec Co-authored-by: purushpsm147 <19334242+purushpsm147@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Refactors duplicated logic across storage adapters, persistence strategies, the intent engine, and the React provider, with additional tests added to cover refactor-exposed gaps.
Changes:
- Delegate
LocalStorageAdapterbehavior toBrowserStorageAdapterand addBrowserStorageAdapter.removeItem(). - Extract shared helpers (
isQuotaError,getConfidence,buildMergedConfig) to remove duplicated logic. - Add/extend tests for quota detection, confidence thresholds, storage key migration, and removal behavior.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/provider.tsx | Extracts adapter/config merge logic into buildMergedConfig to avoid duplication across init paths. |
| packages/core/src/plugins/web/LocalStorageAdapter.ts | Delegates persistence operations to BrowserStorageAdapter and updates documentation. |
| packages/core/src/adapters.ts | Hardens getItem window/localStorage access and adds removeItem. |
| packages/core/src/engine/persistence-strategies.ts | Extracts isQuotaError helper and reuses it in sync/async strategies. |
| packages/core/src/engine/signal-engine.ts | Exports getConfidence for reuse. |
| packages/core/src/engine/intent-engine.ts | Uses shared getConfidence(sampleSize) instead of inlined thresholds. |
| packages/core/tests/persist-strategies.test.mjs | Adds tests for quota detection via message text. |
| packages/core/tests/microkernel.test.mjs | Adds confidence-threshold tests and LocalStorageAdapter.delete/migration tests. |
| packages/core/tests/compatibility-matrix.test.mjs | Adds tests for BrowserStorageAdapter.removeItem and namespace/migration behaviors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * propagating to the caller. Higher-layer error handling (IntentEngine's | ||
| * `onError` callback) is the right place to observe these failures; the | ||
| * caller (`IntentEngine._persist()`) already wraps this call in its own | ||
| * try/catch and routes any thrown error through `onError`. |
There was a problem hiding this comment.
The updated JSDoc for save() says higher-layer error handling via IntentEngine._persist()/onError is the right place to observe quota/security failures, but this method intentionally swallows those errors in its own try/catch. As written, callers generally won’t observe these failures via onError because nothing is thrown. Please adjust the comment to reflect the actual behavior/contract (swallowed errors are not observable upstream).
| * propagating to the caller. Higher-layer error handling (IntentEngine's | |
| * `onError` callback) is the right place to observe these failures; the | |
| * caller (`IntentEngine._persist()`) already wraps this call in its own | |
| * try/catch and routes any thrown error through `onError`. | |
| * propagating to the caller. As a result, these storage write failures | |
| * are intentionally not observable upstream via `IntentEngine._persist()` | |
| * or an `onError` callback, because nothing is rethrown from this method. |
| test('BrowserStorageAdapter: removeItem() removes a namespaced key from localStorage', () => { | ||
| const store = new Map(); | ||
| globalThis.window = { | ||
| localStorage: { | ||
| getItem: (k) => store.get(k) ?? null, |
There was a problem hiding this comment.
This test mutates globalThis.window and then deletes it in finally, but it doesn’t preserve/restore any pre-existing globalThis.window. The test above in this file saves originalWindow and restores it, which avoids cross-test contamination when running under environments that already define window (e.g. jsdom). Consider using the same save/restore pattern here.
| test('BrowserStorageAdapter: getItem() migrates legacy unprefixed key to namespaced key on first read', () => { | ||
| const store = new Map(); | ||
| // Simulate an old SDK installation that wrote the key without a namespace prefix. | ||
| store.set('intent-key', 'legacy-value'); | ||
| globalThis.window = { | ||
| localStorage: { | ||
| getItem: (k) => store.get(k) ?? null, |
There was a problem hiding this comment.
This test deletes globalThis.window on cleanup without restoring any prior value. For better test isolation (and consistency with the first test in this file), capture const originalWindow = globalThis.window and restore it in finally when it was defined.
| test('BrowserStorageAdapter: getItem() does NOT migrate legacy key for custom namespaces', () => { | ||
| const store = new Map(); | ||
| store.set('intent-key', 'legacy-value'); | ||
| globalThis.window = { | ||
| localStorage: { | ||
| getItem: (k) => store.get(k) ?? null, |
There was a problem hiding this comment.
Test isolation: this test overwrites globalThis.window and then deletes it, which can break subsequent tests if window was already defined. Please preserve originalWindow and restore it in finally (as done in the earlier degradation test).
| test('BrowserStorageAdapter: custom namespace prefixes keys independently from the default namespace', () => { | ||
| const store = new Map(); | ||
| globalThis.window = { | ||
| localStorage: { | ||
| getItem: (k) => store.get(k) ?? null, |
There was a problem hiding this comment.
Same globalThis.window cleanup issue as earlier: this test deletes window rather than restoring any previous value. Saving/restoring originalWindow will keep the test file safe to run under jsdom-like environments.
| configRef: React.RefObject<IntentManagerConfig>, | ||
| adaptersRef: React.RefObject< | ||
| Partial<{ storage: StorageAdapter; timer: TimerAdapter; lifecycle: LifecycleAdapter }> |
There was a problem hiding this comment.
buildMergedConfig is typed with React.RefObject, but both refs here are created via useRef(...) and have non-null current values (configRef) and possibly undefined (adaptersRef). With strict TS settings this signature can cause type errors (e.g., configRef.current possibly null when spreading, and adaptersRef not assignable if its current can be undefined). Consider changing the helper to accept React.MutableRefObject<IntentManagerConfig> and React.MutableRefObject<Partial<...> | undefined> (or normalize adaptersRef to {}/null at creation) so the types match actual usage.
| configRef: React.RefObject<IntentManagerConfig>, | |
| adaptersRef: React.RefObject< | |
| Partial<{ storage: StorageAdapter; timer: TimerAdapter; lifecycle: LifecycleAdapter }> | |
| configRef: React.MutableRefObject<IntentManagerConfig>, | |
| adaptersRef: React.MutableRefObject< | |
| Partial<{ storage: StorageAdapter; timer: TimerAdapter; lifecycle: LifecycleAdapter }> | undefined |
Four meaningful duplications removed across
@passiveintent/coreand@passiveintent/react, plus 13 new tests filling coverage gaps exposed by the refactor (506 → 519).Changes
LocalStorageAdapter→ delegates toBrowserStorageAdapterLocalStorageAdapterwas reimplementing ~60 lines already inBrowserStorageAdapter: namespace field,nsKey(), SSR guards, and legacy key migration. It now holds an internalBrowserStorageAdapterinstance and delegates all three methods:load→getItemsave→setItem(wrapped in try/catch to preserve swallow-on-error contract)delete→ newremoveItemadded toBrowserStorageAdapterBonus fix:
BrowserStorageAdapter.getItem's window guard was outside the try/catch. Accessingwindow.localStorageas a property can itself throwSecurityErrorin sandboxed iframes — the guard is now inside the try.isQuotaErrorhelper extracted (persistence-strategies.ts)Identical 2-line quota detection was copy-pasted in
SyncPersistStrategy.persistandAsyncPersistStrategy.handleAsyncWriteError. Extracted as a module-levelisQuotaError(err: unknown): boolean.getConfidenceexported fromsignal-engine.tsintent-engine.tsinlinedsampleSize < 10 ? 'low' : sampleSize < 30 ? 'medium' : 'high'— identical to the privategetConfidenceinsignal-engine.ts. Made it exported and imported inintent-engine.ts.buildMergedConfigextracted inprovider.tsxThe adapter-merge block was copy-pasted between the render-phase init path and the React Strict Mode effect-phase re-init path. Extracted as a module-level
buildMergedConfig(configRef, adaptersRef)helper.New tests (13 added)
LocalStorageAdapter.delete()LocalStorageAdapterandBrowserStorageAdapter)BrowserStorageAdapter.removeItem()BrowserStorageAdapternamespace isolationisQuotaErrormessage brancherr.message.includes('quota')path in both Sync and Async strategiesgetConfidencethresholds'low'(sampleSize < 10) and'medium'(10 ≤ sampleSize < 30) viatrajectory_anomaly