From 24258cae70a01ebbee12eed61576f61cd415b59f Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 7 Jul 2026 12:55:52 +0200 Subject: [PATCH] Fix for applyTransaction hangs forever when a transaction mixes delete+insert on one collection --- .changeset/hot-bats-camp.md | 5 ++ .../src/PowerSyncTransactor.ts | 2 +- .../tests/powersync.test.ts | 67 ++++++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 .changeset/hot-bats-camp.md diff --git a/.changeset/hot-bats-camp.md b/.changeset/hot-bats-camp.md new file mode 100644 index 0000000000..807f81d0a7 --- /dev/null +++ b/.changeset/hot-bats-camp.md @@ -0,0 +1,5 @@ +--- +'@tanstack/powersync-db-collection': patch +--- + +Fix: applyTransaction hangs forever when a transaction mixes delete+insert on one collection for a same-millisecond tie. diff --git a/packages/powersync-db-collection/src/PowerSyncTransactor.ts b/packages/powersync-db-collection/src/PowerSyncTransactor.ts index 2542b6d545..1e174f034e 100644 --- a/packages/powersync-db-collection/src/PowerSyncTransactor.ts +++ b/packages/powersync-db-collection/src/PowerSyncTransactor.ts @@ -284,7 +284,7 @@ export class PowerSyncTransactor { // Need to get the operation in order to wait for it const diffOperation = await context.get<{ id: string; timestamp: string }>( - sanitizeSQL`SELECT id, timestamp FROM ${trackedTableName} ORDER BY timestamp DESC LIMIT 1`, + sanitizeSQL`SELECT id, timestamp FROM ${trackedTableName} ORDER BY operation_id DESC LIMIT 1`, ) return { tableName, diff --git a/packages/powersync-db-collection/tests/powersync.test.ts b/packages/powersync-db-collection/tests/powersync.test.ts index ebf93223ff..adedda7988 100644 --- a/packages/powersync-db-collection/tests/powersync.test.ts +++ b/packages/powersync-db-collection/tests/powersync.test.ts @@ -17,7 +17,8 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { powerSyncCollectionOptions } from '../src' import { PowerSyncTransactor } from '../src/PowerSyncTransactor' import { TEST_DATABASE_IMPLEMENTATION } from './test-db-implementation' -import type { AbstractPowerSyncDatabase } from '@powersync/node' +import type { AbstractPowerSyncDatabase, LockContext } from '@powersync/node' +import type { PendingMutation } from '@tanstack/db' const APP_SCHEMA = new Schema({ users: new Table({ @@ -275,6 +276,70 @@ describePowerSync(`PowerSync Integration`, () => { ).true }) + it(`should complete transactions that delete one key and insert another (same-millisecond tie)`, async () => { + const db = await createDatabase() + + const options = powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.documents, + }) + const collection = createCollection(options) + onTestFinished(() => collection.cleanup()) + await collection.stateWhenReady() + + // Seed the row which will be deleted in the transaction + await collection.insert({ id: `a`, name: `row a` }).isPersisted.promise + + const { trackedTableName } = options.utils.getMeta() + + class SameMillisecondTransactor extends PowerSyncTransactor { + protected override async handleDelete( + mutation: PendingMutation, + context: LockContext, + waitForCompletion?: boolean, + ) { + const result = await super.handleDelete( + mutation, + context, + waitForCompletion, + ) + // Simulate the same-millisecond tie: ensure the delete's diff row + // wins the `ORDER BY timestamp DESC` readback of the insert below. + await context.execute( + `UPDATE ${trackedTableName} SET timestamp = '9999-12-31T23:59:59.999Z'`, + ) + return result + } + } + + const transactor = new SameMillisecondTransactor({ database: db }) + + const tx = createTransaction({ + autoCommit: false, + mutationFn: async ({ transaction }) => { + await transactor.applyTransaction(transaction) + }, + }) + + tx.mutate(() => { + collection.delete(`a`) + collection.insert({ id: `b`, name: `row b` }) + }) + + const outcome = await Promise.race([ + tx.commit().then(() => `persisted` as const), + new Promise<`timed out`>((resolve) => + setTimeout(() => resolve(`timed out`), 2_000), + ), + ]) + expect(outcome).toBe(`persisted`) + + const documents = await db.getAll<{ id: string }>( + `SELECT id FROM documents`, + ) + expect(documents.map((doc) => doc.id)).toEqual([`b`]) + }) + it(`should handle transactions with multiple collections`, async () => { const db = await createDatabase() await createTestData(db)