diff --git a/packages/core/src/adapters.ts b/packages/core/src/adapters.ts index 8c13a67..fa86cc2 100644 --- a/packages/core/src/adapters.ts +++ b/packages/core/src/adapters.ts @@ -70,8 +70,8 @@ export class BrowserStorageAdapter implements StorageAdapter { } getItem(key: string): string | null { - if (typeof window === 'undefined' || !window.localStorage) return null; try { + if (typeof window === 'undefined' || !window.localStorage) return null; const namespaced = window.localStorage.getItem(this.nsKey(key)); if (namespaced !== null) return namespaced; @@ -89,7 +89,8 @@ export class BrowserStorageAdapter implements StorageAdapter { return null; } catch { - // SecurityError in sandboxed iframes / opaque origins + // SecurityError in sandboxed iframes / opaque origins — including the + // edge case where accessing window.localStorage as a property itself throws. return null; } } @@ -101,6 +102,21 @@ export class BrowserStorageAdapter implements StorageAdapter { // so the error surfaces through the configured onError callback. window.localStorage.setItem(this.nsKey(key), value); } + + /** + * Remove a previously persisted value from localStorage. + * Silently no-ops when localStorage is unavailable (SSR, sandboxed + * iframes, incognito with storage blocked) or when a `SecurityError` is + * thrown. The key is automatically namespaced before the removal. + */ + removeItem(key: string): void { + try { + if (typeof window === 'undefined' || !window.localStorage) return; + window.localStorage.removeItem(this.nsKey(key)); + } catch { + // SecurityError in sandboxed iframes / opaque origins + } + } } /* ------------------------------------------------------------------ */ diff --git a/packages/core/src/engine/intent-engine.ts b/packages/core/src/engine/intent-engine.ts index a9eae71..a25e52e 100644 --- a/packages/core/src/engine/intent-engine.ts +++ b/packages/core/src/engine/intent-engine.ts @@ -34,6 +34,7 @@ import type { import type { IntentEventMap } from '../types/events.js'; import { EventEmitter } from './event-emitter.js'; import { normalizeRouteState } from '../utils/route-normalizer.js'; +import { getConfidence } from './signal-engine.js'; /** Maximum trajectory window kept for signal evaluation. */ const TRAJECTORY_WINDOW = 20; @@ -376,7 +377,7 @@ export class IntentEngine { expectedBaselineLogLikelihood: trajectoryResult.baselineLogLikelihood, zScore: trajectoryResult.zScore, sampleSize, - confidence: sampleSize < 10 ? 'low' : sampleSize < 30 ? 'medium' : 'high', + confidence: getConfidence(sampleSize), }); } } diff --git a/packages/core/src/engine/persistence-strategies.ts b/packages/core/src/engine/persistence-strategies.ts index eb7248c..5ac2290 100644 --- a/packages/core/src/engine/persistence-strategies.ts +++ b/packages/core/src/engine/persistence-strategies.ts @@ -46,6 +46,14 @@ export interface PersistStrategy { close(): void; } +/** Returns `true` when `err` is a storage quota exhaustion error. */ +function isQuotaError(err: unknown): boolean { + return ( + err instanceof Error && + (err.name === 'QuotaExceededError' || err.message.toLowerCase().includes('quota')) + ); +} + export abstract class BasePersistStrategy implements PersistStrategy { protected lastPersistedAt = -Infinity; protected throttleTimer: TimerHandle | null = null; @@ -134,14 +142,12 @@ export class SyncPersistStrategy extends BasePersistStrategy { this.lastPersistedAt = this.ctx.getTimer().now(); this.ctx.setEngineHealth('healthy'); } catch (err) { - const isQuota = - err instanceof Error && - (err.name === 'QuotaExceededError' || err.message.toLowerCase().includes('quota')); - if (isQuota) { + const quota = isQuotaError(err); + if (quota) { this.ctx.setEngineHealth('quota_exceeded'); } this.ctx.reportError( - isQuota ? 'QUOTA_EXCEEDED' : 'STORAGE_WRITE', + quota ? 'QUOTA_EXCEEDED' : 'STORAGE_WRITE', err instanceof Error ? err.message : String(err), err, ); @@ -202,14 +208,12 @@ export class AsyncPersistStrategy extends BasePersistStrategy { this.ctx.markDirty(); this.asyncWriteFailCount += 1; - const isQuota = - err instanceof Error && - (err.name === 'QuotaExceededError' || err.message.toLowerCase().includes('quota')); - if (isQuota) { + const quota = isQuotaError(err); + if (quota) { this.ctx.setEngineHealth('quota_exceeded'); } this.ctx.reportError( - isQuota ? 'QUOTA_EXCEEDED' : 'STORAGE_WRITE', + quota ? 'QUOTA_EXCEEDED' : 'STORAGE_WRITE', err instanceof Error ? err.message : String(err), err, ); diff --git a/packages/core/src/engine/signal-engine.ts b/packages/core/src/engine/signal-engine.ts index a033ae2..9e3523e 100644 --- a/packages/core/src/engine/signal-engine.ts +++ b/packages/core/src/engine/signal-engine.ts @@ -25,7 +25,7 @@ import type { export { EventEmitter }; -function getConfidence(sampleSize: number): 'low' | 'medium' | 'high' { +export function getConfidence(sampleSize: number): 'low' | 'medium' | 'high' { if (sampleSize < 10) return 'low'; if (sampleSize < 30) return 'medium'; return 'high'; diff --git a/packages/core/src/plugins/web/LocalStorageAdapter.ts b/packages/core/src/plugins/web/LocalStorageAdapter.ts index afda45e..93778fa 100644 --- a/packages/core/src/plugins/web/LocalStorageAdapter.ts +++ b/packages/core/src/plugins/web/LocalStorageAdapter.ts @@ -51,12 +51,13 @@ */ import type { IPersistenceAdapter } from '../../types/microkernel.js'; +import { BrowserStorageAdapter } from '../../adapters.js'; /** Default namespace prefix applied to every localStorage key. */ const DEFAULT_NAMESPACE = 'passiveintent:'; export class LocalStorageAdapter implements IPersistenceAdapter { - private readonly namespace: string; + private readonly storage: BrowserStorageAdapter; /** * @param namespace Prefix prepended to every key before it is read from or @@ -65,12 +66,7 @@ export class LocalStorageAdapter implements IPersistenceAdapter { * (not recommended when multiple instances share an origin). */ constructor(namespace: string = DEFAULT_NAMESPACE) { - this.namespace = namespace; - } - - /** Compute the namespaced key used for all localStorage operations. */ - private nsKey(key: string): string { - return `${this.namespace}${key}`; + this.storage = new BrowserStorageAdapter(namespace); } /** @@ -78,49 +74,28 @@ export class LocalStorageAdapter implements IPersistenceAdapter { * Returns `null` when the key is absent, when localStorage is unavailable * (SSR, incognito with storage blocked, sandboxed iframe), or when a * `SecurityError` is thrown. + * + * Namespace prefixing, SSR guarding, and legacy key migration are all + * handled by the underlying `BrowserStorageAdapter`. */ load(key: string): string | null { - try { - if (typeof window === 'undefined' || !window.localStorage) return null; - - const namespaced = window.localStorage.getItem(this.nsKey(key)); - if (namespaced !== null) return namespaced; - - // Legacy migration: when using the default namespace, check the old - // unprefixed key so existing installs are not silently wiped on upgrade. - if (this.namespace === DEFAULT_NAMESPACE) { - const legacy = window.localStorage.getItem(key); - if (legacy !== null) { - // Migrate to the namespaced key and remove the legacy entry. - window.localStorage.setItem(this.nsKey(key), legacy); - window.localStorage.removeItem(key); - return legacy; - } - } - - return null; - } catch { - // SecurityError accessing window.localStorage on sandboxed/opaque origins, - // or SecurityError / other errors from getItem. - return null; - } + return this.storage.getItem(key); } /** * Save a value to localStorage. * Silently no-ops when localStorage is unavailable. * - * Unlike `BrowserStorageAdapter.setItem`, this method also catches and - * **swallows** `QuotaExceededError` so that a full storage partition does - * not surface an uncaught exception. Higher-layer error handling - * (IntentEngine's `onError` callback) is the right place to observe this - * failure; the caller (`IntentEngine._persist()`) wraps this call in its - * own try/catch and routes any thrown error through `onError`. + * Wraps `BrowserStorageAdapter.setItem` in a try/catch so that + * `QuotaExceededError` and `SecurityError` are swallowed here rather than + * 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`. */ save(key: string, value: string): void { try { - if (typeof window === 'undefined' || !window.localStorage) return; - window.localStorage.setItem(this.nsKey(key), value); + this.storage.setItem(key, value); } catch { // SecurityError (sandboxed/opaque origin) or QuotaExceededError — swallowed. } @@ -131,11 +106,6 @@ export class LocalStorageAdapter implements IPersistenceAdapter { * Silently no-ops when localStorage is unavailable. */ delete(key: string): void { - try { - if (typeof window === 'undefined' || !window.localStorage) return; - window.localStorage.removeItem(this.nsKey(key)); - } catch { - // SecurityError or other storage errors — swallowed. - } + this.storage.removeItem(key); } } diff --git a/packages/core/tests/compatibility-matrix.test.mjs b/packages/core/tests/compatibility-matrix.test.mjs index f772b14..797090a 100644 --- a/packages/core/tests/compatibility-matrix.test.mjs +++ b/packages/core/tests/compatibility-matrix.test.mjs @@ -53,6 +53,7 @@ test('BrowserStorageAdapter gracefully degrades when window/localStorage are una const adapter = new BrowserStorageAdapter(); assert.equal(adapter.getItem('missing'), null); assert.doesNotThrow(() => adapter.setItem('k', 'v')); + assert.doesNotThrow(() => adapter.removeItem('k')); } finally { if (originalWindow !== undefined) { globalThis.window = originalWindow; @@ -60,6 +61,100 @@ test('BrowserStorageAdapter gracefully degrades when window/localStorage are una } }); +test('BrowserStorageAdapter: removeItem() removes a namespaced key from localStorage', () => { + const store = new Map(); + globalThis.window = { + localStorage: { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + }, + }; + try { + const adapter = new BrowserStorageAdapter(); + adapter.setItem('my-key', 'my-value'); + assert.equal(adapter.getItem('my-key'), 'my-value'); + adapter.removeItem('my-key'); + assert.equal(adapter.getItem('my-key'), null, 'value must be absent after removeItem'); + // Confirm the namespaced key (not the bare key) was removed. + assert.equal(store.has('passiveintent:my-key'), false); + assert.equal(store.has('my-key'), false); + } finally { + delete globalThis.window; + } +}); + +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, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + }, + }; + try { + const adapter = new BrowserStorageAdapter(); + // First read must fall back to the unprefixed key and return the value. + assert.equal(adapter.getItem('intent-key'), 'legacy-value'); + // The value must now be stored under the namespaced key. + assert.equal(store.get('passiveintent:intent-key'), 'legacy-value', 'value must be migrated to namespaced key'); + // The legacy unprefixed key must be removed. + assert.equal(store.has('intent-key'), false, 'legacy key must be deleted after migration'); + // Subsequent reads must hit the namespaced key directly. + assert.equal(adapter.getItem('intent-key'), 'legacy-value'); + } finally { + delete globalThis.window; + } +}); + +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, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + }, + }; + try { + const adapter = new BrowserStorageAdapter('my-mfe:'); + // Custom-namespace adapter must not touch the unprefixed key. + assert.equal(adapter.getItem('intent-key'), null); + assert.equal(store.has('intent-key'), true, 'legacy key must remain untouched'); + } finally { + delete globalThis.window; + } +}); + +test('BrowserStorageAdapter: custom namespace prefixes keys independently from the default namespace', () => { + const store = new Map(); + globalThis.window = { + localStorage: { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + }, + }; + try { + const def = new BrowserStorageAdapter(); + const mfe = new BrowserStorageAdapter('checkout:'); + def.setItem('state', 'global'); + mfe.setItem('state', 'checkout'); + // Each adapter must only see its own namespaced value. + assert.equal(def.getItem('state'), 'global'); + assert.equal(mfe.getItem('state'), 'checkout'); + // Underlying keys must be stored with the correct prefixes. + assert.equal(store.get('passiveintent:state'), 'global'); + assert.equal(store.get('checkout:state'), 'checkout'); + } finally { + delete globalThis.window; + } +}); + test('BrowserTimerAdapter works with platform timers and monotonic fallback', () => { const timer = new BrowserTimerAdapter(); let fired = false; diff --git a/packages/core/tests/microkernel.test.mjs b/packages/core/tests/microkernel.test.mjs index 2121411..4fc1820 100644 --- a/packages/core/tests/microkernel.test.mjs +++ b/packages/core/tests/microkernel.test.mjs @@ -476,6 +476,46 @@ test('IntentEngine track(): emits trajectory_anomaly when evaluateTrajectory ret engine.destroy(); }); +test('IntentEngine track(): trajectory_anomaly confidence is "medium" for sampleSize in [10, 30)', () => { + const { engine } = makeEngine({ + model: { + trajectory: { + zScore: 3.1, + isAnomalous: true, + logLikelihood: -7, + baselineLogLikelihood: -2, + sampleSize: 20, + }, + }, + }); + const events = []; + engine.on('trajectory_anomaly', (e) => events.push(e)); + engine.track('/a'); + engine.track('/b'); + assert.equal(events[0].confidence, 'medium'); + engine.destroy(); +}); + +test('IntentEngine track(): trajectory_anomaly confidence is "low" for sampleSize < 10', () => { + const { engine } = makeEngine({ + model: { + trajectory: { + zScore: 2.8, + isAnomalous: true, + logLikelihood: -5, + baselineLogLikelihood: -2, + sampleSize: 5, + }, + }, + }); + const events = []; + engine.on('trajectory_anomaly', (e) => events.push(e)); + engine.track('/a'); + engine.track('/b'); + assert.equal(events[0].confidence, 'low'); + engine.destroy(); +}); + test('IntentEngine track(): does NOT emit trajectory_anomaly when evaluateTrajectory returns null', () => { const { engine } = makeEngine({ model: { trajectory: null } }); const events = []; @@ -991,6 +1031,97 @@ test('LocalStorageAdapter: multiple adapters sharing storage are independent by } }); +test('LocalStorageAdapter: delete() removes a previously saved value', () => { + const store = new Map(); + global.window = { + localStorage: { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + }, + }; + try { + const adapter = new LocalStorageAdapter(); + adapter.save('intent-key', 'some-value'); + assert.equal(adapter.load('intent-key'), 'some-value'); + adapter.delete('intent-key'); + assert.equal(adapter.load('intent-key'), null); + } finally { + delete global.window; + } +}); + +test('LocalStorageAdapter: delete() is a no-op in Node.js (no window object)', () => { + const adapter = new LocalStorageAdapter(); + assert.doesNotThrow(() => adapter.delete('any-key')); +}); + +test('LocalStorageAdapter: delete() swallows SecurityError from removeItem', () => { + global.window = { + localStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => { + const e = new Error('SecurityError'); + e.name = 'SecurityError'; + throw e; + }, + }, + }; + try { + const adapter = new LocalStorageAdapter(); + assert.doesNotThrow(() => adapter.delete('any-key')); + } finally { + delete global.window; + } +}); + +test('LocalStorageAdapter: load() migrates legacy unprefixed key to namespaced key on first read', () => { + const store = new Map(); + // Pre-populate the store with a legacy (unprefixed) key, as if written by an old SDK version. + store.set('intent-key', 'legacy-value'); + global.window = { + localStorage: { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + }, + }; + try { + const adapter = new LocalStorageAdapter(); + // First read: should find the legacy key, migrate it, and return the value. + assert.equal(adapter.load('intent-key'), 'legacy-value'); + // After migration the value must be stored under the namespaced key. + assert.equal(store.get('passiveintent:intent-key'), 'legacy-value', 'namespaced key must be written'); + // The old unprefixed key must be removed. + assert.equal(store.has('intent-key'), false, 'legacy key must be deleted after migration'); + } finally { + delete global.window; + } +}); + +test('LocalStorageAdapter: load() does NOT perform legacy migration for custom namespaces', () => { + const store = new Map(); + // A custom-namespace adapter must never touch the unprefixed key. + store.set('intent-key', 'legacy-value'); + global.window = { + localStorage: { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + }, + }; + try { + const adapter = new LocalStorageAdapter('my-mfe:'); + // The namespaced key is absent; migration must NOT fall back to the unprefixed key. + assert.equal(adapter.load('intent-key'), null); + // Legacy key must remain untouched. + assert.equal(store.has('intent-key'), true, 'legacy key must not be touched by a custom-namespace adapter'); + } finally { + delete global.window; + } +}); + // =========================================================================== // Section 8 — createBrowserIntent factory // =========================================================================== diff --git a/packages/core/tests/persist-strategies.test.mjs b/packages/core/tests/persist-strategies.test.mjs index 3fd6e43..fe194bb 100644 --- a/packages/core/tests/persist-strategies.test.mjs +++ b/packages/core/tests/persist-strategies.test.mjs @@ -215,6 +215,24 @@ test('SyncPersistStrategy: reports QUOTA_EXCEEDED and sets engineHealth on quota assert.equal(state.engineHealth, 'quota_exceeded'); }); +test('SyncPersistStrategy: detects quota error via message text when err.name is generic', () => { + // isQuotaError also matches errors whose message contains "quota", regardless of err.name. + const quotaErr = new Error('storage quota exceeded'); + + const { ctx, state } = makeCtx({ + storageSetItem: () => { + throw quotaErr; + }, + }); + + const strategy = new SyncPersistStrategy(ctx); + strategy.persist(); + + assert.equal(state.errors.length, 1); + assert.equal(state.errors[0].code, 'QUOTA_EXCEEDED'); + assert.equal(state.engineHealth, 'quota_exceeded'); +}); + // --------------------------------------------------------------------------- // BasePersistStrategy – throttle + trailing flush // --------------------------------------------------------------------------- @@ -461,6 +479,28 @@ test('AsyncPersistStrategy: reports QUOTA_EXCEEDED and sets engineHealth on quot assert.equal(state.engineHealth, 'quota_exceeded'); }); +test('AsyncPersistStrategy: detects quota error via message text when err.name is generic', async () => { + // isQuotaError also matches errors whose message contains "quota", regardless of err.name. + const quotaErr = new Error('storage quota exceeded'); + + const { ctx, state } = makeCtx({ + asyncStorageSetItem: async () => { + throw quotaErr; + }, + debounceMs: 0, + timerNow: () => 0, + }); + + const strategy = new AsyncPersistStrategy(ctx); + strategy.persist(); + + await new Promise((r) => setTimeout(r, 20)); + + assert.equal(state.errors.length, 1); + assert.equal(state.errors[0].code, 'QUOTA_EXCEEDED'); + assert.equal(state.engineHealth, 'quota_exceeded'); +}); + // --------------------------------------------------------------------------- // AsyncPersistStrategy – flushNow cancels retry timer and writes immediately // --------------------------------------------------------------------------- diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index 281df8a..49914f0 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -80,6 +80,28 @@ export interface PassiveIntentProviderProps { // ── Provider ────────────────────────────────────────────────────────────────── +/** + * Merges adapter overrides from `adaptersRef` into the base config stored in + * `configRef`. Extracted as a standalone helper so the identical logic is not + * copy-pasted between the synchronous render-phase init path and the React + * Strict Mode effect-phase re-init path. + */ +function buildMergedConfig( + configRef: React.RefObject, + adaptersRef: React.RefObject< + Partial<{ storage: StorageAdapter; timer: TimerAdapter; lifecycle: LifecycleAdapter }> + >, +): IntentManagerConfig { + return { + ...configRef.current, + ...(adaptersRef.current?.storage !== undefined && { storage: adaptersRef.current.storage }), + ...(adaptersRef.current?.timer !== undefined && { timer: adaptersRef.current.timer }), + ...(adaptersRef.current?.lifecycle !== undefined && { + lifecycleAdapter: adaptersRef.current.lifecycle, + }), + }; +} + /** * `PassiveIntentProvider` — place once near the root of your React app to share * a single `IntentManager` instance across the entire component tree. @@ -136,16 +158,8 @@ export function PassiveIntentProvider({ // this, domain hooks like useExitIntent() would call ctx.on() while // instanceRef is still null, silently dropping subscriptions. if (instanceRef.current === null && IS_BROWSER) { - const mergedConfig: IntentManagerConfig = { - ...configRef.current, - ...(adaptersRef.current?.storage !== undefined && { storage: adaptersRef.current.storage }), - ...(adaptersRef.current?.timer !== undefined && { timer: adaptersRef.current.timer }), - ...(adaptersRef.current?.lifecycle !== undefined && { - lifecycleAdapter: adaptersRef.current.lifecycle, - }), - }; try { - instanceRef.current = new IntentManager(mergedConfig); + instanceRef.current = new IntentManager(buildMergedConfig(configRef, adaptersRef)); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); if (onErrorRef.current) { @@ -180,16 +194,8 @@ export function PassiveIntentProvider({ // Skip recreation if render-phase init already failed — retrying would // duplicate the expensive failure and fire onError a second time. if (failedInitRef.current) return; - const mergedConfig: IntentManagerConfig = { - ...configRef.current, - ...(adaptersRef.current?.storage !== undefined && { storage: adaptersRef.current.storage }), - ...(adaptersRef.current?.timer !== undefined && { timer: adaptersRef.current.timer }), - ...(adaptersRef.current?.lifecycle !== undefined && { - lifecycleAdapter: adaptersRef.current.lifecycle, - }), - }; try { - instanceRef.current = new IntentManager(mergedConfig); + instanceRef.current = new IntentManager(buildMergedConfig(configRef, adaptersRef)); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); failedInitRef.current = true;