175 lines
6.4 KiB
TypeScript
175 lines
6.4 KiB
TypeScript
/**
|
|
* A one-way channel from the persistence seam to the UI.
|
|
*
|
|
* When `lib/store/kv-persist.ts` refuses to write — because the key never
|
|
* hydrated and persisting from an un-hydrated store would overwrite real data
|
|
* with defaults — the user's changes stop being saved. That is invisible by
|
|
* construction: the app keeps working, the store keeps updating in memory, and
|
|
* everything is lost on reload. It has to be said out loud.
|
|
*
|
|
* Kept deliberately small and framework-free so the store seam, which is also
|
|
* evaluated during SSR, does not pull the toast stack into its module graph.
|
|
*/
|
|
export type PersistHealthStatus =
|
|
/** Storage is unusable; changes are not being saved. Resolvable. */
|
|
| 'unavailable'
|
|
/** Storage recovered, but edits made while it was down are gone. Final. */
|
|
| 'changes-lost'
|
|
/** Storage recovered with nothing lost; retract any standing notice. */
|
|
| 'recovered';
|
|
|
|
export interface PersistHealthEvent {
|
|
name: string;
|
|
status: PersistHealthStatus;
|
|
}
|
|
|
|
type Listener = (event: PersistHealthEvent) => void;
|
|
|
|
const listeners = new Set<Listener>();
|
|
/**
|
|
* Two independent lines per key, because the statuses answer different
|
|
* questions and resolve on different timescales.
|
|
*
|
|
* `unavailable` describes the state of the world right now and stops being true
|
|
* the moment storage works again. `changes-lost` describes something that
|
|
* already happened: recovering afterwards does not un-lose the edits, so it
|
|
* survives any number of later failures and recoveries and goes away only when
|
|
* the user dismisses it.
|
|
*/
|
|
const unavailable = new Set<string>();
|
|
const lost = new Set<string>();
|
|
const pending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
/** Keys whose `unavailable` notice actually reached subscribers. */
|
|
const delivered = new Set<string>();
|
|
/** Catch-up timers for subscribers that arrived after a problem was raised. */
|
|
const catchUps = new Map<Listener, ReturnType<typeof setTimeout>>();
|
|
|
|
/**
|
|
* Publish on a fresh task rather than inline.
|
|
*
|
|
* Recovery usually resolves within a few microtasks of the failure that
|
|
* triggered it, and a warning that appears and vanishes in that window reads as
|
|
* a bug rather than as information. Deferring lets {@link reportPersistHealth}
|
|
* cancel a notice a successful recovery has already made untrue, and lets a
|
|
* late subscriber be caught up after its toast host has mounted.
|
|
*/
|
|
function publish(event: PersistHealthEvent): void {
|
|
const slot = `${event.name}:${event.status === 'changes-lost' ? 'lost' : 'fault'}`;
|
|
const timer = setTimeout(() => {
|
|
pending.delete(slot);
|
|
if (event.status === 'recovered') delivered.delete(event.name);
|
|
else if (event.status === 'unavailable') delivered.add(event.name);
|
|
for (const listener of listeners) listener(event);
|
|
}, 0);
|
|
pending.set(slot, timer);
|
|
}
|
|
|
|
function cancelPending(slot: string): void {
|
|
const timer = pending.get(slot);
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer);
|
|
pending.delete(slot);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Report a change in a key's persistence health. Repeat statuses are dropped.
|
|
*
|
|
* `changes-lost` is a fait accompli, not a condition: the edits are already
|
|
* gone, so a later recovery has nothing to retract and leaves it standing.
|
|
* `unavailable` is the opposite — it describes a state of the world, and stops
|
|
* being true the moment storage works again.
|
|
*/
|
|
export function reportPersistHealth(name: string, status: PersistHealthStatus): void {
|
|
if (status === 'changes-lost') {
|
|
if (lost.has(name)) return;
|
|
lost.add(name);
|
|
publish({ name, status });
|
|
return;
|
|
}
|
|
|
|
if (status === 'recovered') {
|
|
// Only the transient line recovers. A standing `changes-lost` is untouched:
|
|
// storage working again does not bring the edits back.
|
|
if (!unavailable.delete(name)) return;
|
|
cancelPending(`${name}:fault`);
|
|
// Only retract a notice that actually reached someone. If the warning was
|
|
// still waiting its turn, cancelling it *is* the point of publishing on a
|
|
// delay — announcing recovery from a problem nobody saw would put the
|
|
// flicker back by another route.
|
|
if (!delivered.has(name)) return;
|
|
publish({ name, status });
|
|
return;
|
|
}
|
|
|
|
if (unavailable.has(name)) return;
|
|
unavailable.add(name);
|
|
cancelPending(`${name}:fault`);
|
|
publish({ name, status });
|
|
}
|
|
|
|
/**
|
|
* The user has acknowledged a lost-changes notice.
|
|
*
|
|
* Clearing the latch is what makes the notice re-armable: without it the same
|
|
* store losing changes a second time is swallowed as a duplicate, and any
|
|
* subscriber that mounts later resurrects a toast the user already dismissed.
|
|
*/
|
|
export function acknowledgePersistLoss(name: string): void {
|
|
lost.delete(name);
|
|
cancelPending(`${name}:lost`);
|
|
}
|
|
|
|
/** Storage is unusable for this key. */
|
|
export function reportPersistUnavailable(name: string): void {
|
|
reportPersistHealth(name, 'unavailable');
|
|
}
|
|
|
|
/**
|
|
* Subscribe to persistence health. A listener that arrives while a problem is
|
|
* standing is caught up on a later task — React mounts well after the store
|
|
* module runs, so the notice would otherwise be missed exactly when it matters
|
|
* most, and the toast host may itself be a sibling React has not reached yet.
|
|
*
|
|
* The catch-up re-reads the current state when it fires rather than replaying
|
|
* the snapshot taken at subscribe time: a recovery in between must not be
|
|
* overtaken by a stale warning that then has nothing left to dismiss it.
|
|
*/
|
|
export function subscribeToPersistHealth(listener: Listener): () => void {
|
|
listeners.add(listener);
|
|
if (lost.size > 0 || unavailable.size > 0) {
|
|
catchUps.set(
|
|
listener,
|
|
setTimeout(() => {
|
|
catchUps.delete(listener);
|
|
if (!listeners.has(listener)) return;
|
|
for (const name of lost) listener({ name, status: 'changes-lost' });
|
|
for (const name of unavailable) {
|
|
delivered.add(name);
|
|
listener({ name, status: 'unavailable' });
|
|
}
|
|
}, 0),
|
|
);
|
|
}
|
|
return () => {
|
|
listeners.delete(listener);
|
|
const catchUp = catchUps.get(listener);
|
|
if (catchUp !== undefined) {
|
|
clearTimeout(catchUp);
|
|
catchUps.delete(listener);
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Test-only: forget all reported health. */
|
|
export function resetPersistHealth(): void {
|
|
for (const timer of pending.values()) clearTimeout(timer);
|
|
for (const timer of catchUps.values()) clearTimeout(timer);
|
|
pending.clear();
|
|
catchUps.clear();
|
|
unavailable.clear();
|
|
lost.clear();
|
|
delivered.clear();
|
|
listeners.clear();
|
|
}
|