taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/idb-bridge/src/SqliteBackend.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpackages/idb-bridge/src/backends.test.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/idb-bridge/src/sqlite-error-recovery.test.ts | 38++++++++++++++++++++++++++++++++++++++
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(); + } + }, +);