174 lines
4.9 KiB
TypeScript
174 lines
4.9 KiB
TypeScript
import { createSeedDataset } from "../domain/seed.ts";
|
|
import type { Dataset, FxRate, MonthId } from "../domain/types.ts";
|
|
|
|
export interface Persistence {
|
|
read(): Promise<Dataset | null>;
|
|
write(dataset: Dataset): Promise<void>;
|
|
}
|
|
|
|
export type Listener = () => void;
|
|
|
|
export interface HistoryState {
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
}
|
|
|
|
export interface SnapshotStore {
|
|
load(): Promise<void>;
|
|
get(): Dataset;
|
|
mutate(fn: (draft: Dataset) => void): Promise<void>;
|
|
replace(dataset: Dataset): Promise<void>;
|
|
reset(): Promise<void>;
|
|
getHistory(): HistoryState;
|
|
undo(): Promise<boolean>;
|
|
redo(): Promise<boolean>;
|
|
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<Listener>();
|
|
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<unknown> = Promise.resolve();
|
|
|
|
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
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<boolean> {
|
|
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);
|
|
},
|
|
};
|
|
}
|