From ad78196d37081e63d0d3a6076bad3bf115ad4a37 Mon Sep 17 00:00:00 2001 From: Marius Seufzer Date: Mon, 11 May 2026 12:14:04 +0200 Subject: [PATCH] test(transactions): demonstrate ambient tx loss across await in mutate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstring on `Transaction#mutate` (packages/db/src/transactions.ts:248-251) claims: "If the callback returns a Promise, the transaction context will remain active until the promise settles, allowing optimistic writes after `await` boundaries." The implementation does not honor that contract: `mutate()` invokes the callback synchronously and pops the transaction off the ambient stack in a `finally` block (packages/db/src/transactions.ts:294-298), so any collection operation executed after an `await` inside an async callback runs without an ambient transaction. This test asserts the documented behavior and fails today with `expected length 2, got 1` — only the pre-`await` insert lands on the transaction; the post-`await` insert is orphaned into a new direct transaction via the collection's `onInsert` handler instead. This commit only adds the failing test. CI will go red intentionally until either the implementation is fixed to match the docstring or the docstring is updated to match the implementation. --- packages/db/tests/transactions.test.ts | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/db/tests/transactions.test.ts b/packages/db/tests/transactions.test.ts index d9f27667d..fd03fe6ce 100644 --- a/packages/db/tests/transactions.test.ts +++ b/packages/db/tests/transactions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createTransaction } from '../src/transactions' import { createCollection } from '../src/collection/index.js' import { @@ -84,6 +84,35 @@ describe(`Transactions`, () => { transaction.commit() }) + // Asserts the docstring on Transaction#mutate: "If the callback returns a + // Promise, the transaction context will remain active until the promise + // settles, allowing optimistic writes after `await` boundaries." + it(`should keep the ambient transaction active across awaits inside mutate()`, async () => { + const onInsert = vi.fn(async () => {}) + const collection = createCollection<{ id: number; value: string }>({ + id: `ambient-tx-across-await`, + getKey: (item) => item.id, + onInsert, + sync: { sync: () => {} }, + }) + + const transaction = createTransaction({ + mutationFn: async () => Promise.resolve(), + autoCommit: false, + }) + + transaction.mutate(async () => { + collection.insert({ id: 1, value: `before-await` }) + await Promise.resolve() + collection.insert({ id: 2, value: `after-await` }) + }) + + // Let the async callback's microtasks drain. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(transaction.mutations).toHaveLength(2) + expect(onInsert).not.toHaveBeenCalled() + }) it(`should allow mutating multiple collections`, () => { const transaction = createTransaction({ mutationFn: async () => Promise.resolve(),