Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/poor-worlds-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@tanstack/db': patch
---

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.
54 changes: 47 additions & 7 deletions packages/db/src/collection/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,18 +418,26 @@ 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 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<TKey>()
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 hasPersistingTransaction = persistingTransactionCount > 0

// pending synced transactions could be either `committed` or still open.
// we only want to process `committed` transactions here
Expand Down Expand Up @@ -460,7 +468,39 @@ export class CollectionStateManager<
},
)

if (!hasPersistingTransaction || hasTruncateSync) {
// Collect pending sync keys and compute conflicts
const pendingSyncKeys = new Set<TKey>()
for (const transaction of committedSyncedTransactions) {
for (const operation of transaction.operations) {
pendingSyncKeys.add(operation.key as TKey)
}
}

const conflictingKeys = new Set<TKey>()
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 ||
hasTruncateSync
) {
// Set flag to prevent redundant optimistic state recalculations
this.isCommittingSyncTransactions = true

Expand Down
47 changes: 47 additions & 0 deletions packages/db/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TKey>
/** Keys of items being modified by currently persisting optimistic transactions */
persistingKeys: Set<TKey>
/** Keys that appear in both pending sync and persisting transactions (potential conflicts) */
conflictingKeys: Set<TKey>
/** 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<string, unknown>,
TKey extends string | number = string | number,
Expand Down Expand Up @@ -348,6 +369,32 @@ export interface SyncConfig<
* - `full`: Updates contain the entire row.
*/
rowUpdateMode?: `partial` | `full`

/**
* 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
*/
onSyncWhilePersisting?: (context: SyncWhilePersistingContext<TKey>) => boolean
}

export interface ChangeMessage<
Expand Down
4 changes: 2 additions & 2 deletions packages/db/tests/collection-getters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type { SyncConfig } from '../src/types'
type Item = { id: string; name: string }

describe(`Collection getters`, () => {
let collection: CollectionImpl<Item>
let mockSync: SyncConfig<Item>
let collection: CollectionImpl<Item, string>
let mockSync: SyncConfig<Item, string>

beforeEach(() => {
mockSync = {
Expand Down
Loading
Loading