import { createSeedDataset } from "../domain/seed.ts"; import type { Dataset, FxRate, MonthId } from "../domain/types.ts"; export interface Persistence { read(): Promise; write(dataset: Dataset): Promise; } export type Listener = () => void; export interface HistoryState { canUndo: boolean; canRedo: boolean; } export interface SnapshotStore { load(): Promise; get(): Dataset; mutate(fn: (draft: Dataset) => void): Promise; replace(dataset: Dataset): Promise; reset(): Promise; getHistory(): HistoryState; undo(): Promise; redo(): Promise; subscribe(listener: Listener): () => void; } export function createSnapshotStore( persistence: Persistence, currentMonth: MonthId, /** * Rates for a FIRST RUN only. Passed in rather than read here so this module * stays free of the ambient environment; src/store/index.ts supplies * BAKED_FX_RATES. Loading an existing dataset never touches its rates — the * user may have cleared one deliberately. */ seedRates: readonly FxRate[] = [], ): SnapshotStore { let snapshot: Dataset | null = null; const listeners = new Set(); const undoStack: Dataset[] = []; const redoStack: Dataset[] = []; let historyState: HistoryState = { canUndo: false, canRedo: false }; const historyLimit = 50; // All mutations run through this chain, one at a time, so a mutation // always clones from the snapshot the previous mutation committed rather // than from a stale snapshot captured before it. Without this, two // mutations issued before either's write resolves both clone the same // starting point and the one that commits last silently discards the // other's change. let queue: Promise = Promise.resolve(); function enqueue(work: () => Promise): Promise { const run = queue.then(work); // Keep the chain alive after a failure, but the caller still sees the // rejection via `run`, which is returned unmodified below. queue = run.catch(() => undefined); return run; } function notify() { for (const listener of listeners) listener(); } function updateHistoryState() { historyState = { canUndo: undoStack.length > 0, canRedo: redoStack.length > 0 }; } function pushHistory(stack: Dataset[], dataset: Dataset) { stack.push(dataset); if (stack.length > historyLimit) stack.shift(); } function clearHistory() { undoStack.length = 0; redoStack.length = 0; updateHistoryState(); } function get(): Dataset { if (!snapshot) throw new Error("Snapshot store is not loaded yet"); return snapshot; } async function commit(next: Dataset, afterWrite?: () => void) { // Write first: if persistence fails, the in-memory snapshot must not have // moved, or the UI would show data that was never saved. await persistence.write(next); snapshot = next; afterWrite?.(); notify(); } function travel(from: Dataset[], to: Dataset[]): Promise { return enqueue(async () => { const target = from.at(-1); if (!target) return false; const previous = get(); await commit(structuredClone(target), () => { from.pop(); pushHistory(to, previous); updateHistoryState(); }); return true; }); } return { load() { return enqueue(async () => { const stored = await persistence.read(); if (stored) { snapshot = stored; clearHistory(); notify(); return; } await commit(createSeedDataset(currentMonth, seedRates), clearHistory); }); }, get, mutate(fn) { return enqueue(async () => { const previous = get(); const draft = structuredClone(previous); fn(draft); if (JSON.stringify(draft) === JSON.stringify(previous)) return; await commit(draft, () => { pushHistory(undoStack, previous); redoStack.length = 0; updateHistoryState(); }); }); }, replace(dataset) { return enqueue(() => commit(structuredClone(dataset), clearHistory)); }, /** * Back to first-run state: the seed posts, the seed currency table and the * seed rates this store was built with. Seeding lives here rather than in * the UI so "reset" and "a brand-new browser" cannot drift apart — they * are the same call. * * Goes through the queue like every other write, so a reset issued while a * mutation is still in flight cannot be overtaken by it. */ reset() { return enqueue(() => commit(createSeedDataset(currentMonth, seedRates), clearHistory)); }, getHistory() { return historyState; }, undo() { return travel(undoStack, redoStack); }, redo() { return travel(redoStack, undoStack); }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, }; }