stor: tier-3 polish — entries(), refcount registry, client cookie tests

Closes the three remaining items from the storage audit backlog:

- entries(): introspection snapshot of every live entry with
  { key, fullKey, adapter }. Useful for devtools panels and audit logs.
  Read-only metadata — no entry handles exposed.

- registeredDefaults refcount: the conflict registry now tracks how many
  live entries point at each (adapter, fullKey) and forgets the slot when
  the count drops to zero. Re-creating a key with a different default
  after the previous owner cleaned up no longer emits a stale warning.
  When at least one owner remains, the diagnostic still fires.

- Client cookieAdapter() coverage: 8 tests with a mocked `document` (no
  jsdom dependency) covering setItem/getItem/removeItem, encode/decode
  defaults, custom encoder pair, Set-Cookie attribute building (path,
  samesite, secure, max-age, domain), sameSite=none auto-secure and
  multi-cookie parsing.

Cookie polling for onChange remains intentionally out of scope —
documented in README under "Cross-tab sync".

746 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 585906e170
commit 7dfa530a47

@ -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

@ -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();

@ -44,8 +44,11 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
const adapterIds = new WeakMap<SyncStorageAdapter, number>();
let adapterIdSeq = 0;
// First-write-wins registry for conflict diagnostics.
const registeredDefaults = new Map<string, unknown>();
// 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<string, { value: unknown; refs: number }>();
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;

@ -23,6 +23,7 @@ export type {
StorageEntryOptions,
StorageEntry,
ActiveStorageEntry,
StorageEntryInfo,
EngineStorage,
ActiveStorage,
EngineStorageOptions

@ -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<string, string>;
restore: () => void;
} {
const jar = new Map<string, string>();
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<typeof installFakeDocument>;
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');
});
});

@ -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()', () => {

@ -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<T>(key: string, defaults: T | (() => T), options?: StorageEntryOptions<T>): StorageEntry<T>;
/**
* 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<StorageEntryInfo>;
/**
* Remove every cell created via `entry(...)` against this storage from its
* underlying adapter. Live entries stay usable — subsequent `get()` calls

Loading…
Cancel
Save

Powered by TurnKey Linux.