clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-demo-review-fixes
lofi / package / runtime / notice-queue.ts
notice-queue.ts 8.9K
1/**2 * Package-owned durable notice queue: the store behind the built-in3 * `s.notice` effect unit.4 *5 * A notice is a user-visible message an effect enqueues when a write settles —6 * the durable answer to "a rejected write still flashed success". The queue is7 * deliberately not an imperative toast: an effect can fire at a boot re-arm8 * with no UI mounted, so the entry must outlive the render and be picked up9 * later. Entries persist beside the write journal (OPFS when the browser10 * provides it, `localStorage` otherwise), are keyed by the enqueuing11 * obligation's journal id so an at-least-once re-delivery adds one entry, and12 * retire by dismissal or TTL. A component (the built-in notices surface, or an13 * author's) subscribes and renders; toasts are a userland wrapper over this.14 *15 * @module16 */1718import {19 createDefaultJournalStorage,20 createMemoryJournalStorage,21 type JournalStorage,22} from "./write-journal.ts";2324/** How a notice is classified for rendering. */25export type NoticeTone = "info" | "success" | "warning" | "error";2627/** One durable notice entry. */28export type NoticeEntry = {29 /** The enqueuing obligation's journal id — the entry's idempotency key. */30 id: string;31 /** The user-facing message. */32 message: string;33 /** The message classification. */34 tone: NoticeTone;35 /** Epoch milliseconds when the entry was enqueued. */36 createdAt: number;37 /** Epoch milliseconds when the entry retires by TTL, or `null` for none. */38 expiresAt: number | null;39};4041/** The persisted queue document. */42type NoticeDocument = {43 version: 1;44 entries: NoticeEntry[];45};4647function parse(text: string | null): NoticeDocument {48 if (!text) return { version: 1, entries: [] };49 try {50 const value = JSON.parse(text) as Partial<NoticeDocument> | null;51 if (value && value.version === 1 && Array.isArray(value.entries)) {52 const entries = value.entries.filter((entry): entry is NoticeEntry =>53 typeof entry?.id === "string" && typeof entry.message === "string"54 );55 return { version: 1, entries };56 }57 } catch {58 // A corrupt queue must not brick boot; start empty.59 }60 return { version: 1, entries: [] };61}6263/** What one enqueue call carries, before the queue stamps identity and time. */64export type NoticeEnqueueInput = {65 /** The idempotency key — the enqueuing obligation's journal id. */66 id: string;67 /** The user-facing message. */68 message: string;69 /** The message classification. */70 tone: NoticeTone;71 /** Lifespan before TTL retirement, or `null` to keep until dismissed. */72 ttlMs: number | null;73};7475/**76 * The single reader and writer of the durable notice queue: an in-memory77 * document with coalesced persistence, a live-notice snapshot, and change78 * notification. Retirement (dismissal, TTL) is applied lazily on every read79 * and on a periodic sweep, so a queue loaded at boot never surfaces a message80 * whose window already closed.81 */82export class NoticeQueue {83 readonly #storage: JournalStorage;84 readonly #now: () => number;85 #document: NoticeDocument = { version: 1, entries: [] };86 #chain: Promise<void> = Promise.resolve();87 #loaded = false;88 #loading: Promise<void> | null = null;89 #listeners = new Set<() => void>();90 #onCountChange?: (count: number) => void;9192 /** Creates a queue over one storage location and clock. */93 constructor(94 storage: JournalStorage,95 now: () => number = Date.now,96 onCountChange?: (count: number) => void,97 ) {98 this.#storage = storage;99 this.#now = now;100 this.#onCountChange = onCountChange;101 }102103 /**104 * Loads the persisted document once, dropping already-expired entries.105 * Entries enqueued in the async load window — an effect firing at a boot106 * re-arm before load resolves — are merged in rather than clobbered: the107 * persisted set is the base, and any in-memory entry (fresher) wins on id.108 */109 load(): Promise<void> {110 if (this.#loaded) return Promise.resolve();111 this.#loading ??= this.#load().catch((error) => {112 this.#loading = null;113 throw error;114 });115 return this.#loading;116 }117118 async #load(): Promise<void> {119 const persisted = parse(await this.#storage.load());120 const pending = this.#document.entries;121 const byId = new Map<string, NoticeEntry>();122 for (const entry of persisted.entries) byId.set(entry.id, entry);123 // In-memory entries were enqueued this session and are authoritative for124 // their id; they overwrite any stale persisted copy.125 for (const entry of pending) byId.set(entry.id, entry);126 this.#document = { version: 1, entries: [...byId.values()] };127 this.#loaded = true;128 const swept = this.#sweepExpired();129 // A load that absorbed in-flight entries must re-persist the merged set130 // and publish it, so the boot-enqueued notice is not silently deferred.131 if (pending.length > 0 || swept) await this.#persist();132 this.#emit();133 }134135 /**136 * Enqueues one notice, idempotent by id: an entry whose id is already137 * present is left untouched, so an at-least-once re-delivery adds nothing.138 */139 async enqueue(input: NoticeEnqueueInput): Promise<void> {140 await this.load();141 const exists = this.#document.entries.some((entry) => entry.id === input.id);142 if (!exists) {143 const createdAt = this.#now();144 this.#document.entries.push({145 id: input.id,146 message: input.message,147 tone: input.tone,148 createdAt,149 expiresAt: input.ttlMs === null ? null : createdAt + input.ttlMs,150 });151 this.#emit();152 }153 // Persist duplicate deliveries too: if the first attempt mutated memory154 // but its save failed, replay is the retry that makes the entry durable.155 await this.#persist();156 }157158 /** Dismisses one entry by id; unknown ids are a no-op. */159 dismiss(id: string): void {160 const next = this.#document.entries.filter((entry) => entry.id !== id);161 if (next.length === this.#document.entries.length) return;162 this.#document.entries = next;163 void this.#persist().catch(() => undefined);164 this.#emit();165 }166167 /** Dismisses every entry. */168 dismissAll(): void {169 if (this.#document.entries.length === 0) return;170 this.#document.entries = [];171 void this.#persist().catch(() => undefined);172 this.#emit();173 }174175 /**176 * The live notices: enqueued, not dismissed, not past their TTL. A pure177 * read — it computes the live view without mutating stored state, so a178 * render never silently prunes the queue (which would drift the persisted179 * set and the `activeNotices` count from what a later `sweep()` sees).180 * Actual retirement of expired entries happens in {@link sweep}, which181 * persists and notifies. A fresh array each call, so callers may hold it.182 */183 list(): readonly NoticeEntry[] {184 return this.#liveEntries();185 }186187 #liveEntries(): NoticeEntry[] {188 const now = this.#now();189 return this.#document.entries.filter((entry) =>190 entry.expiresAt === null || entry.expiresAt > now191 );192 }193194 /** Subscribes to queue changes; returns an unsubscribe function. */195 subscribe(listener: () => void): () => void {196 this.#listeners.add(listener);197 return () => this.#listeners.delete(listener);198 }199200 /** Resolves once every scheduled persistence has settled. */201 flush(): Promise<void> {202 return this.#chain;203 }204205 /** Applies TTL retirement, persisting and notifying if anything changed. */206 sweep(): void {207 if (this.#sweepExpired()) {208 void this.#persist().catch(() => undefined);209 this.#emit();210 }211 }212213 #sweepExpired(): boolean {214 const now = this.#now();215 const before = this.#document.entries.length;216 this.#document.entries = this.#document.entries.filter((entry) =>217 entry.expiresAt === null || entry.expiresAt > now218 );219 return this.#document.entries.length !== before;220 }221222 #persist(): Promise<void> {223 const snapshot = JSON.stringify(this.#document);224 const attempt = this.#chain.then(() => this.#storage.save(snapshot));225 // Keep the serialized queue usable after a failed attempt while returning226 // that failure to the effect handler that requires durable delivery.227 this.#chain = attempt.catch(() => undefined);228 return attempt;229 }230231 #emit(): void {232 // The count and every subscriber see the live view, so `activeNotices`233 // never overcounts entries that have expired but not yet been swept.234 this.#onCountChange?.(this.#liveEntries().length);235 for (const listener of this.#listeners) listener();236 }237}238239/** Resolves the default notice-queue storage for one app id. */240export function createDefaultNoticeStorage(appId: string): JournalStorage {241 // The journal's storage resolver already picks OPFS, then localStorage, then242 // memory; a distinct file name keeps the two documents apart.243 return createDefaultJournalStorage(`notices-${appId}`);244}245246/** An in-memory notice queue for tests and storage-less runtimes. */247export function createMemoryNoticeQueue(now?: () => number): NoticeQueue {248 return new NoticeQueue(createMemoryJournalStorage(), now);249}