Files
openmaic/OpenMAIC/tests/store/kv-persist.test.ts
2026-08-16 14:58:47 +08:00

832 lines
30 KiB
TypeScript

/**
* The zustand `persist` ↔ `KVStore` seam (lib/store/kv-persist.ts).
*
* The seam routes a store's persistence through the KV contract and **nothing
* more**: it reads and writes the KV scope and never touches a legacy
* `localStorage` key. There is no migration of pre-cutover data — an empty KV
* scope hydrates the store to defaults.
*
* What remains load-bearing is async-storage *steady state*: because a KV
* backend can be remote and can fail, no failed read, out-of-order write, or
* clear-vs-write race may end with the user's data replaced by defaults, and a
* write the backend refused must either be replayed on recovery or reported as
* lost. Most cases below are therefore *sequences*, not single calls.
*/
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import {
BrowserKVStore,
type DeviceSafeKVStore,
type KVScope,
type KVStore,
} from '@openmaic/storage';
import { createKVPersistStorage, DEFAULT_RECOVERY_BACKOFF_MS } from '@/lib/store/kv-persist';
import {
resetPersistHealth,
subscribeToPersistHealth,
type PersistHealthEvent,
} from '@/lib/store/persist-health';
/**
* Let the seam's detached recovery chain and the health channel's deferred
* publish both run to completion. Several turns: recovery is scheduled on one,
* the notice it does or does not cancel on the next.
*/
const flushTasks = async () => {
for (let i = 0; i < 12; i++) await new Promise<void>((resolve) => setTimeout(resolve, 0));
};
/**
* A `KVStore` decorator that can be told to fail, or to hold an operation until
* the test releases it. Faults are the subject of these cases, so they are
* injected at the backend rather than mocked at the adapter.
*/
class ControllableKV implements DeviceSafeKVStore {
// Device-safe: it wraps a local BrowserKVStore, so its device scope stays on
// the machine. The brand lets it back a `'device'`-scoped persist store, which
// #1002's `kvPersistStorage` requires of any device backend.
readonly servesDeviceScopeLocally = true as const;
failGet = false;
failSet = false;
failRemove = false;
private setGate: { match: (key: string) => boolean; wait: Promise<void> } | null = null;
private readGate: { match: (key: string) => boolean; wait: Promise<void> } | null = null;
constructor(private readonly inner: KVStore) {}
/** Hold the next matching `set` until the returned function is called. */
stallSet(match: (key: string) => boolean): () => void {
let release!: () => void;
const wait = new Promise<void>((resolve) => {
release = resolve;
});
this.setGate = { match, wait };
return release;
}
/**
* Hold the next matching `get` *after* it has read, so the caller resolves
* with the value as of the moment it asked. That is the only way to model a
* reader whose view of the store is a snapshot older than the store itself —
* the normal state of affairs against a remote backend.
*/
stallGetAfterRead(match: (key: string) => boolean): () => void {
let release!: () => void;
const wait = new Promise<void>((resolve) => {
release = resolve;
});
this.readGate = { match, wait };
return release;
}
async get<T>(key: string, scope?: KVScope): Promise<T | null> {
if (this.failGet) throw new Error('kv get failed');
if (this.readGate?.match(key)) {
const { wait } = this.readGate;
this.readGate = null;
const snapshot = await this.inner.get<T>(key, scope);
await wait;
return snapshot;
}
return this.inner.get<T>(key, scope);
}
async set<T>(key: string, value: T, scope?: KVScope): Promise<void> {
if (this.failSet) throw new Error('kv set failed');
if (this.setGate?.match(key)) {
const { wait } = this.setGate;
this.setGate = null;
await wait;
}
return this.inner.set(key, value, scope);
}
async remove(key: string, scope?: KVScope): Promise<void> {
if (this.failRemove) throw new Error('kv remove failed');
return this.inner.remove(key, scope);
}
async keys(prefix?: string, scope?: KVScope): Promise<string[]> {
return this.inner.keys(prefix, scope);
}
}
const NAME = 'settings-storage';
const isBlobKey = (key: string) => key === NAME;
interface Prefs {
nickname: string;
}
/** In-memory `Storage`, isolated per test — nothing ambient is touched. */
class MemoryStorage implements Storage {
private readonly entries = new Map<string, string>();
get length(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
}
getItem(key: string): string | null {
return this.entries.get(key) ?? null;
}
key(index: number): string | null {
return [...this.entries.keys()][index] ?? null;
}
removeItem(key: string): void {
this.entries.delete(key);
}
setItem(key: string, value: string): void {
this.entries.set(key, value);
}
[name: string]: unknown;
}
function harness() {
const backing = new MemoryStorage();
const kv = new ControllableKV(new BrowserKVStore({ storage: backing }));
return {
backing,
kv,
/** A fresh adapter over the same KV backend — what a page reload builds. */
storage: (scope: KVScope = 'account') => createKVPersistStorage<Prefs>(scope, { kv }),
};
}
type PersistStorageUnderTest = ReturnType<ReturnType<typeof harness>['storage']>;
/** `setItem` is refused until a read has settled the key, so hydrate first. */
async function hydrated(storage: PersistStorageUnderTest): Promise<PersistStorageUnderTest> {
await storage.getItem(NAME);
return storage;
}
/** Health events raised during a test, in order. */
let health: PersistHealthEvent[] = [];
/** Just the ones the user would actually see as a standing problem. */
const problems = () => health.filter((e) => e.status !== 'recovered').map((e) => e.name);
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
resetPersistHealth();
health = [];
subscribeToPersistHealth((event) => health.push(event));
});
afterEach(() => {
vi.restoreAllMocks();
resetPersistHealth();
});
describe('createKVPersistStorage — round trip', () => {
it('reads back what it wrote, envelope intact', async () => {
const persist = await hydrated(harness().storage());
await persist.setItem(NAME, { state: { nickname: 'Ada' }, version: 4 });
expect(await persist.getItem(NAME)).toEqual({ state: { nickname: 'Ada' }, version: 4 });
});
it('returns null for a store that was never written', async () => {
expect(await harness().storage().getItem(NAME)).toBeNull();
});
it('removeItem clears the entry', async () => {
const persist = await hydrated(harness().storage());
await persist.setItem(NAME, { state: { nickname: 'Ada' } });
await persist.removeItem(NAME);
expect(await persist.getItem(NAME)).toBeNull();
});
});
describe('createKVPersistStorage — empty KV hydrates defaults, no migration', () => {
it('an empty KV scope reads null and the key becomes writable', async () => {
// The whole "no migration" story at the adapter level: nothing is read but
// the KV scope. Empty KV → null (zustand keeps its defaults) → settled, so
// the user's first edit persists.
const h = harness();
const persist = h.storage();
expect(await persist.getItem(NAME)).toBeNull();
await persist.setItem(NAME, { state: { nickname: 'Ada' }, version: 4 });
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'Ada' }, version: 4 });
});
it('the adapter takes no legacy storage — there is nothing it could migrate', () => {
// A structural guarantee, not a runtime one: the deps have no legacy-source
// field, so no code path can read a pre-cutover raw key.
const persist = createKVPersistStorage<Prefs>('account', { kv: harness().kv });
expect(typeof persist.getItem).toBe('function');
expect(Object.keys({ kv: harness().kv })).toEqual(['kv']);
});
});
describe('createKVPersistStorage — scope', () => {
it('writes under the scope it was given, and only that scope', async () => {
const h = harness();
const persist = await hydrated(h.storage('device'));
await persist.setItem(NAME, { state: { nickname: 'Ada' } });
expect(await h.kv.get(NAME, 'device')).toEqual({ state: { nickname: 'Ada' } });
expect(await h.kv.get(NAME, 'account')).toBeNull();
});
it('does not read across scopes', async () => {
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'Ada' } }, 'account');
// `device` data is defined as never leaving the machine, so the two scopes
// stay disjoint even on a shared backend.
expect(await h.storage('device').getItem(NAME)).toBeNull();
});
});
describe('createKVPersistStorage — a failed read never persists defaults', () => {
it('returns null and refuses writes when the KV read fails', async () => {
// A failed read is not an empty store. The key is left unsettled so the
// always-on initializer's `set()` cannot land defaults over a value we
// could not read.
const h = harness();
h.kv.failGet = true;
const persist = h.storage();
expect(await persist.getItem(NAME)).toBeNull();
await persist.setItem(NAME, { state: { nickname: '' }, version: 4 });
h.kv.failGet = false;
expect(await h.kv.get(NAME, 'account')).toBeNull();
await flushTasks();
expect(problems()).toContain(NAME);
});
it('surfaces a failed write instead of swallowing it', async () => {
const h = harness();
const persist = await hydrated(h.storage());
h.kv.failSet = true;
await persist.setItem(NAME, { state: { nickname: 'Ada' } });
expect(console.error).toHaveBeenCalled();
});
it('refuses to persist before any read has settled the key', async () => {
const h = harness();
await h.storage().setItem(NAME, { state: { nickname: 'premature' } });
expect(await h.kv.get(NAME, 'account')).toBeNull();
});
});
describe('createKVPersistStorage — writes issued during hydration', () => {
it('refuses a write issued while the read is still in flight', async () => {
// The gate has to be read when `setItem` is *called*. Checked inside the
// queued task instead, this write waits behind `getItem`, finds the gate
// opened by the very read it raced, and lands its pre-hydration snapshot
// on top of the stored value.
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
const persist = h.storage();
const releaseRead = h.kv.stallGetAfterRead(isBlobKey);
const load = persist.getItem(NAME);
// zustand issues this from a `set()` that ran before hydration resolved.
const prematureWrite = persist.setItem(NAME, { state: { nickname: '' }, version: 4 });
releaseRead();
await Promise.all([load, prematureWrite]);
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'stored' }, version: 4 });
});
it('allows the write zustand issues once hydration has resolved', async () => {
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
const persist = h.storage();
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'edited' }, version: 4 });
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'edited' }, version: 4 });
});
});
describe('createKVPersistStorage — write ordering', () => {
it('applies writes in call order even when an earlier one resolves late', async () => {
const h = harness();
const persist = await hydrated(h.storage());
const release = h.kv.stallSet(isBlobKey);
const first = persist.setItem(NAME, { state: { nickname: 'first' }, version: 4 });
const second = persist.setItem(NAME, { state: { nickname: 'second' }, version: 4 });
release();
await Promise.all([first, second]);
// Unserialized, the stalled first write lands last and silently rolls the
// newer value back.
expect(await h.kv.get(NAME, 'account')).toEqual({
state: { nickname: 'second' },
version: 4,
});
});
it('a failed write closes the key rather than reporting a save that did not happen', async () => {
const h = harness();
const persist = await hydrated(h.storage());
h.kv.failSet = true;
await persist.setItem(NAME, { state: { nickname: 'doomed' } });
await flushTasks();
expect(problems()).toEqual([NAME]);
h.kv.failSet = false;
await persist.setItem(NAME, { state: { nickname: 'Ada' }, version: 4 });
expect(await h.kv.get(NAME, 'account')).toBeNull();
});
it('reopens the key once a read confirms the backend is back', async () => {
const h = harness();
const persist = await hydrated(h.storage());
await persist.setItem(NAME, { state: { nickname: 'stored' }, version: 4 });
h.kv.failSet = true;
await persist.setItem(NAME, { state: { nickname: 'edited' }, version: 4 });
h.kv.failSet = false;
await persist.getItem(NAME);
// The refused edit was taken against real hydrated data, so it is the
// newest copy there is and gets replayed rather than dropped.
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'edited' }, version: 4 });
});
it('does not leave the queue wedged after a failed write', async () => {
const h = harness();
const persist = await hydrated(h.storage());
h.kv.failSet = true;
await persist.setItem(NAME, { state: { nickname: 'doomed' } });
h.kv.failSet = false;
// A read still runs — the chain is intact even though the key is closed.
await expect(persist.getItem(NAME)).resolves.toBeNull();
});
it('does not replay a stale snapshot over a newer write that succeeded', async () => {
// Both writes are admitted while the key is open, so both are queued. The
// first fails and is remembered; the second lands. Replaying the first on
// recovery would undo the second.
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
const persist = h.storage();
await persist.getItem(NAME);
const inner = h.kv;
let sets = 0;
const originalSet = inner.set.bind(inner);
inner.set = async (key, value, scope) => {
if (key === NAME && ++sets === 1) throw new Error('kv set failed');
return originalSet(key, value, scope);
};
const first = persist.setItem(NAME, { state: { nickname: 'A' }, version: 4 });
const second = persist.setItem(NAME, { state: { nickname: 'B' }, version: 4 });
await Promise.all([first, second]);
inner.set = originalSet;
await persist.getItem(NAME);
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'B' }, version: 4 });
});
});
describe('createKVPersistStorage — clearing closes the gate synchronously', () => {
it('refuses a write issued while the clear is in flight', async () => {
// An always-on initializer issues `set()` on every load. Admitted here, that
// write queues behind the delete and puts the credentials the user just
// cleared straight back.
const h = harness();
const persist = await hydrated(h.storage());
await persist.setItem(NAME, { state: { nickname: 'secret' }, version: 4 });
const clearing = persist.removeItem(NAME);
const racingWrite = persist.setItem(NAME, { state: { nickname: 'secret' }, version: 4 });
await Promise.all([clearing, racingWrite]);
expect(await h.kv.get(NAME, 'account')).toBeNull();
});
it('does not replay a refused write across a clear', async () => {
// Replaying here would resurrect exactly what the user asked to delete.
const h = harness();
const persist = await hydrated(h.storage());
await persist.setItem(NAME, { state: { nickname: 'secret' }, version: 4 });
const clearing = persist.removeItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'secret' }, version: 4 });
await clearing;
expect(await persist.getItem(NAME)).toBeNull();
expect(await h.kv.get(NAME, 'account')).toBeNull();
});
it('accepts writes again once the clear has completed', async () => {
const h = harness();
const persist = await hydrated(h.storage());
await persist.removeItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'after' }, version: 4 });
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'after' }, version: 4 });
});
it('propagates a backend failure instead of reporting a clear that did not happen', async () => {
const h = harness();
const persist = await hydrated(h.storage());
await persist.setItem(NAME, { state: { nickname: 'Ada' }, version: 4 });
h.kv.failRemove = true;
await expect(persist.removeItem(NAME)).rejects.toThrow(/Could not remove/);
h.kv.failRemove = false;
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'Ada' }, version: 4 });
});
});
describe('createKVPersistStorage — refused writes ask for recovery, then say so', () => {
it('asks the store to rehydrate on the first refusal only', async () => {
const h = harness();
h.kv.failGet = true;
const onWriteRefused = vi.fn();
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
onWriteRefused,
recoveryBackoffMs: [0],
});
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'a' } });
await persist.setItem(NAME, { state: { nickname: 'b' } });
await flushTasks();
expect(onWriteRefused).toHaveBeenCalledTimes(1);
});
it('logs every refusal, not just the first', async () => {
const h = harness();
h.kv.failGet = true;
const persist = h.storage();
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'a' } });
await persist.setItem(NAME, { state: { nickname: 'b' } });
await persist.setItem(NAME, { state: { nickname: 'c' } });
const refusals = (console.error as unknown as Mock).mock.calls.filter((call) =>
String(call[0] ?? '').includes('Refusing to persist'),
);
expect(refusals).toHaveLength(3);
});
it('unblocks persistence when the recovery attempt works', async () => {
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' } }, 'account');
h.kv.failGet = true;
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
onWriteRefused: async () => {
h.kv.failGet = false;
await persist.getItem(NAME);
},
});
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'a' } });
await flushTasks();
await persist.setItem(NAME, { state: { nickname: 'b' }, version: 4 });
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'b' }, version: 4 });
});
it('stays quiet through a recovery with nothing owed', async () => {
// Storage broke and came back before anything was written. The warning is
// published on a later task precisely so a recovery this quick can cancel
// it before anyone sees it.
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' } }, 'account');
const persist = h.storage();
h.kv.failGet = true;
await persist.getItem(NAME);
h.kv.failGet = false;
await persist.getItem(NAME);
await flushTasks();
expect(problems()).toEqual([]);
});
it('raises a durable notice when recovery does not work', async () => {
const h = harness();
h.kv.failGet = true;
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
onWriteRefused: async () => {
// The backend is still down, so the retry changes nothing.
await persist.getItem(NAME);
},
});
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'a' } });
await flushTasks();
expect(problems()).toEqual([NAME]);
});
it('raises the notice when the recovery attempt itself throws', async () => {
const h = harness();
h.kv.failGet = true;
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
onWriteRefused: () => Promise.reject(new Error('rehydrate failed')),
});
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'a' } });
await flushTasks();
expect(problems()).toEqual([NAME]);
});
});
describe('createKVPersistStorage — a replay is owed until it lands', () => {
it('does not drop the edit when the recovering read works but the replay write fails', async () => {
// Readable but not writable is a real state — a quota-exhausted backend
// answers reads perfectly. Retiring the snapshot on the strength of the
// read alone loses the edit silently.
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
const persist = h.storage();
await persist.getItem(NAME);
h.kv.failSet = true;
await persist.setItem(NAME, { state: { nickname: 'edited' }, version: 4 });
// Reads work again; writes still do not.
expect(await persist.getItem(NAME)).toEqual({ state: { nickname: 'edited' }, version: 4 });
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'stored' }, version: 4 });
// The edit is still owed, so the next recovery writes it rather than
// handing back the stale stored value.
h.kv.failSet = false;
expect(await persist.getItem(NAME)).toEqual({ state: { nickname: 'edited' }, version: 4 });
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'edited' }, version: 4 });
});
it('eventually tells the user when the replay can never land', async () => {
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
const persist = h.storage();
await persist.getItem(NAME);
h.kv.failSet = true;
await persist.setItem(NAME, { state: { nickname: 'edited' }, version: 4 });
await persist.getItem(NAME);
await persist.getItem(NAME);
await flushTasks();
expect(problems()).toContain(NAME);
});
it('clears the debt once the replay lands', async () => {
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
const persist = h.storage();
await persist.getItem(NAME);
h.kv.failSet = true;
await persist.setItem(NAME, { state: { nickname: 'edited' }, version: 4 });
h.kv.failSet = false;
await persist.getItem(NAME);
// A later write wins outright; the replay is not owed a second time.
await persist.setItem(NAME, { state: { nickname: 'newer' }, version: 4 });
await persist.getItem(NAME);
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'newer' }, version: 4 });
});
});
describe('createKVPersistStorage — a lost edit is reported, not swallowed', () => {
it('says so when an edit made while storage was down cannot be replayed', async () => {
// The edit was taken while the store held only defaults (the read failed
// before it ever loaded a value), so replaying it would overwrite the
// stored value with defaults. It cannot be replayed — and the user is told.
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
h.kv.failGet = true;
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
onWriteRefused: async () => {
h.kv.failGet = false;
await persist.getItem(NAME);
},
});
await persist.getItem(NAME); // fails → defaults, unsettled
await persist.setItem(NAME, { state: { nickname: 'edited' } });
await flushTasks();
expect(health.map((event) => event.status)).toContain('changes-lost');
});
});
describe('createKVPersistStorage — unreachable browser storage is a failure, not an empty store', () => {
/** A browser whose `localStorage` getter throws, as privacy modes do. */
function withHostileLocalStorage(): () => void {
const restore = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
get() {
throw new Error('localStorage is denied');
},
});
vi.stubGlobal('window', {});
return () => {
if (restore) Object.defineProperty(globalThis, 'localStorage', restore);
else Reflect.deleteProperty(globalThis, 'localStorage');
vi.unstubAllGlobals();
};
}
it('signals instead of hydrating an empty store in silence', async () => {
const restore = withHostileLocalStorage();
try {
const persist = createKVPersistStorage<Prefs>('account');
expect(await persist.getItem(NAME)).toBeNull();
await persist.setItem(NAME, { state: { nickname: 'lost' } });
await flushTasks();
expect(problems()).toEqual([NAME]);
} finally {
restore();
}
});
it('reports a clear it could not perform', async () => {
const restore = withHostileLocalStorage();
try {
const persist = createKVPersistStorage<Prefs>('account');
await expect(persist.removeItem(NAME)).rejects.toThrow(/unreachable/);
} finally {
restore();
}
});
});
describe('createKVPersistStorage — recovery is bounded and re-armable', () => {
it('stops retrying a backend that reads but cannot write', async () => {
// The treadmill: the recovering read succeeds, the key settles, the replay
// write fails, and that failure asks for recovery again. A quota-exhausted
// backend behaves exactly like this, and unbounded it is a loop that never
// lets the page settle.
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
const attempts: string[] = [];
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
recoveryBackoffMs: [0, 0, 0],
onWriteRefused: async (name) => {
attempts.push(name);
await persist.getItem(NAME);
},
});
await persist.getItem(NAME);
h.kv.failSet = true;
await persist.setItem(NAME, { state: { nickname: 'edited' }, version: 4 });
for (let i = 0; i < 40; i++) await new Promise<void>((resolve) => setTimeout(resolve, 0));
// Exactly the budget: not "a few", and emphatically not one per lap.
expect(attempts).toHaveLength(3);
expect(health.map((event) => event.status)).toContain('changes-lost');
});
it('ships a schedule that actually backs off', () => {
// A guard on the shipped default: an all-zero schedule would leave the cap
// as the only thing between a flaky backend and a busy loop.
expect(DEFAULT_RECOVERY_BACKOFF_MS.length).toBeGreaterThan(1);
const delays = [...DEFAULT_RECOVERY_BACKOFF_MS];
expect(delays.at(-1)).toBeGreaterThan(0);
for (let i = 1; i < delays.length; i++) {
expect(delays[i]).toBeGreaterThan(delays[i - 1]);
}
});
it('re-arms the budget once storage answers', async () => {
const h = harness();
const asked: string[] = [];
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
onWriteRefused: (name) => void asked.push(name),
recoveryBackoffMs: [0, 0],
});
h.kv.failGet = true;
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'a' } });
await flushTasks();
const spentOnFirstFault = asked.length;
expect(spentOnFirstFault).toBe(2);
// Storage comes back and the key settles clean...
h.kv.failGet = false;
await persist.getItem(NAME);
await flushTasks();
expect(asked).toHaveLength(spentOnFirstFault);
// ...so a later failure gets a fresh budget.
h.kv.failGet = true;
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'b' } });
await flushTasks();
expect(asked.length).toBe(spentOnFirstFault + 2);
});
it('spends the budget once, however many writes are refused', async () => {
const h = harness();
const asked: string[] = [];
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
onWriteRefused: (name) => void asked.push(name),
recoveryBackoffMs: [0, 0, 0],
});
h.kv.failGet = true;
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'a' } });
await persist.setItem(NAME, { state: { nickname: 'b' } });
await persist.setItem(NAME, { state: { nickname: 'c' } });
await persist.getItem(NAME);
await flushTasks();
expect(asked).toHaveLength(3);
});
it('stops for good once the budget is spent', async () => {
const h = harness();
const asked: string[] = [];
const persist = createKVPersistStorage<Prefs>('account', {
kv: h.kv,
onWriteRefused: (name) => void asked.push(name),
recoveryBackoffMs: [0, 0],
});
h.kv.failGet = true;
await persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: 'a' } });
await flushTasks();
await flushTasks();
await flushTasks();
expect(asked).toHaveLength(2);
});
});
describe('createKVPersistStorage — cold-start writes are not an alarm', () => {
it('says nothing to the user when a healthy backend simply had not hydrated yet', async () => {
// An always-on initializer writes on every load, routinely before the
// first read resolves. Nothing is broken; a sticky red toast here would be
// a bug report about a working app.
const h = harness();
await h.kv.set(NAME, { state: { nickname: 'stored' }, version: 4 }, 'account');
const persist = h.storage();
const releaseRead = h.kv.stallGetAfterRead(isBlobKey);
const load = persist.getItem(NAME);
await persist.setItem(NAME, { state: { nickname: '' }, version: 4 });
releaseRead();
await load;
await flushTasks();
expect(health).toEqual([]);
expect(await h.kv.get(NAME, 'account')).toEqual({ state: { nickname: 'stored' }, version: 4 });
});
});
describe('createKVPersistStorage — no browser storage (SSR)', () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it('degrades to a no-op instead of throwing', async () => {
// No `kv` injected and no ambient localStorage: the store module is
// evaluated on the server too, where persist must simply do nothing.
const persist = createKVPersistStorage<Prefs>('account');
expect(await persist.getItem(NAME)).toBeNull();
await expect(persist.setItem(NAME, { state: { nickname: 'Ada' } })).resolves.toBeUndefined();
await expect(persist.removeItem(NAME)).resolves.toBeUndefined();
});
});