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) <noreply@anthropic.com>
This commit is contained in:
1 parent
da0d1ac35c
commit
ed1667f8a0
4 files changed
+162
-27
No files matched your search
+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) {
|
||||
|
||||
Reference in new issue
Block a user