From c427e7e121155400ceeb798e3c081a228e2a1b09 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Thu, 18 Dec 2025 21:12:30 -0800 Subject: [PATCH 1/4] collection: option to sync while persisting. --- packages/db/src/collection/state.ts | 13 +- packages/db/src/types.ts | 6 + .../optimistic-sync-while-persisting.test.ts | 131 ++++++++++++++++++ 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 packages/db/tests/query/optimistic-sync-while-persisting.test.ts diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index b76580c19..81b086d49 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -418,8 +418,9 @@ export class CollectionStateManager< } /** - * Attempts to commit pending synced transactions if there are no active transactions - * This method processes operations from pending transactions and applies them to the synced data + * Attempts to commit pending synced transactions. + * Commits are normally deferred while optimistic transactions are persisting, + * unless allowSyncWhilePersisting is enabled on the collection's sync config. */ commitPendingTransactions = () => { // Check if there are any persisting transaction @@ -430,6 +431,8 @@ export class CollectionStateManager< break } } + const allowSyncWhilePersisting = + this.config.sync.allowSyncWhilePersisting === true // pending synced transactions could be either `committed` or still open. // we only want to process `committed` transactions here @@ -460,7 +463,11 @@ export class CollectionStateManager< }, ) - if (!hasPersistingTransaction || hasTruncateSync) { + if ( + allowSyncWhilePersisting || + !hasPersistingTransaction || + hasTruncateSync + ) { // Set flag to prevent redundant optimistic state recalculations this.isCommittingSyncTransactions = true diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 22f0fd75b..d67a36bbe 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -348,6 +348,12 @@ export interface SyncConfig< * - `full`: Updates contain the entire row. */ rowUpdateMode?: `partial` | `full` + + /** + * Allow applying synced transactions while optimistic transactions are persisting. + * @default false + */ + allowSyncWhilePersisting?: boolean } export interface ChangeMessage< diff --git a/packages/db/tests/query/optimistic-sync-while-persisting.test.ts b/packages/db/tests/query/optimistic-sync-while-persisting.test.ts new file mode 100644 index 000000000..5ff765e35 --- /dev/null +++ b/packages/db/tests/query/optimistic-sync-while-persisting.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "vitest" +import { createCollection } from "../../src/collection/index.js" +import { createLiveQueryCollection } from "../../src/query/index.js" +import { createOptimisticAction } from "../../src/optimistic-action.js" + +type Item = { + id: string + value: string +} + +type SyncControls = { + begin: () => void + write: (message: { + type: `insert` | `update` | `delete` + value: Item + }) => void + commit: () => void + markReady: () => void +} + +let collectionCounter = 0 + +function setup() { + let syncBegin: SyncControls[`begin`] | undefined + let syncWrite: SyncControls[`write`] | undefined + let syncCommit: SyncControls[`commit`] | undefined + let syncMarkReady: SyncControls[`markReady`] | undefined + + const source = createCollection({ + id: `optimistic-sync-source-${++collectionCounter}`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + allowSyncWhilePersisting: true, + }, + }) + + const derived = createLiveQueryCollection({ + startSync: true, + query: (q) => q.from({ item: source }), + getKey: (item: Item) => item.id, + }) + + if (!syncBegin || !syncWrite || !syncCommit || !syncMarkReady) { + throw new Error(`Sync controls not initialized`) + } + + return { + source, + derived, + sync: { + begin: syncBegin, + write: syncWrite, + commit: syncCommit, + markReady: syncMarkReady, + }, + } +} + +function createPendingOptimisticAction( + source: ReturnType[`source`] +) { + let resolveMutation: (() => void) | undefined + let startResolve: (() => void) | undefined + const started = new Promise((resolve) => { + startResolve = resolve + }) + + const action = createOptimisticAction({ + onMutate: (item) => { + source.insert(item) + }, + mutationFn: async () => { + return new Promise((resolve) => { + resolveMutation = resolve + startResolve?.() + }) + }, + }) + + return { + action, + started, + resolve: () => resolveMutation?.(), + } +} + +describe(`sync while optimistic transaction is persisting (opt-in)`, () => { + test(`shows non-conflicting synced rows immediately`, async () => { + const { source, derived, sync } = setup() + const { action, started, resolve } = createPendingOptimisticAction(source) + + action({ id: `optimistic-1`, value: `optimistic` }) + await started + + sync.begin() + sync.write({ type: `insert`, value: { id: `synced-1`, value: `synced` } }) + sync.commit() + + expect(derived.has(`optimistic-1`)).toBe(true) + expect(derived.has(`synced-1`)).toBe(true) + expect(derived.size).toBe(2) + + resolve() + }) + + test(`keeps optimistic row visible on conflicting sync`, async () => { + const { source, derived, sync } = setup() + const { action, started, resolve } = createPendingOptimisticAction(source) + + action({ id: `item-1`, value: `optimistic` }) + await started + + expect(derived.get(`item-1`)?.value).toBe(`optimistic`) + + sync.begin() + sync.write({ type: `insert`, value: { id: `item-1`, value: `synced` } }) + sync.commit() + + expect(derived.size).toBe(1) + expect(derived.get(`item-1`)?.value).toBe(`optimistic`) + + resolve() + }) +}) From 478ebc054555e83a5922ca61fcd9db2864b69423 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 05:19:31 +0000 Subject: [PATCH 2/4] ci: apply automated fixes --- .../query/optimistic-sync-while-persisting.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/db/tests/query/optimistic-sync-while-persisting.test.ts b/packages/db/tests/query/optimistic-sync-while-persisting.test.ts index 5ff765e35..c9e76ad51 100644 --- a/packages/db/tests/query/optimistic-sync-while-persisting.test.ts +++ b/packages/db/tests/query/optimistic-sync-while-persisting.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, test } from "vitest" -import { createCollection } from "../../src/collection/index.js" -import { createLiveQueryCollection } from "../../src/query/index.js" -import { createOptimisticAction } from "../../src/optimistic-action.js" +import { describe, expect, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createLiveQueryCollection } from '../../src/query/index.js' +import { createOptimisticAction } from '../../src/optimistic-action.js' type Item = { id: string @@ -64,7 +64,7 @@ function setup() { } function createPendingOptimisticAction( - source: ReturnType[`source`] + source: ReturnType[`source`], ) { let resolveMutation: (() => void) | undefined let startResolve: (() => void) | undefined From 53e5b1b0fa0f7026fbd2ca412b9a3d890ec91e2b Mon Sep 17 00:00:00 2001 From: James Arthur Date: Thu, 18 Dec 2025 21:22:45 -0800 Subject: [PATCH 3/4] changeset. --- .changeset/poor-worlds-glow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/poor-worlds-glow.md diff --git a/.changeset/poor-worlds-glow.md b/.changeset/poor-worlds-glow.md new file mode 100644 index 000000000..f9b48c9b7 --- /dev/null +++ b/.changeset/poor-worlds-glow.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Added an `allowSyncWhilePersisting?: boolean` flag to core collection sync config. If present and true then this allows concurrent updates to sync into a collection whilst an optimistic transaction is persisting. From 57776d61573afeb473525a39b0f748c8a0da83ca Mon Sep 17 00:00:00 2001 From: James Arthur Date: Thu, 18 Dec 2025 23:14:29 -0800 Subject: [PATCH 4/4] collection: sharper onSyncWhilePersisting callback rather than flag. --- .changeset/poor-worlds-glow.md | 22 +- packages/db/src/collection/state.ts | 47 ++- packages/db/src/types.ts | 47 ++- packages/db/tests/collection-getters.test.ts | 4 +- .../optimistic-sync-while-persisting.test.ts | 269 +++++++++++++++++- 5 files changed, 366 insertions(+), 23 deletions(-) diff --git a/.changeset/poor-worlds-glow.md b/.changeset/poor-worlds-glow.md index f9b48c9b7..6d46b9bed 100644 --- a/.changeset/poor-worlds-glow.md +++ b/.changeset/poor-worlds-glow.md @@ -2,4 +2,24 @@ '@tanstack/db': patch --- -Added an `allowSyncWhilePersisting?: boolean` flag to core collection sync config. If present and true then this allows concurrent updates to sync into a collection whilst an optimistic transaction is persisting. +Added `onSyncWhilePersisting` callback to collection sync config for fine-grained control over whether synced data should be applied while optimistic transactions are persisting. + +The callback receives context about pending sync operations and persisting transactions, allowing developers to make selective decisions: + +```typescript +sync: { + sync: ({ begin, write, commit }) => { /* ... */ }, + // Allow sync only when there are no conflicting keys + onSyncWhilePersisting: ({ conflictingKeys }) => conflictingKeys.size === 0, +} +``` + +Context provided to the callback: + +- `pendingSyncKeys` - Keys of items in pending sync operations +- `persistingKeys` - Keys being modified by persisting optimistic transactions +- `conflictingKeys` - Keys that appear in both (potential conflicts) +- `persistingTransactionCount` - Number of persisting transactions +- `isTruncate` - Whether this includes a truncate operation + +If no callback is provided, sync is deferred while any optimistic transaction is persisting (the safe default). Truncate operations always proceed regardless of the callback. diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 81b086d49..02fc247d0 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -420,19 +420,24 @@ export class CollectionStateManager< /** * Attempts to commit pending synced transactions. * Commits are normally deferred while optimistic transactions are persisting, - * unless allowSyncWhilePersisting is enabled on the collection's sync config. + * unless the onSyncWhilePersisting callback returns true. */ commitPendingTransactions = () => { - // Check if there are any persisting transaction - let hasPersistingTransaction = false + // Check for persisting transactions and collect their keys + let persistingTransactionCount = 0 + const persistingKeys = new Set() for (const transaction of this.transactions.values()) { if (transaction.state === `persisting`) { - hasPersistingTransaction = true - break + persistingTransactionCount++ + // Collect keys from this transaction's mutations for this collection + for (const mutation of transaction.mutations) { + if (mutation.collection === this.collection) { + persistingKeys.add(mutation.key as TKey) + } + } } } - const allowSyncWhilePersisting = - this.config.sync.allowSyncWhilePersisting === true + const hasPersistingTransaction = persistingTransactionCount > 0 // pending synced transactions could be either `committed` or still open. // we only want to process `committed` transactions here @@ -463,6 +468,34 @@ export class CollectionStateManager< }, ) + // Collect pending sync keys and compute conflicts + const pendingSyncKeys = new Set() + for (const transaction of committedSyncedTransactions) { + for (const operation of transaction.operations) { + pendingSyncKeys.add(operation.key as TKey) + } + } + + const conflictingKeys = new Set() + for (const key of pendingSyncKeys) { + if (persistingKeys.has(key)) { + conflictingKeys.add(key) + } + } + + // Determine whether to allow sync while persisting via callback + let allowSyncWhilePersisting = false + const onSyncWhilePersisting = this.config.sync.onSyncWhilePersisting + if (hasPersistingTransaction && onSyncWhilePersisting && !hasTruncateSync) { + allowSyncWhilePersisting = onSyncWhilePersisting({ + pendingSyncKeys, + persistingKeys, + conflictingKeys, + persistingTransactionCount, + isTruncate: false, + }) + } + if ( allowSyncWhilePersisting || !hasPersistingTransaction || diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index d67a36bbe..53bd1e671 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -321,6 +321,27 @@ export type SyncConfigRes = { loadSubset?: LoadSubsetFn unloadSubset?: UnloadSubsetFn } + +/** + * Context passed to the onSyncWhilePersisting callback. + * Provides information about pending sync operations and persisting transactions + * to help decide whether to allow synced data to be applied immediately. + */ +export interface SyncWhilePersistingContext< + TKey extends string | number = string | number, +> { + /** Keys of items in pending sync operations waiting to be applied */ + pendingSyncKeys: Set + /** Keys of items being modified by currently persisting optimistic transactions */ + persistingKeys: Set + /** Keys that appear in both pending sync and persisting transactions (potential conflicts) */ + conflictingKeys: Set + /** Number of optimistic transactions currently in the persisting state */ + persistingTransactionCount: number + /** Whether this includes a truncate operation (truncates always proceed regardless) */ + isTruncate: boolean +} + export interface SyncConfig< T extends object = Record, TKey extends string | number = string | number, @@ -350,10 +371,30 @@ export interface SyncConfig< rowUpdateMode?: `partial` | `full` /** - * Allow applying synced transactions while optimistic transactions are persisting. - * @default false + * Callback invoked when synced data arrives while optimistic transactions are persisting. + * Return true to allow the sync to proceed, false to defer until transactions complete. + * + * If not provided, synced data is deferred while any optimistic transaction is persisting + * (the safe default). Use this callback to selectively allow non-conflicting syncs. + * + * Note: Truncate operations always proceed regardless of this callback. + * + * @param context - Information about pending sync and persisting transactions + * @returns true to allow sync, false to defer + * + * @example + * // Always allow sync while persisting (most permissive) + * onSyncWhilePersisting: () => true + * + * @example + * // Allow only if there are no conflicting keys + * onSyncWhilePersisting: ({ conflictingKeys }) => conflictingKeys.size === 0 + * + * @example + * // Allow small syncs only + * onSyncWhilePersisting: ({ pendingSyncKeys }) => pendingSyncKeys.size < 10 */ - allowSyncWhilePersisting?: boolean + onSyncWhilePersisting?: (context: SyncWhilePersistingContext) => boolean } export interface ChangeMessage< diff --git a/packages/db/tests/collection-getters.test.ts b/packages/db/tests/collection-getters.test.ts index f5f8dda41..ff058fc4c 100644 --- a/packages/db/tests/collection-getters.test.ts +++ b/packages/db/tests/collection-getters.test.ts @@ -7,8 +7,8 @@ import type { SyncConfig } from '../src/types' type Item = { id: string; name: string } describe(`Collection getters`, () => { - let collection: CollectionImpl - let mockSync: SyncConfig + let collection: CollectionImpl + let mockSync: SyncConfig beforeEach(() => { mockSync = { diff --git a/packages/db/tests/query/optimistic-sync-while-persisting.test.ts b/packages/db/tests/query/optimistic-sync-while-persisting.test.ts index c9e76ad51..366bd1569 100644 --- a/packages/db/tests/query/optimistic-sync-while-persisting.test.ts +++ b/packages/db/tests/query/optimistic-sync-while-persisting.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection } from '../../src/query/index.js' import { createOptimisticAction } from '../../src/optimistic-action.js' +import type { SyncWhilePersistingContext } from '../../src/types.js' type Item = { id: string @@ -20,7 +21,15 @@ type SyncControls = { let collectionCounter = 0 -function setup() { +type SetupOptions = { + onSyncWhilePersisting?: ( + context: SyncWhilePersistingContext, + ) => boolean +} + +function setup(options: SetupOptions = {}) { + const { onSyncWhilePersisting = () => true } = options + let syncBegin: SyncControls[`begin`] | undefined let syncWrite: SyncControls[`write`] | undefined let syncCommit: SyncControls[`commit`] | undefined @@ -37,7 +46,7 @@ function setup() { syncCommit = commit syncMarkReady = markReady }, - allowSyncWhilePersisting: true, + onSyncWhilePersisting, }, }) @@ -63,9 +72,9 @@ function setup() { } } -function createPendingOptimisticAction( - source: ReturnType[`source`], -) { +function createPendingOptimisticAction(source: { + insert: (item: Item) => any +}) { let resolveMutation: (() => void) | undefined let startResolve: (() => void) | undefined const started = new Promise((resolve) => { @@ -91,9 +100,11 @@ function createPendingOptimisticAction( } } -describe(`sync while optimistic transaction is persisting (opt-in)`, () => { - test(`shows non-conflicting synced rows immediately`, async () => { - const { source, derived, sync } = setup() +describe(`sync while optimistic transaction is persisting (onSyncWhilePersisting callback)`, () => { + test(`shows non-conflicting synced rows immediately when callback returns true`, async () => { + const { source, derived, sync } = setup({ + onSyncWhilePersisting: () => true, + }) const { action, started, resolve } = createPendingOptimisticAction(source) action({ id: `optimistic-1`, value: `optimistic` }) @@ -111,7 +122,9 @@ describe(`sync while optimistic transaction is persisting (opt-in)`, () => { }) test(`keeps optimistic row visible on conflicting sync`, async () => { - const { source, derived, sync } = setup() + const { source, derived, sync } = setup({ + onSyncWhilePersisting: () => true, + }) const { action, started, resolve } = createPendingOptimisticAction(source) action({ id: `item-1`, value: `optimistic` }) @@ -128,4 +141,240 @@ describe(`sync while optimistic transaction is persisting (opt-in)`, () => { resolve() }) + + test(`callback receives correct context`, async () => { + const callbackSpy = vi.fn( + (_ctx: SyncWhilePersistingContext) => true, + ) + const { source, sync } = setup({ onSyncWhilePersisting: callbackSpy }) + const { action, started, resolve } = createPendingOptimisticAction(source) + + action({ id: `optimistic-1`, value: `optimistic` }) + await started + + sync.begin() + sync.write({ type: `insert`, value: { id: `synced-1`, value: `synced` } }) + sync.write({ type: `insert`, value: { id: `synced-2`, value: `synced` } }) + sync.commit() + + expect(callbackSpy).toHaveBeenCalledTimes(1) + const context = callbackSpy.mock + .calls[0]![0] as unknown as SyncWhilePersistingContext + + expect(context.pendingSyncKeys).toBeInstanceOf(Set) + expect(context.pendingSyncKeys.has(`synced-1`)).toBe(true) + expect(context.pendingSyncKeys.has(`synced-2`)).toBe(true) + expect(context.pendingSyncKeys.size).toBe(2) + + expect(context.persistingKeys).toBeInstanceOf(Set) + expect(context.persistingKeys.has(`optimistic-1`)).toBe(true) + expect(context.persistingKeys.size).toBe(1) + + expect(context.conflictingKeys).toBeInstanceOf(Set) + expect(context.conflictingKeys.size).toBe(0) + + expect(context.persistingTransactionCount).toBe(1) + expect(context.isTruncate).toBe(false) + + resolve() + }) + + test(`callback receives conflicting keys correctly`, async () => { + const callbackSpy = vi.fn( + (_ctx: SyncWhilePersistingContext) => true, + ) + const { source, sync } = setup({ onSyncWhilePersisting: callbackSpy }) + const { action, started, resolve } = createPendingOptimisticAction(source) + + action({ id: `shared-key`, value: `optimistic` }) + await started + + sync.begin() + sync.write({ type: `insert`, value: { id: `shared-key`, value: `synced` } }) + sync.write({ + type: `insert`, + value: { id: `synced-only`, value: `synced` }, + }) + sync.commit() + + const context = callbackSpy.mock + .calls[0]![0] as unknown as SyncWhilePersistingContext + + expect(context.conflictingKeys.has(`shared-key`)).toBe(true) + expect(context.conflictingKeys.size).toBe(1) + expect(context.pendingSyncKeys.size).toBe(2) + expect(context.persistingKeys.size).toBe(1) + + resolve() + }) + + test(`callback returning false defers sync until transaction completes`, async () => { + const callbackSpy = vi.fn(() => false) + const { source, derived, sync } = setup({ + onSyncWhilePersisting: callbackSpy, + }) + const { action, started, resolve } = createPendingOptimisticAction(source) + + const transaction = action({ id: `optimistic-1`, value: `optimistic` }) + await started + + // At this point, transaction should be persisting + expect(transaction.state).toBe(`persisting`) + + sync.begin() + sync.write({ type: `insert`, value: { id: `synced-1`, value: `synced` } }) + sync.commit() + + // Callback should have been called once (during sync.commit) + expect(callbackSpy).toHaveBeenCalledTimes(1) + + // Synced row should NOT be visible yet because callback returned false + expect(derived.has(`optimistic-1`)).toBe(true) + expect(derived.has(`synced-1`)).toBe(false) + + // Check pending sync transactions before completing + expect(source._state.pendingSyncedTransactions.length).toBeGreaterThan(0) + + // Complete the optimistic transaction and wait for it to finish + resolve() + await transaction.isPersisted.promise + + // Transaction should now be completed + expect(transaction.state).toBe(`completed`) + + // Give a tick for any async cleanup + await new Promise((r) => setTimeout(r, 0)) + + // The callback should NOT be called again (because no persisting transactions) + // So it should still be 1 call total + expect(callbackSpy).toHaveBeenCalledTimes(1) + + // Pending syncs should have been processed + expect(source._state.pendingSyncedTransactions.length).toBe(0) + + // Now synced row should be visible + expect(source.has(`synced-1`)).toBe(true) + expect(derived.has(`synced-1`)).toBe(true) + }) + + test(`selective allow based on conflicting keys`, async () => { + // Only allow sync if there are no conflicts + const { source, derived, sync } = setup({ + onSyncWhilePersisting: ({ conflictingKeys }) => + conflictingKeys.size === 0, + }) + const { action, started, resolve } = createPendingOptimisticAction(source) + + action({ id: `optimistic-1`, value: `optimistic` }) + await started + + // Non-conflicting sync should be allowed + sync.begin() + sync.write({ type: `insert`, value: { id: `synced-1`, value: `synced` } }) + sync.commit() + + expect(derived.has(`synced-1`)).toBe(true) + + // Conflicting sync should be deferred + sync.begin() + sync.write({ + type: `update`, + value: { id: `optimistic-1`, value: `synced-update` }, + }) + sync.commit() + + // The optimistic value should still be visible (sync was deferred) + expect(derived.get(`optimistic-1`)?.value).toBe(`optimistic`) + + resolve() + }) + + test(`no callback means sync is deferred while persisting (default behavior)`, async () => { + // Setup without onSyncWhilePersisting callback + let syncBegin: SyncControls[`begin`] | undefined + let syncWrite: SyncControls[`write`] | undefined + let syncCommit: SyncControls[`commit`] | undefined + + const source = createCollection({ + id: `no-callback-test-${++collectionCounter}`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + }, + // No onSyncWhilePersisting callback + }, + }) + + const derived = createLiveQueryCollection({ + startSync: true, + query: (q) => q.from({ item: source }), + getKey: (item: Item) => item.id, + }) + + const { action, started, resolve } = createPendingOptimisticAction(source) + + const transaction = action({ id: `optimistic-1`, value: `optimistic` }) + await started + + syncBegin!() + syncWrite!({ type: `insert`, value: { id: `synced-1`, value: `synced` } }) + syncCommit!() + + // Without callback, sync should be deferred + expect(derived.has(`optimistic-1`)).toBe(true) + expect(derived.has(`synced-1`)).toBe(false) + + resolve() + await transaction.isPersisted.promise + + // After transaction completes, sync should apply + expect(derived.has(`synced-1`)).toBe(true) + }) + + test(`callback is not called when no persisting transactions`, async () => { + const callbackSpy = vi.fn(() => true) + const { sync } = setup({ onSyncWhilePersisting: callbackSpy }) + + // Sync without any pending optimistic transaction + sync.begin() + sync.write({ type: `insert`, value: { id: `synced-1`, value: `synced` } }) + sync.commit() + + // Callback should not be called since there's no persisting transaction + expect(callbackSpy).not.toHaveBeenCalled() + }) + + test(`multiple persisting transactions are counted correctly`, async () => { + const callbackSpy = vi.fn( + (_ctx: SyncWhilePersistingContext) => true, + ) + const { source, sync } = setup({ onSyncWhilePersisting: callbackSpy }) + + // Create two pending optimistic actions + const action1 = createPendingOptimisticAction(source) + const action2 = createPendingOptimisticAction(source) + + action1.action({ id: `optimistic-1`, value: `optimistic` }) + await action1.started + + action2.action({ id: `optimistic-2`, value: `optimistic` }) + await action2.started + + sync.begin() + sync.write({ type: `insert`, value: { id: `synced-1`, value: `synced` } }) + sync.commit() + + const context = callbackSpy.mock + .calls[0]![0] as unknown as SyncWhilePersistingContext + expect(context.persistingTransactionCount).toBe(2) + expect(context.persistingKeys.has(`optimistic-1`)).toBe(true) + expect(context.persistingKeys.has(`optimistic-2`)).toBe(true) + + action1.resolve() + action2.resolve() + }) })