Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
12 changes: 12 additions & 0 deletions .changeset/acknowledged-optimistic-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/db': minor
---

Expose an `acknowledged` state for optimistic mutations, a virtual prop derived from existing internal state that sits between the optimistic write and the settled (synced-back) state.
Collections can use `tx.acknowledge()` from inside the mutation handler without exiting, so that people using the collection can decide to fire a transition or drop pending state upon `isAcknowledged` instead of waiting for `isSettled`.

Additive and non-breaking; `isPersisted` / `$synced` are unchanged; no conflict with pending/planned behaviors for `isSettled`.

- `Transaction.acknowledge()` — a setter called by a collection adapter when the server confirms a write.
- `Transaction.isAcknowledged` — resolves when `acknowledge()` is called, or with `isPersisted` when no adapter calls `acknowledge()`; rejects on failure. Never resolves later than `isPersisted`.
- `$acknowledged` virtual property — `true` once acknowledged, always `true` when `$synced` is `true`. Wired through row enrichment, the virtual-prop cache, and group-by aggregation, and emitted as a virtual-prop-only update when it flips mid-flight.
1 change: 1 addition & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The result types are automatically inferred from your query structure, providing
Live query results include computed, read-only virtual properties on every row:

- `$synced`: `true` when the row is confirmed by sync; `false` when it is still optimistic.
- `$acknowledged`: `false` at the start of an optimistic update; flips to `true` when `$synced` is flipped, or earlier if the mutation handler calls `tx.acknowledge()`
- `$origin`: `"local"` if the last confirmed change came from this client, otherwise `"remote"`.
- `$key`: the row key for the result.
- `$collectionId`: the source collection ID.
Expand Down
21 changes: 21 additions & 0 deletions docs/guides/mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,27 @@ tx.isPersisted.promise.then(() => {
console.log(tx.state) // 'pending', 'persisting', 'completed', or 'failed'
```

### Reacting to acknowledgement vs. sync

When a mutation handler acknowledges a server write before the sync comes back, the UI can choose to fire transitions or drop state based on this signal, either `transaction.isAcknowledged` or the `row.$acknowledged`

```typescript
const tx = todoCollection.insert(draft)

await tx.isAcknowledged.promise // resolves at acknowledgement — never later than isPersisted
toast.success("Profile created") // server has the write now; sync may still be catching up
router.navigate('/welcome')
```

Reactively, `$acknowledged` lets you show an intermediate "saved, syncing…" state:

```tsx
const rowState =
!row.$acknowledged ? 'pending-spinner' // in flight
: !row.$synced ? 'saved' // server has it; sync catching up
: 'saved' // fully settled
```

Comment on lines +995 to +1015

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the reactive example to actually demonstrate distinct acknowledgement states.

The text claims $acknowledged "lets you show an intermediate 'saved, syncing…' state," but the ternary collapses both the acknowledged-not-synced and fully-synced branches to the same 'saved' label (lines 1012–1013). Readers looking for how to surface the intermediate state won't see a differentiated example.

Update the final branch to a distinct label so the three-state lifecycle is explicit:

 const rowState =
   !row.$acknowledged ? 'pending-spinner'   // in flight
-  : !row.$synced     ? 'saved'         // server has it; sync catching up
-  :                    'saved'        // fully settled
+  : !row.$synced     ? 'saved-syncing' // server has it; sync catching up
+  :                    'saved-settled'  // fully settled
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Reacting to acknowledgement vs. sync
When a mutation handler acknowledges a server write before the sync comes back, the UI can choose to fire transitions or drop state based on this signal, either `transaction.isAcknowledged` or the `row.$acknowledged`
```typescript
const tx = todoCollection.insert(draft)
await tx.isAcknowledged.promise // resolves at acknowledgement — never later than isPersisted
toast.success("Profile created") // server has the write now; sync may still be catching up
router.navigate('/welcome')
```
Reactively, `$acknowledged` lets you show an intermediate "saved, syncing…" state:
```tsx
const rowState =
!row.$acknowledged ? 'pending-spinner' // in flight
: !row.$synced ? 'saved' // server has it; sync catching up
: 'saved' // fully settled
```
### Reacting to acknowledgement vs. sync
When a mutation handler acknowledges a server write before the sync comes back, the UI can choose to fire transitions or drop state based on this signal, either `transaction.isAcknowledged` or the `row.$acknowledged`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/guides/mutations.md` around lines 995 - 1015, The reactive example in
the acknowledgment vs. sync section does not clearly distinguish the
acknowledged-but-not-synced state from the fully synced state. Update the
ternary in the $acknowledged example so the branch after !row.$acknowledged uses
a distinct label for the intermediate “saved, syncing…” state, and keep the
final branch for the fully synced state; reference the rowState example and the
$acknowledged/$synced conditions when making the change.

## Paced Mutations

Paced mutations provide fine-grained control over **when and how** mutations are persisted to your backend. Instead of persisting every mutation immediately, you can use timing strategies to batch, delay, or queue mutations based on your application's needs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ const stripVirtualProps = <T extends Record<string, any> | undefined>(
if (!value || typeof value !== `object`) return value
const {
$synced: _synced,
$acknowledged: _acknowledged,
$origin: _origin,
$key: _key,
$collectionId: _collectionId,
Expand Down
143 changes: 142 additions & 1 deletion packages/db/src/collection/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ export class CollectionStateManager<
public pendingOptimisticDirectUpserts = new Set<TKey>()
public pendingOptimisticDirectDeletes = new Set<TKey>()

/**
* Keys whose covering optimistic mutation belongs to a transaction that has
* been acknowledged by the server (via {@link Transaction.acknowledge}) but
* whose change has not yet synced back. Used for the `$acknowledged` virtual
* property. Rebuilt from transaction state on each recompute.
*/
public acknowledgedKeys = new Set<TKey>()

/**
* Tracks the origin of confirmed changes for each row.
* 'local' = change originated from this client
Expand All @@ -107,6 +115,7 @@ export class CollectionStateManager<
object,
{
synced: boolean
acknowledged: boolean
origin: VirtualOrigin
key: TKey
collectionId: string
Expand All @@ -120,6 +129,16 @@ export class CollectionStateManager<
// State used for computing the change events
public syncedKeys = new Set<TKey>()
public preSyncVisibleState = new Map<TKey, TOutput>()
/**
* Whether each to-be-synced key was acknowledged in the visible state captured
* before the sync recompute, keyed alongside {@link preSyncVisibleState}. The
* recompute rebuilds `acknowledgedKeys` from active transactions only, so by
* the time a settle event is emitted the now-completed transaction's ack is
* gone; this preserves it so the settle event's `previousValue.$acknowledged`
* reflects what was actually emitted before (e.g. `false` when ack and settle
* coincide, `true` when the row was acknowledged first).
*/
public preSyncAcknowledged = new Map<TKey, boolean>()
public recentlySyncedKeys = new Set<TKey>()
public hasReceivedFirstCommit = false
public isCommittingSyncTransactions = false
Expand Down Expand Up @@ -164,6 +183,27 @@ export class CollectionStateManager<
return !this.optimisticUpserts.has(key) && !this.optimisticDeletes.has(key)
}

/**
* Whether the backend has acknowledged the pending write for a row without
* errors, independent of whether it has synced back. Used for `$acknowledged`.
* A synced row from a completed (settled) transaction, is always acknowledged.
*/
public isRowAcknowledged(key: TKey): boolean {
if (this.isLocalOnly) {
return true
}
if (this.isRowSynced(key)) {
return true
}
if (
this.pendingOptimisticUpserts.has(key) ||
this.pendingOptimisticDeletes.has(key)
) {
return true
}
return this.acknowledgedKeys.has(key)
Comment on lines +191 to +204

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Tie acknowledgement to the visible covering mutation, not just the key.

A key-level acknowledgedKeys makes overlapping writes incorrect: if an older transaction for a key is acknowledged/completed and a newer unacknowledged transaction currently supplies the visible optimistic value, Lines 198-202 or Lines 674-675 still report $acknowledged: true. onTransactionAcknowledged() can also emit for a transaction that no longer covers the visible row.

Track acknowledgement for the last applied optimistic mutation per key, or clear the key when a later unacknowledged optimistic mutation is applied. As per coding guidelines, “Ensure logic matches intent, particularly for subset/superset limit comparisons and predicate merging.” <coding_guidelines>

Also applies to: 673-676, 1476-1487

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/collection/state.ts` around lines 191 - 204, The row
acknowledgment logic in State is still key-based, so newer optimistic mutations
can be shown as $acknowledged:true when an older transaction finishes. Update
isRowAcknowledged, onTransactionAcknowledged, and the optimistic apply/merge
paths so acknowledgment is tied to the last visible covering mutation for a key
rather than acked by key alone. When a newer unacknowledged optimistic write is
applied, clear or override any prior acknowledged state for that key so only the
mutation currently supplying the visible row can report acknowledged.

Source: Coding guidelines

}

/**
* Gets the origin of the last confirmed change to a row.
* Returns 'local' if the row has optimistic mutations (optimistic changes are local).
Expand All @@ -187,6 +227,7 @@ export class CollectionStateManager<
): VirtualRowProps<TKey> {
return {
$synced: overrides?.$synced ?? this.isRowSynced(key),
$acknowledged: overrides?.$acknowledged ?? this.isRowAcknowledged(key),
$origin: overrides?.$origin ?? this.getRowOrigin(key),
$key: overrides?.$key ?? key,
$collectionId: overrides?.$collectionId ?? this.collection.id,
Expand All @@ -200,11 +241,13 @@ export class CollectionStateManager<
optimisticUpserts?: Pick<Map<TKey, unknown>, 'has'>
optimisticDeletes?: Pick<Set<TKey>, 'has'>
completedOptimisticKeys?: Pick<Map<TKey, unknown>, 'has'>
acknowledgedKeys?: Pick<Set<TKey>, 'has'>
},
): VirtualRowProps<TKey> {
if (this.isLocalOnly) {
return this.createVirtualPropsSnapshot(key, {
$synced: true,
$acknowledged: true,
$origin: 'local',
})
}
Expand All @@ -213,13 +256,22 @@ export class CollectionStateManager<
options?.optimisticUpserts ?? this.optimisticUpserts
const optimisticDeletes =
options?.optimisticDeletes ?? this.optimisticDeletes
// `completedOptimisticKeys` are optimistic ops whose transaction has reached
// the `completed` state (its mutationFn returned) but whose overlay is still
// up while this cycle's sync is applied — so the row is not yet $synced.
// Whether it counts as $acknowledged is decided by the captured ack state
// below, not by the mere fact that the transaction completed.
const isCompletedOptimistic =
options?.completedOptimisticKeys?.has(key) === true
const hasOptimisticChange =
optimisticUpserts.has(key) ||
optimisticDeletes.has(key) ||
options?.completedOptimisticKeys?.has(key) === true
isCompletedOptimistic
const acknowledgedKeys = options?.acknowledgedKeys ?? this.acknowledgedKeys

return this.createVirtualPropsSnapshot(key, {
$synced: !hasOptimisticChange,
$acknowledged: !hasOptimisticChange || acknowledgedKeys.has(key),
$origin: hasOptimisticChange
? 'local'
: ((options?.rowOrigins ?? this.rowOrigins).get(key) ?? 'remote'),
Expand All @@ -232,6 +284,7 @@ export class CollectionStateManager<
): WithVirtualProps<TOutput, TKey> {
const existingRow = row as Partial<WithVirtualProps<TOutput, TKey>>
const synced = existingRow.$synced ?? virtualProps.$synced
const acknowledged = existingRow.$acknowledged ?? virtualProps.$acknowledged
const origin = existingRow.$origin ?? virtualProps.$origin
const resolvedKey = existingRow.$key ?? virtualProps.$key
const collectionId = existingRow.$collectionId ?? virtualProps.$collectionId
Expand All @@ -240,6 +293,7 @@ export class CollectionStateManager<
if (
cached &&
cached.synced === synced &&
cached.acknowledged === acknowledged &&
cached.origin === origin &&
cached.key === resolvedKey &&
cached.collectionId === collectionId
Expand All @@ -250,13 +304,15 @@ export class CollectionStateManager<
const enriched = {
...row,
$synced: synced,
$acknowledged: acknowledged,
$origin: origin,
$key: resolvedKey,
$collectionId: collectionId,
} as WithVirtualProps<TOutput, TKey>

this.virtualPropsCache.set(row as object, {
synced,
acknowledged,
origin,
key: resolvedKey,
collectionId,
Expand Down Expand Up @@ -540,6 +596,8 @@ export class CollectionStateManager<
this.optimisticUpserts.clear()
this.optimisticDeletes.clear()
this.pendingLocalChanges.clear()
// Rebuilt below from active transactions' acknowledged flags.
this.acknowledgedKeys.clear()

// Seed optimistic state with pending optimistic mutations only when a sync is pending
const pendingSyncKeys = new Set<TKey>()
Expand Down Expand Up @@ -612,6 +670,10 @@ export class CollectionStateManager<
this.optimisticDeletes.add(mutation.key)
break
}

if (transaction.acknowledged) {
this.acknowledgedKeys.add(mutation.key)
}
}
}
}
Expand Down Expand Up @@ -1187,6 +1249,15 @@ export class CollectionStateManager<
this.pendingOptimisticDirectUpserts.clear()
this.pendingOptimisticDirectDeletes.clear()

// The acknowledged state captured before the recompute (the recompute
// drops the now-completed transaction's ack), so each "previous" snapshot
// below reflects what was actually emitted before the settle — `false`
// when ack and settle coincide, `true` when the row was acknowledged
// first. Stable across the batch, so build the lookup once.
const preSyncAcknowledgedKeys = {
has: (k: TKey) => this.preSyncAcknowledged.get(k) === true,
}

// Now check what actually changed in the final visible state
for (const key of changedKeys) {
const previousVisibleValue = currentVisibleState.get(key)
Expand All @@ -1196,10 +1267,13 @@ export class CollectionStateManager<
optimisticUpserts: previousOptimisticUpserts,
optimisticDeletes: previousOptimisticDeletes,
completedOptimisticKeys: completedOptimisticOps,
acknowledgedKeys: preSyncAcknowledgedKeys,
})
const nextVirtualProps = this.getVirtualPropsSnapshotForState(key)
const virtualChanged =
previousVirtualProps.$synced !== nextVirtualProps.$synced ||
previousVirtualProps.$acknowledged !==
nextVirtualProps.$acknowledged ||
previousVirtualProps.$origin !== nextVirtualProps.$origin
const previousValueWithVirtual =
previousVisibleValue !== undefined
Expand All @@ -1209,6 +1283,7 @@ export class CollectionStateManager<
this.collection.id,
() => previousVirtualProps.$synced,
() => previousVirtualProps.$origin,
() => previousVirtualProps.$acknowledged,
)
: undefined

Expand Down Expand Up @@ -1256,6 +1331,7 @@ export class CollectionStateManager<
this.collection.id,
() => previousVirtualProps.$synced,
() => previousVirtualProps.$origin,
() => previousVirtualProps.$acknowledged,
)
events.push({
type: `update`,
Expand Down Expand Up @@ -1309,6 +1385,7 @@ export class CollectionStateManager<

// Clear the pre-sync state since sync operations are complete
this.preSyncVisibleState.clear()
this.preSyncAcknowledged.clear()

// Clear recently synced keys after a microtask to allow recomputeOptimisticState to see them
Promise.resolve().then(() => {
Expand Down Expand Up @@ -1375,6 +1452,68 @@ export class CollectionStateManager<
this.preSyncVisibleState.set(key, currentValue)
}
}
// Capture the acknowledged state too, before the recompute clears
// acknowledgedKeys. Match the emitted $acknowledged: a still-optimistic
// row is acknowledged only if it was explicitly acknowledged or synced.
if (!this.preSyncAcknowledged.has(key)) {
this.preSyncAcknowledged.set(
key,
this.acknowledgedKeys.has(key) || this.isRowSynced(key),
)
}
}
}

/**
* Called when a transaction is acknowledged by the server (via
* `Transaction.acknowledge()`). Flips `$acknowledged` to true for the
* transaction's still-optimistic rows and emits virtual-prop-only update
* events so subscribers react, without touching the data or the overlay.
*/
public onTransactionAcknowledged(transaction: Transaction<any>): void {
const events: Array<InternalChangeMessage<TOutput, TKey>> = []

for (const mutation of transaction.mutations) {
if (!this.isThisCollection(mutation.collection)) {
continue
}
const key = mutation.key as TKey
if (this.acknowledgedKeys.has(key)) {
continue
}

const value = this.get(key)
const previousVirtualProps = this.getVirtualPropsSnapshotForState(key)
this.acknowledgedKeys.add(key)

// Already settled / not visible: nothing to emit, the flag is enough.
if (value === undefined) {
continue
}

const nextVirtualProps = this.getVirtualPropsSnapshotForState(key)
if (
previousVirtualProps.$acknowledged === nextVirtualProps.$acknowledged
) {
continue
}

events.push({
type: `update`,
key,
value,
previousValue: value,
__virtualProps: {
value: nextVirtualProps,
previousValue: previousVirtualProps,
},
})
}

if (events.length > 0) {
this.indexes.updateIndexes(events)
// forceEmit: acknowledgement is a user-visible action, keep UI responsive.
this.changes.emitEvents(events, true)
}
}

Expand Down Expand Up @@ -1408,6 +1547,8 @@ export class CollectionStateManager<
this.pendingOptimisticDirectUpserts.clear()
this.pendingOptimisticDirectDeletes.clear()
this.clearOriginTrackingState()
this.acknowledgedKeys.clear()
this.preSyncAcknowledged.clear()
this.isLocalOnly = false
this.size = 0
this.pendingSyncedTransactions = []
Expand Down
Loading