fix: serialize snapshot mutations, catch export/plan throws in event handlers
Fixes the three findings from the final whole-branch review:
- SnapshotStore.mutate/replace now serialize behind a promise queue, so
concurrent mutate() calls (e.g. two independent onChange handlers firing
before either write resolves) each clone from the previous commit instead
of racing on a stale snapshot and silently losing an edit. Preserves
write-before-move-snapshot, caller-visible rejections, structuredClone
identity, and non-wedging on a failed write. Adds tests for concurrent
mutations and a failing write mid-queue.
- DataSection's exportJson/exportOds are wrapped in try/catch and report
failures through the existing import error state/rendering, since a throw
(e.g. MissingRateError from buildOds) inside a plain onClick is invisible
to the error boundary. The pre-import JSON backup now explicitly aborts
the destructive replace if the backup export fails, rather than relying on
an uncaught throw to prevent it.
- PlanEditor no longer calls monthOf()/withPlan() with an empty or partial
date from a native <input type="date">, and requires an integer month
count (with step={1}) before calling withPlan/equalSlices, preventing
both domain throws from firing inside plain event handlers.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
da0d1ac35c
commit
ed1667f8a0
@@ -83,6 +83,73 @@ describe("mutate", () => {
|
||||
).rejects.toThrow("disk full");
|
||||
expect(store.get().posts[0]!.name).toBe("Video Games");
|
||||
});
|
||||
|
||||
test("two concurrent mutate() calls both survive (no lost update)", async () => {
|
||||
// Simulate real persistence latency: each write takes ~20ms, so if the
|
||||
// two mutations clone concurrently instead of queueing, the second
|
||||
// commit silently overwrites the first's change.
|
||||
let stored = createSeedDataset("2026-09");
|
||||
const persistence: Persistence = {
|
||||
async read() {
|
||||
return stored;
|
||||
},
|
||||
async write(dataset) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
stored = dataset;
|
||||
},
|
||||
};
|
||||
const store = createSnapshotStore(persistence, "2026-09");
|
||||
await store.load();
|
||||
|
||||
const first = store.mutate((draft) => {
|
||||
draft.months[0]!.income = { amount: 20000, currency: "DKK" };
|
||||
});
|
||||
const second = store.mutate((draft) => {
|
||||
draft.posts[0]!.name = "Renamed";
|
||||
});
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(store.get().months[0]!.income.amount).toBe(20000);
|
||||
expect(store.get().posts[0]!.name).toBe("Renamed");
|
||||
expect(stored.months[0]!.income.amount).toBe(20000);
|
||||
expect(stored.posts[0]!.name).toBe("Renamed");
|
||||
});
|
||||
|
||||
test("a failing write mid-queue rejects only its own caller and does not wedge later mutations", async () => {
|
||||
const fake = fakePersistence(createSeedDataset("2026-09"));
|
||||
// The queue processes mutations strictly in order, so failing the
|
||||
// SECOND write call (regardless of when each mutate() is invoked)
|
||||
// reliably targets the middle mutation below.
|
||||
let writeCount = 0;
|
||||
const persistence: Persistence = {
|
||||
read: fake.persistence.read,
|
||||
async write(dataset) {
|
||||
writeCount++;
|
||||
if (writeCount === 2) throw new Error("disk full");
|
||||
await fake.persistence.write(dataset);
|
||||
},
|
||||
};
|
||||
const store = createSnapshotStore(persistence, "2026-09");
|
||||
await store.load();
|
||||
|
||||
const okBefore = store.mutate((draft) => {
|
||||
draft.months[0]!.income = { amount: 111, currency: "DKK" };
|
||||
});
|
||||
const failing = store.mutate((draft) => {
|
||||
draft.posts[0]!.name = "Should not stick";
|
||||
});
|
||||
const okAfter = store.mutate((draft) => {
|
||||
draft.posts[1]!.name = "Should stick";
|
||||
});
|
||||
|
||||
await okBefore;
|
||||
await expect(failing).rejects.toThrow("disk full");
|
||||
await okAfter;
|
||||
|
||||
expect(store.get().months[0]!.income.amount).toBe(111);
|
||||
expect(store.get().posts[0]!.name).not.toBe("Should not stick");
|
||||
expect(store.get().posts[1]!.name).toBe("Should stick");
|
||||
});
|
||||
});
|
||||
|
||||
test("replace swaps the whole dataset, as JSON import needs", async () => {
|
||||
|
||||
+24
-6
@@ -23,6 +23,22 @@ export function createSnapshotStore(
|
||||
let snapshot: Dataset | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -53,14 +69,16 @@ export function createSnapshotStore(
|
||||
|
||||
get,
|
||||
|
||||
async mutate(fn) {
|
||||
const draft = structuredClone(get());
|
||||
fn(draft);
|
||||
await commit(draft);
|
||||
mutate(fn) {
|
||||
return enqueue(async () => {
|
||||
const draft = structuredClone(get());
|
||||
fn(draft);
|
||||
await commit(draft);
|
||||
});
|
||||
},
|
||||
|
||||
async replace(dataset) {
|
||||
await commit(dataset);
|
||||
replace(dataset) {
|
||||
return enqueue(() => commit(dataset));
|
||||
},
|
||||
|
||||
subscribe(listener) {
|
||||
|
||||
@@ -16,19 +16,42 @@ interface Props {
|
||||
onChange: (next: PurchaseDraft) => void;
|
||||
}
|
||||
|
||||
// A native <input type="date"> reports "" until the user has filled in every
|
||||
// field, and monthOf() throws on anything that isn't a well-formed
|
||||
// YYYY-MM-DD. This is UI-level input validation, not a domain concern — the
|
||||
// domain is right to throw on malformed input; the caller just must not call
|
||||
// it with malformed input.
|
||||
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function hasValidDate(draft: PurchaseDraft): boolean {
|
||||
return ISO_DATE_PATTERN.test(draft.date);
|
||||
}
|
||||
|
||||
export function PlanEditor({ draft, onChange }: Props) {
|
||||
const balance = planBalance(draft);
|
||||
|
||||
if (!draft.plan) {
|
||||
const dateReady = hasValidDate(draft);
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(withPlan(draft, monthOf(draft.date), 3))}
|
||||
>
|
||||
Spread this over several months
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!dateReady}
|
||||
onClick={() => {
|
||||
if (!dateReady) return;
|
||||
onChange(withPlan(draft, monthOf(draft.date), 3));
|
||||
}}
|
||||
>
|
||||
Spread this over several months
|
||||
</Button>
|
||||
{!dateReady && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick a date for this purchase first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,11 +64,12 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="h-8 w-16 tabular-nums"
|
||||
value={draft.plan.slices.length}
|
||||
onChange={(event) => {
|
||||
const months = Number(event.target.value);
|
||||
if (months >= 1) {
|
||||
if (Number.isInteger(months) && months >= 1) {
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months));
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -18,20 +18,44 @@ export function DataSection() {
|
||||
const [pending, setPending] = useState<Dataset | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Shared by every export button and by the pre-import backup below.
|
||||
// `buildOds` in particular can throw (e.g. MissingRateError when a
|
||||
// currency in use has no configured rate), and a throw out of a plain
|
||||
// onClick handler is invisible to React's error boundary — so every
|
||||
// export path funnels through here and reports failure via the same
|
||||
// error state the import flow already uses. Returns whether it succeeded,
|
||||
// so callers that depend on the export (the import backup) can tell.
|
||||
function runExport(build: () => { filename: string; blob: Blob }): boolean {
|
||||
try {
|
||||
setError(null);
|
||||
const { filename, blob } = build();
|
||||
downloadBlob(filename, blob);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(`Could not export: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildJsonExport() {
|
||||
return {
|
||||
filename: exportFilename(currentMonth),
|
||||
blob: new Blob([exportDatasetJson(dataset)], { type: "application/json" }),
|
||||
};
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
downloadBlob(
|
||||
exportFilename(currentMonth),
|
||||
new Blob([exportDatasetJson(dataset)], { type: "application/json" }),
|
||||
);
|
||||
runExport(buildJsonExport);
|
||||
}
|
||||
|
||||
function exportOds() {
|
||||
downloadBlob(
|
||||
odsFilename(currentMonth),
|
||||
new Blob([buildOds(dataset) as BlobPart], {
|
||||
runExport(() => ({
|
||||
filename: odsFilename(currentMonth),
|
||||
blob: new Blob([buildOds(dataset) as BlobPart], {
|
||||
type: "application/vnd.oasis.opendocument.spreadsheet",
|
||||
}),
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
async function chooseFile(file: File) {
|
||||
@@ -40,7 +64,8 @@ export function DataSection() {
|
||||
setPending(parseDatasetJson(await file.text()));
|
||||
} catch (cause) {
|
||||
setPending(null);
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(`Could not import: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +73,9 @@ export function DataSection() {
|
||||
if (!pending) return;
|
||||
// Back up first: import replaces everything, and this is the user's only
|
||||
// copy. Order matters — the backup must download before the destructive
|
||||
// replace, never after.
|
||||
exportJson();
|
||||
// replace, never after. If the backup itself fails, abort rather than
|
||||
// destroy the only copy of the user's data.
|
||||
if (!runExport(buildJsonExport)) return;
|
||||
await store.replace(pending);
|
||||
setPending(null);
|
||||
}
|
||||
@@ -87,7 +113,7 @@ export function DataSection() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">Could not import: {error}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{pending && (
|
||||
<div className="space-y-2 rounded border border-destructive p-3 text-sm">
|
||||
|
||||
Reference in New Issue
Block a user