diff --git a/src/arts/stor/README.md b/src/arts/stor/README.md index 66b06bb..b3846e8 100644 --- a/src/arts/stor/README.md +++ b/src/arts/stor/README.md @@ -275,6 +275,21 @@ const draft = Storage.entry('bio-draft', '', { adapter: sessionAdapter }); const cart = Storage.entry('cart', { items: [] }); ``` +## Storage.entries() + +Snapshot of every live entry registered against this storage: + +```ts +Storage.entries(); +// [ +// { key: 'theme', fullKey: 'theme', adapter: 'cookie' }, +// { key: 'cart', fullKey: 'app:cart', adapter: 'memory' } +// ] +``` + +Pure metadata, no entry handles. Use it for devtools panels, debug pages or +audit logs. Disposed entries drop from the snapshot. + ## Storage.clear() Wipe every entry created against this `EngineStorage` from its underlying @@ -314,6 +329,12 @@ cross-context channel; their entries still sync intra-tab via the bus. If an entry overrides `adapter`, it gets its own bus channel and does not receive events from another backend using the same key. +The cookie adapter does **not** poll `document.cookie` for changes. Browsers +do not emit a native event when a cookie mutates from another tab or from +the server, and a polling loop would burn CPU for a niche use case. If you +need cross-tab sync of a cookie, mirror it to localStorage and listen there, +or hook into a server-sent event your backend already exposes. + ## Mutación profunda `ActiveStorageEntry.current` is **not** a deep proxy. Assigning to a nested diff --git a/src/arts/stor/active-storage.svelte.ts b/src/arts/stor/active-storage.svelte.ts index 3fbc912..5be2c3a 100644 --- a/src/arts/stor/active-storage.svelte.ts +++ b/src/arts/stor/active-storage.svelte.ts @@ -92,6 +92,7 @@ export function createActiveStorage(options: EngineStorageOptions = {}): ActiveS adapter: engine.adapter, namespace: engine.namespace, entry: activeEntry, + entries: () => engine.entries(), clear: () => engine.clear(), dispose() { engine.dispose(); diff --git a/src/arts/stor/engine-storage.ts b/src/arts/stor/engine-storage.ts index ec233db..c1dd406 100644 --- a/src/arts/stor/engine-storage.ts +++ b/src/arts/stor/engine-storage.ts @@ -44,8 +44,11 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS const adapterIds = new WeakMap(); let adapterIdSeq = 0; - // First-write-wins registry for conflict diagnostics. - const registeredDefaults = new Map(); + // First-write-wins registry for conflict diagnostics. Refcounted so the + // entry is forgotten once every live registration is disposed — avoids + // false positives when a key is re-created with a different default + // after the previous owner cleaned up. + const registeredDefaults = new Map(); const entryDisposers = new Set<() => void>(); // Track every entry created so `clear()` can wipe only what this storage // owns and notify the bus uniformly. `key` is the original (pre-namespace) @@ -128,11 +131,14 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS const initialDefault = resolveDefault(); // Conflict diagnostic — first registration of adapter+`fullKey` wins. + // Refcounted so the registry forgets the slot once the last entry on + // it is disposed; otherwise re-creating the same key with a different + // default would emit a stale warning even when nobody owns it anymore. const registryKey = registryKeyFor(entryAdapter, fullKey); const previous = registeredDefaults.get(registryKey); if (previous !== undefined) { try { - if (JSON.stringify(previous) !== JSON.stringify(initialDefault)) { + if (JSON.stringify(previous.value) !== JSON.stringify(initialDefault)) { report( { key, @@ -146,8 +152,9 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS } catch { /* circular default — skip the diagnostic */ } + previous.refs += 1; } else { - registeredDefaults.set(registryKey, initialDefault); + registeredDefaults.set(registryKey, { value: initialDefault, refs: 1 }); } const isRaw = entryOptions.raw === true; @@ -331,6 +338,11 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS registeredEntries.add(registration); const dispose = (): void => { registeredEntries.delete(registration); + const slot = registeredDefaults.get(registryKey); + if (slot !== undefined) { + slot.refs -= 1; + if (slot.refs <= 0) registeredDefaults.delete(registryKey); + } localDisposers.splice(0).forEach((fn) => fn()); }; entryDisposers.add(dispose); @@ -366,6 +378,14 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS return handle; } + function entriesSnapshot(): ReadonlyArray<{ key: string; fullKey: string; adapter: string }> { + return [...registeredEntries].map((r) => ({ + key: r.key, + fullKey: r.fullKey, + adapter: r.adapter.name + })); + } + function clear(): void { // Snapshot first — `removeItem` does not mutate the registry, but // future iterations of the design might, so this is defensive. @@ -386,6 +406,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS adapter, namespace, entry, + entries: entriesSnapshot, clear, dispose() { if (disposed) return; diff --git a/src/arts/stor/index.ts b/src/arts/stor/index.ts index 8b64245..7cb83ff 100644 --- a/src/arts/stor/index.ts +++ b/src/arts/stor/index.ts @@ -23,6 +23,7 @@ export type { StorageEntryOptions, StorageEntry, ActiveStorageEntry, + StorageEntryInfo, EngineStorage, ActiveStorage, EngineStorageOptions diff --git a/src/arts/stor/test/cookie-adapter-client.test.ts b/src/arts/stor/test/cookie-adapter-client.test.ts new file mode 100644 index 0000000..bdbce79 --- /dev/null +++ b/src/arts/stor/test/cookie-adapter-client.test.ts @@ -0,0 +1,186 @@ +/** + * cookieAdapter() — client-side tests with mocked `document`. + * + * The default Vitest project is node, so `document` is undefined and the + * adapter no-ops on every call. We install a tiny stub that mirrors the + * subset of `document.cookie` semantics the adapter touches: a single + * string holding a serialized cookie jar, plus a setter that merges new + * declarations. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SyncStorageAdapter } from '../types'; + +interface CookieJarStub { + get cookie(): string; + set cookie(value: string); +} + +function installFakeDocument(): { + doc: CookieJarStub; + jar: Map; + restore: () => void; +} { + const jar = new Map(); + + const doc: CookieJarStub = { + get cookie() { + return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + }, + set cookie(value: string) { + // Browsers parse the first `name=value`; remaining segments are + // attributes. Honor `max-age=0` as deletion. + const head = value.split(';')[0].trim(); + const eq = head.indexOf('='); + if (eq < 0) return; + const name = head.slice(0, eq); + const val = head.slice(eq + 1); + const isDeletion = /max-age\s*=\s*0\b/i.test(value); + if (isDeletion) { + jar.delete(name); + return; + } + jar.set(name, val); + } + }; + + const original = (globalThis as { document?: unknown }).document; + Object.defineProperty(globalThis, 'document', { + value: doc, + writable: true, + configurable: true + }); + + return { + doc, + jar, + restore: () => { + if (original === undefined) { + delete (globalThis as { document?: unknown }).document; + } else { + Object.defineProperty(globalThis, 'document', { + value: original, + writable: true, + configurable: true + }); + } + } + }; +} + +let env: ReturnType; +let cookieAdapter: typeof import('../adapters/cookie').cookieAdapter; + +beforeEach(async () => { + env = installFakeDocument(); + // Re-import so the module-level `isBrowser` flag re-evaluates against the + // freshly installed `document`. Vitest caches modules per test file by + // default, so `vi.resetModules()` is required. + vi.resetModules(); + cookieAdapter = (await import('../adapters/cookie')).cookieAdapter; +}); + +afterEach(() => { + env.restore(); +}); + +describe('cookieAdapter() — client', () => { + it('writes and reads back a value via document.cookie', () => { + const adapter: SyncStorageAdapter = cookieAdapter(); + adapter.setItem('theme', 'forest'); + expect(env.jar.get('theme')).toBe('forest'); + expect(adapter.getItem('theme')).toBe('forest'); + }); + + it('returns null for missing keys', () => { + const adapter = cookieAdapter(); + expect(adapter.getItem('missing')).toBeNull(); + }); + + it('encodes special characters by default (encodeURIComponent)', () => { + const adapter = cookieAdapter(); + adapter.setItem('user name', 'Ada Lovelace'); + // The jar stores the encoded form. + expect(env.jar.get('user%20name')).toBe('Ada%20Lovelace'); + // Reading via the adapter returns the decoded form. + expect(adapter.getItem('user name')).toBe('Ada Lovelace'); + }); + + it('honors a custom encode/decode pair (identity)', () => { + const adapter = cookieAdapter({ encode: (s) => s, decode: (s) => s }); + adapter.setItem('locale', 'es-MX'); + expect(env.jar.get('locale')).toBe('es-MX'); + expect(adapter.getItem('locale')).toBe('es-MX'); + }); + + it('removeItem deletes the cookie via max-age=0', () => { + const adapter = cookieAdapter(); + adapter.setItem('theme', 'forest'); + expect(env.jar.has('theme')).toBe(true); + adapter.removeItem('theme'); + expect(env.jar.has('theme')).toBe(false); + }); + + it('builds Set-Cookie attributes (path, samesite, secure, max-age)', () => { + // Spy on the setter to capture the full Set-Cookie string. + const writes: string[] = []; + Object.defineProperty(env.doc, 'cookie', { + get() { + return [...env.jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + }, + set(value: string) { + writes.push(value); + const head = value.split(';')[0].trim(); + const eq = head.indexOf('='); + if (eq >= 0) env.jar.set(head.slice(0, eq), head.slice(eq + 1)); + }, + configurable: true + }); + + const adapter = cookieAdapter({ + path: '/admin', + sameSite: 'strict', + secure: true, + maxAge: 3600, + domain: 'example.com' + }); + adapter.setItem('theme', 'forest'); + + expect(writes).toHaveLength(1); + const written = writes[0].toLowerCase(); + expect(written).toContain('path=/admin'); + expect(written).toContain('samesite=strict'); + expect(written).toContain('secure'); + expect(written).toContain('max-age=3600'); + expect(written).toContain('domain=example.com'); + }); + + it('forces secure when sameSite is none', () => { + const writes: string[] = []; + Object.defineProperty(env.doc, 'cookie', { + get() { + return [...env.jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + }, + set(value: string) { + writes.push(value); + }, + configurable: true + }); + + const adapter = cookieAdapter({ sameSite: 'none' }); + adapter.setItem('cross', 'yes'); + + expect(writes[0].toLowerCase()).toContain('secure'); + }); + + it('parses multi-cookie strings without bleeding values', () => { + env.jar.set('theme', 'forest'); + env.jar.set('locale', 'es'); + env.jar.set('cart-size', '7'); + + const adapter = cookieAdapter(); + expect(adapter.getItem('theme')).toBe('forest'); + expect(adapter.getItem('locale')).toBe('es'); + expect(adapter.getItem('cart-size')).toBe('7'); + }); +}); diff --git a/src/arts/stor/test/engine-storage.test.ts b/src/arts/stor/test/engine-storage.test.ts index d135126..abd5c3e 100644 --- a/src/arts/stor/test/engine-storage.test.ts +++ b/src/arts/stor/test/engine-storage.test.ts @@ -407,6 +407,65 @@ describe('createEngineStorage — multi-entry conflict diagnostic', () => { s.entry('theme', 'forest', { adapter: createMemoryAdapter() }); expect(errors).toHaveLength(0); }); + + it('forgets the default after the last live entry is disposed (no stale warning)', () => { + const errors: StorageErrorContext[] = []; + const s = createEngineStorage({ + adapter: createMemoryAdapter(), + onError: (ctx) => errors.push(ctx) + }); + const a = s.entry('theme', 'base'); + a.dispose(); + // Re-create with a different default — registry was cleared, so no + // "defaults mismatch" should fire. + s.entry('theme', 'forest'); + expect(errors).toHaveLength(0); + }); + + it('keeps tracking the default while at least one entry remains', () => { + const errors: StorageErrorContext[] = []; + const s = createEngineStorage({ + adapter: createMemoryAdapter(), + onError: (ctx) => errors.push(ctx) + }); + const a = s.entry('theme', 'base'); + s.entry('theme', 'base'); // same default → no warn, refcount = 2 + a.dispose(); // refcount drops to 1, slot still alive + s.entry('theme', 'sunset'); // conflicts with the live 'base' + expect(errors).toHaveLength(1); + expect(String(errors[0].error)).toMatch(/defaults/); + }); +}); + +describe('createEngineStorage — entries() introspection', () => { + it('lists every live entry with key, fullKey and adapter name', () => { + const main = createMemoryAdapter(); + const cookie = createMemoryAdapter(); + const s = createEngineStorage({ adapter: main, namespace: 'app' }); + s.entry('theme', 'base', { adapter: cookie, namespace: false, raw: true }); + s.entry('cart', { items: [] as string[] }); + + const list = s.entries(); + expect(list).toHaveLength(2); + expect(list[0]).toEqual({ key: 'theme', fullKey: 'theme', adapter: 'memory' }); + expect(list[1]).toEqual({ key: 'cart', fullKey: 'app:cart', adapter: 'memory' }); + }); + + it('drops disposed entries from the snapshot', () => { + const s = createEngineStorage({ adapter: createMemoryAdapter() }); + const a = s.entry('a', 'd'); + const b = s.entry('b', 'd'); + expect(s.entries()).toHaveLength(2); + a.dispose(); + expect(s.entries()).toHaveLength(1); + expect(s.entries()[0].key).toBe('b'); + void b; + }); + + it('returns an empty array when no entries are registered', () => { + const s = createEngineStorage({ adapter: createMemoryAdapter() }); + expect(s.entries()).toEqual([]); + }); }); describe('createEngineStorage — clear()', () => { diff --git a/src/arts/stor/types.ts b/src/arts/stor/types.ts index 0271763..5d12e0c 100644 --- a/src/arts/stor/types.ts +++ b/src/arts/stor/types.ts @@ -168,10 +168,27 @@ export interface EngineStorageOptions { onError?: StorageErrorHandler; } +/** + * Lightweight metadata about a live entry — returned by `EngineStorage.entries()` + * for introspection (debug panels, devtools, audit logs). Does not expose + * the entry handle itself: this is read-only metadata. + */ +export interface StorageEntryInfo { + key: string; + fullKey: string; + adapter: string; +} + export interface EngineStorage { readonly adapter: SyncStorageAdapter; readonly namespace: string | undefined; entry(key: string, defaults: T | (() => T), options?: StorageEntryOptions): StorageEntry; + /** + * Snapshot of every live entry currently registered against this storage. + * Order matches creation order. Useful for devtools panels and audit + * logs; do not mutate the returned array. + */ + entries(): ReadonlyArray; /** * Remove every cell created via `entry(...)` against this storage from its * underlying adapter. Live entries stay usable — subsequent `get()` calls