commit 24470cd87c2cbb1c1c49c7500363e8f05d41e675
parent 74724178d53be1b41e768772e3de8c6ad56cdc78
Author: Florian Dold <dold@taler.net>
Date: Sun, 13 Sep 2026 16:16:42 +0200
idb-bridge: roll back failed record writes
Wrap object, index and key-generator updates in a request savepoint so a
canceled uniqueness error cannot leave a partial write behind. Abort the
whole transaction if the savepoint rollback fails, even if the
application cancels the request error. Preserve the key generator on
cursor updates.
Diffstat:
3 files changed, 154 insertions(+), 24 deletions(-)
diff --git a/packages/idb-bridge/src/SqliteBackend.ts b/packages/idb-bridge/src/SqliteBackend.ts
@@ -88,6 +88,8 @@ interface ConnectionInfo {
interface TransactionInfo {
connectionCookie: string;
+ /** A failed request rollback makes the whole transaction uncommittable. */
+ abortError?: Error;
}
interface MyIndexMeta {
@@ -1921,6 +1923,10 @@ export class SqliteBackend implements Backend {
if (!txInfo) {
throw Error("transaction not found");
}
+ if (txInfo.abortError) {
+ await this.rollback(btx);
+ throw txInfo.abortError;
+ }
const connInfo = this.connectionMap.get(txInfo.connectionCookie);
if (!connInfo) {
throw Error("not connected");
@@ -2368,36 +2374,53 @@ export class SqliteBackend implements Backend {
indexRows.push({ indexId, indexInfo, keys });
}
- if (existingObject !== undefined) {
- await this._deleteObjectFromIndexes(
- indexRows,
- serializedObjectKey,
- structuredRevive(JSON.parse(existingObject)),
- );
- }
-
- await (
- await this._prep(sqlInsertObjectData)
- ).run({
- object_store_id: objectStoreId,
- key: serializedObjectKey,
- value: JSON.stringify(structuredEncapsulate(value)),
- });
+ // Request errors can be canceled by the application without aborting the
+ // transaction. Undo every part of this write before reporting such an error.
+ await (await this._prep("SAVEPOINT idb_store_request")).run({});
+ try {
+ if (existingObject !== undefined) {
+ await this._deleteObjectFromIndexes(
+ indexRows,
+ serializedObjectKey,
+ structuredRevive(JSON.parse(existingObject)),
+ );
+ }
- if (scopeInfo.autoIncrement) {
await (
- await this._prep(sqlUpdateAutoIncrement)
+ await this._prep(sqlInsertObjectData)
).run({
object_store_id: objectStoreId,
- auto_increment: updatedKeyGenerator,
+ key: serializedObjectKey,
+ value: JSON.stringify(structuredEncapsulate(value)),
});
- }
- await this._insertIndexRows(
- storeReq.objectStoreName,
- indexRows,
- serializedObjectKey,
- );
+ if (updatedKeyGenerator !== undefined) {
+ await (
+ await this._prep(sqlUpdateAutoIncrement)
+ ).run({
+ object_store_id: objectStoreId,
+ auto_increment: updatedKeyGenerator,
+ });
+ }
+
+ await this._insertIndexRows(
+ storeReq.objectStoreName,
+ indexRows,
+ serializedObjectKey,
+ );
+ await (await this._prep("RELEASE idb_store_request")).run({});
+ } catch (error) {
+ try {
+ await (await this._prep("ROLLBACK TO idb_store_request")).run({});
+ await (await this._prep("RELEASE idb_store_request")).run({});
+ } catch (rollbackError) {
+ txInfo.abortError =
+ rollbackError instanceof Error
+ ? rollbackError
+ : new Error(String(rollbackError));
+ }
+ throw error;
+ }
if (this.trackStats) {
this.accessStats.writesPerStore[storeReq.objectStoreName] =
diff --git a/packages/idb-bridge/src/backends.test.ts b/packages/idb-bridge/src/backends.test.ts
@@ -74,6 +74,75 @@ test("range deletion respects both open endpoints", async () => {
}
});
+for (const operation of ["insert", "replace", "cursor"] as const) {
+ test(`a canceled unique-index error rolls back a failed ${operation}`, async () => {
+ const open = useTestIndexedDb().open(`request-atomicity-${Math.random()}`);
+ open.onupgradeneeded = () => {
+ const store = open.result.createObjectStore("rows", {
+ keyPath: "id",
+ autoIncrement: true,
+ });
+ store.createIndex("by-tag", "tag");
+ store.createIndex("unique-email", "email", { unique: true });
+ store.createIndex("unique-aliases", "aliases", {
+ unique: true,
+ multiEntry: true,
+ });
+ store.put({ id: 1, email: "first", aliases: ["one"], tag: "original" });
+ store.put({ id: 2, email: "second", aliases: ["two"], tag: "original" });
+ };
+ const db = await promiseFromRequest(open);
+ try {
+ const tx = db.transaction("rows", "readwrite");
+ const finished = new Promise<void>((resolve, reject) => {
+ tx.oncomplete = () => resolve();
+ tx.onabort = () => reject(tx.error);
+ });
+ const store = tx.objectStore("rows");
+ // Conflict in the last index, after earlier index insertions succeeded.
+ const value = {
+ id: operation === "insert" ? 9 : 2,
+ email: "new",
+ aliases: ["fresh", "one"],
+ tag: "replacement",
+ };
+ const cursor =
+ operation === "cursor"
+ ? await promiseFromRequest(store.openCursor(2))
+ : undefined;
+ const request = cursor ? cursor.update(value) : store.put(value);
+ let failure: string | undefined;
+ request.onerror = (event: { preventDefault(): void }) => {
+ failure = request.error?.name;
+ event.preventDefault();
+ // Successful work in the same transaction must still commit, and the
+ // failed insertion must not have advanced the key generator to ten.
+ store.add({ email: "third", aliases: ["three"], tag: "other" });
+ };
+ await finished;
+ assert.strictEqual(failure, "ConstraintError");
+ const read = db.transaction("rows").objectStore("rows");
+ const [rows, indexed, stale] = await Promise.all([
+ promiseFromRequest(read.getAll()),
+ promiseFromRequest(read.index("unique-aliases").getAll()),
+ promiseFromRequest(read.index("by-tag").getAll("replacement")),
+ ]);
+ assert.deepStrictEqual(
+ rows.map((r: any) => [r.id, r.email]),
+ [
+ [1, "first"],
+ [2, "second"],
+ [3, "third"],
+ ],
+ );
+ assert.deepStrictEqual(indexed.map((r: any) => r.id).sort(), [1, 2, 3]);
+ assert.deepStrictEqual(stale, []);
+ } finally {
+ db.close();
+ }
+ });
+}
+
test("Spec: Example 1 Part 1", async (t) => {
const idb = useTestIndexedDb();
diff --git a/packages/idb-bridge/src/sqlite-error-recovery.test.ts b/packages/idb-bridge/src/sqlite-error-recovery.test.ts
@@ -159,3 +159,41 @@ test(
await rawDb.close();
},
);
+
+test(
+ "a canceled request still aborts when its savepoint cannot be rolled back",
+ { timeout: 10_000 },
+ async () => {
+ const { db, faults, rawDb } = await setup();
+ try {
+ // Fail after the object has been written, then prevent its request-local
+ // rollback. Canceling the request error must not commit that partial write.
+ faults.failNext("RELEASE idb_store_request");
+ faults.failNext("ROLLBACK TO idb_store_request");
+ const tx = db.transaction("records", "readwrite");
+ const done = new Promise<TransactionResult>((resolve) => {
+ tx.onabort = () => resolve({ status: "abort", error: tx.error });
+ tx.oncomplete = () => resolve({ status: "complete", error: tx.error });
+ });
+ const request = tx.objectStore("records").put({ id: 1 });
+ request.onerror = (event) => {
+ event.preventDefault();
+ tx.objectStore("records").put({ id: 2 });
+ };
+ const result = await done;
+ assert.strictEqual(result.status, "abort");
+ assert.match(
+ result.error?.message ?? "",
+ /ROLLBACK TO idb_store_request/,
+ );
+ const rows = await promiseFromRequest(
+ db.transaction("records").objectStore("records").getAll(),
+ );
+ assert.deepStrictEqual(rows, []);
+ assert.strictEqual((await put(db, 3)).status, "complete");
+ } finally {
+ db.close();
+ await rawDb.close();
+ }
+ },
+);