Skip to content

Commit eafc0f4

Browse files
adulbrichclaude
andauthored
fix(storage): resolve a write when it commits, not when the request succeeds (#253)
`run()` resolved on `request.onsuccess`. In IndexedDB a request succeeding and its transaction committing are separate events, in that order, so a transaction that aborts at commit time -- quota exhaustion being the realistic cause -- fired `onabort` after the promise had already settled, where `reject()` is a no-op. The comment above it claimed to handle exactly the case it did not. Every write to this database goes through `run()`: `putDocument`, `putFile`, `putBlob`, `deleteDocument`, `deleteFile`, `deleteBlob`. So a quota-aborted save of a preset reported success and lost the data, and because `readJson` in `app-storage.ts` swallows read errors into a fallback, the loss surfaced later as a missing preset rather than as an error at save time. Resolving on `transaction.oncomplete` is the durable signal, which is what `updateDocument` already does. The result now has to be captured in `onsuccess` and handed over at commit, since `request.result` is only valid inside its own handler. Reads share `run()`, so they settle a tick later too; every action it issues is a single request, so no caller is left holding a transaction that has since committed. #249 makes this worth fixing now rather than later: it added a RAW conversion cache of up to 2 GB to the same origin, and it writes through `putBlob`, so it both raises quota pressure and is subject to the same false success. Tests cover the abort paths, which had none. Closes #250 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0871bf8 commit eafc0f4

2 files changed

Lines changed: 105 additions & 4 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import "fake-indexeddb/auto";
2+
import { afterEach, describe, expect, it } from "@jest/globals";
3+
import { getDocument, putDocument, updateDocument } from "./kv";
4+
5+
// What aborts a transaction at commit time in the field is quota exhaustion,
6+
// and there is no way to exhaust fake-indexeddb's quota. Aborting from a
7+
// listener on the successful request reproduces the ordering that matters:
8+
// the request succeeds, and the transaction goes away afterwards.
9+
const realPut = IDBObjectStore.prototype.put;
10+
11+
// Aborting while the request is still pending is the other order: the abort
12+
// settles every in-flight request with an AbortError before the transaction
13+
// itself gives up, so the request never succeeds at all.
14+
function abortDuringNextPut(): void {
15+
IDBObjectStore.prototype.put = function put(
16+
this: IDBObjectStore,
17+
...args: Parameters<IDBObjectStore["put"]>
18+
) {
19+
IDBObjectStore.prototype.put = realPut;
20+
const request = realPut.apply(this, args);
21+
this.transaction.abort();
22+
return request;
23+
};
24+
}
25+
26+
function abortAfterNextPut(): void {
27+
IDBObjectStore.prototype.put = function put(
28+
this: IDBObjectStore,
29+
...args: Parameters<IDBObjectStore["put"]>
30+
) {
31+
IDBObjectStore.prototype.put = realPut;
32+
const request = realPut.apply(this, args);
33+
request.addEventListener("success", () => {
34+
request.transaction?.abort();
35+
});
36+
return request;
37+
};
38+
}
39+
40+
afterEach(() => {
41+
IDBObjectStore.prototype.put = realPut;
42+
});
43+
44+
describe("a write whose transaction aborts after the request succeeds", () => {
45+
it("rejects rather than reporting the write as durable", async () => {
46+
abortAfterNextPut();
47+
48+
await expect(
49+
putDocument("aborted-preset", { name: "one" })
50+
).rejects.toThrow();
51+
});
52+
53+
it("leaves nothing behind to read", async () => {
54+
abortAfterNextPut();
55+
56+
await putDocument("rolled-back-preset", { name: "two" }).catch(
57+
() => undefined
58+
);
59+
60+
expect(await getDocument("rolled-back-preset")).toBeUndefined();
61+
});
62+
63+
// `run`'s `onerror` path cannot be isolated from its `onabort` path through
64+
// this API: an unprevented request error aborts its own transaction anyway,
65+
// so both handlers fire and either one alone would reject. Removing
66+
// `request.onerror` was checked, and this still passes. What it does pin is
67+
// that a request which never succeeds settles the promise rather than
68+
// leaving it pending forever -- the failure mode that resolving on
69+
// `oncomplete` would introduce if the abort rejection went missing.
70+
it("rejects rather than hanging when the abort lands mid-flight", async () => {
71+
abortDuringNextPut();
72+
73+
await expect(
74+
putDocument("abandoned-preset", { name: "three" })
75+
).rejects.toThrow();
76+
});
77+
});
78+
79+
describe("updateDocument", () => {
80+
it("rejects when the transaction aborts after its put succeeds", async () => {
81+
abortAfterNextPut();
82+
83+
await expect(
84+
updateDocument<number[]>("aborted-counter", (current) => [
85+
...(current ?? []),
86+
1,
87+
])
88+
).rejects.toThrow();
89+
});
90+
});

src/lib/storage/kv.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,23 @@ function run<T>(
161161
new Promise<T>((resolve, reject) => {
162162
const transaction = database.transaction(store, mode);
163163
const request = action(transaction.objectStore(store));
164-
request.onsuccess = () => resolve(request.result);
165-
// Both are needed: a request can fail on its own, and a transaction
166-
// can abort underneath a request that already succeeded (quota, for
167-
// one), which would otherwise resolve a write that never landed.
164+
let value: T;
165+
request.onsuccess = () => {
166+
value = request.result;
167+
};
168168
request.onerror = () =>
169169
reject(request.error ?? new Error(`${store}: request failed`));
170+
// Resolved on the transaction, not the request: a request succeeding
171+
// and its transaction committing are separate events, in that order,
172+
// and a quota abort lands between them. Resolving on the request would
173+
// report a write that never landed, and the later `onabort` would be a
174+
// no-op on an already-settled promise. `updateDocument` does the same.
175+
//
176+
// Reads go through here too, and that is deliberate rather than
177+
// incidental: a readonly transaction commits straight after its last
178+
// request, so waiting for it costs a tick and keeps one code path
179+
// instead of two that differ in when they are allowed to settle.
180+
transaction.oncomplete = () => resolve(value);
170181
transaction.onabort = () =>
171182
reject(
172183
transaction.error ?? new Error(`${store}: transaction aborted`)

0 commit comments

Comments
 (0)