Skip to content

feat(db): expose an acknowledged state for optimistic mutations#1633

Open
mhsnook wants to merge 12 commits into
TanStack:mainfrom
mhsnook:feat/tx-acknowledge
Open

feat(db): expose an acknowledged state for optimistic mutations#1633
mhsnook wants to merge 12 commits into
TanStack:mainfrom
mhsnook:feat/tx-acknowledge

Conversation

@mhsnook

@mhsnook mhsnook commented Jul 1, 2026

Copy link
Copy Markdown

🎯 Changes

An optimistic mutation with a realtime-sync backend often offer two opportunities to confirm: an initial acknowledgement of the mutation's success, and an echo that comes back in over the realtime sync path.

Today isPersisted and $synced only flip at the very end of the process, when the optimistic overlay is dropped. But for many UIs, the initial acknowledgement is enough to realistically proceed with our UI -- to drop the spinner or fire a transition -- decoupling its timing from that of the data overlay. The library already tracks this state internally; this PR just exposes it. Everything here is additive and non-breakingisPersisted / $synced keep their exact timing.

New in @tanstack/db:

  • Transaction.acknowledge() -- called from inside the mutation handler, to indicate acknowledgement while the handler remains unresolved (and the overlay stays in place).
  • Transaction.isAcknowledged -- resolves together with isPersisted, or earlier if/when the adapter calls acknowledge(); rejects on failure.
  • $acknowledged virtual property -- true once acknowledged, always true when $synced is true.

No collection adapter is changed in this PR — collection maintainers choose to adopt it is left to the maintainers. Whether collections adopt it is entirely left to them, but here is what it would look like if it were used in an insert wrapper much like the Electric DB collection's:

const wrappedOnInsert = async (params) => {
  const result = await config.onInsert!(params) 
  // "okay ✅ / ❌ error" is known at this time..

  params.transaction.acknowledge()  // 🥳 UI can move on

  await processMatchingStrategy(result)
  return result
}

Consuming it

// imperative — react a sync round-trip earlier than isPersisted
const tx = todos.insert(draft)
await tx.isAcknowledged   // fires at the ack if the collection adopted it
toast.success(`Saved`)

// reactive, three phases
const label =
  !row.$acknowledged ? `Saving…`
  : !row.$synced     ? `Saved`   // server has it; quietly reconciling
  :                    `Saved`   // fully settled

Docs

✅ Checklist

  • I have tested this code locally with pnpm test.

🚀 Release Impact

  • This change affects published code, and I have added a changeset (@tanstack/db: minor).
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • New Features
    • Added $acknowledged virtual property to expose server acknowledgement state during optimistic updates (distinct from $synced), including GROUP BY aggregation.
    • Introduced transaction acknowledgement via acknowledge() and isAcknowledged to enable “acknowledged vs synced” UI transitions.
  • Documentation
    • Updated mutations and live-queries guides to explain acknowledgement vs sync and how to react in the UI.
  • Bug Fixes
    • Improved propagation and change-event behavior so $acknowledged updates correctly without altering synced/overlay data.
  • Tests
    • Expanded acknowledgement test coverage and updated virtual-prop stripping helpers to account for $acknowledged.

claude and others added 9 commits June 30, 2026 12:30
Introduce the "acknowledged" milestone for optimistic mutations, sitting
between the optimistic write and the settled (synced-back) state. A write
against a realtime-sync backend has two confirmations: the server accepting
the write (acknowledged) and the change echoing back through sync (settled).
Today every UI signal flips only at settle; this exposes the earlier ack.

This commit adds the core, transport-agnostic plumbing in @tanstack/db:

- Transaction: a no-op-safe `acknowledge()` setter, an `acknowledged` flag,
  and an `isAcknowledged` deferred that resolves at the ack, coincides with
  `isPersisted` when no adapter acks, and rejects on failure.
- virtual-props: a new `$acknowledged` virtual property (always true when
  `$synced` is true; coincides with `$synced` for collections without a
  separate ack signal), wired through the create/enrich/aggregate helpers.
- CollectionStateManager: track `acknowledgedKeys`, compute `$acknowledged`
  in the virtual-prop snapshot and cache, and emit a virtual-prop-only
  update when a transaction is acknowledged mid-flight, without touching the
  data or the optimistic overlay.

`isPersisted` / `$synced` are byte-for-byte unchanged: the handler still
blocks on the echo, so their timing is identical. The new signal is purely
additive and opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdrWSXNfp9tfYMWrjVxvo6
Extend the live-query group-by compiler to aggregate the new
`$acknowledged` virtual property alongside `$synced`: a group is
acknowledged only when every row in it is acknowledged. Rows from
collections without a separate ack signal fall back to `$synced`, so the
aggregate stays consistent with non-grouped queries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdrWSXNfp9tfYMWrjVxvo6
Add tests for the ack layer: acknowledge() flips $acknowledged and resolves
isAcknowledged while still persisting (without settling) and emits a
virtual-prop-only update; ack and settle coincide when no adapter calls
acknowledge(); failure before the ack rejects isAcknowledged; an already
resolved ack survives a later settle failure; synced rows are acknowledged;
and acknowledge() is idempotent and a no-op after completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdrWSXNfp9tfYMWrjVxvo6
The settle change event's previousValue.$acknowledged could read `true`
even when the row was never acknowledged separately (ack and settle
coinciding) — i.e. when a collection doesn't call acknowledge(). The
"previous" virtual-prop snapshot treated a just-completed (settled)
optimistic op as acknowledged, and by settle time acknowledgedKeys has
already been rebuilt from active transactions only, dropping the
now-completed transaction's ack.

Fix:
- Capture the acknowledged state of the to-be-synced keys before the
  recompute clears it (in capturePreSyncVisibleState, alongside
  preSyncVisibleState), into preSyncAcknowledged.
- Feed that captured set into the "previous" snapshot at the settle
  emission instead of relying on live acknowledgedKeys.
- Stop treating "settled" (isCompletedOptimistic) as implying
  "acknowledged" in the $acknowledged computation; $synced is unchanged.

Now previousValue.$acknowledged matches what was actually emitted before:
`false` when ack and settle coincide, `true` when the row was acknowledged
first. The emission-contract tests assert both. Full @tanstack/db suite
passes (2464).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdrWSXNfp9tfYMWrjVxvo6
Lock in how many change events a consumer sees as a row moves through its
lifecycle:

- When ack and settle coincide (a collection that never calls acknowledge()),
  the pending -> synced transition flips $acknowledged and $synced together
  in a single combined update event — not two.
- When acknowledge() runs before the sync echo, the consumer sees two
  distinct updates at two different times: first a virtual-prop-only update
  flipping $acknowledged (with $synced still false), then a second update at
  settle flipping $synced.

This documents that adding $acknowledged to the emission diff never
double-emits for a single flip; extra events appear only when there are
genuinely two flips at two moments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdrWSXNfp9tfYMWrjVxvo6
Replace the (removed) standalone proposal doc with user-facing
documentation wired into the existing guides:

- live-queries.md: add $acknowledged to the virtual-properties list.
- mutations.md: add a "Reacting to acknowledgement vs. sync" section under
  the transaction lifecycle, covering tx.isAcknowledged and $acknowledged
  and how they relate to isPersisted / $synced.

No docs/config.json change needed — these are edits to existing pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdrWSXNfp9tfYMWrjVxvo6
…Props

The acknowledged optimistic state work added a $acknowledged virtual prop
that now appears on settled rows. The persisted.test.ts helper stripped
the other virtual props but not this one, so 10 toEqual assertions failed
on the extra field. Add $acknowledged to the destructuring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix tab characters and JSDoc indentation in comments that were introduced
without prettier running locally. Whitespace-only; no code or wording change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdrWSXNfp9tfYMWrjVxvo6
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47368e42-0b21-4d02-897f-b7a9446db9b9

📥 Commits

Reviewing files that changed from the base of the PR and between a615c3c and 447080d.

📒 Files selected for processing (2)
  • packages/db/src/collection/state.ts
  • packages/db/tests/transaction-acknowledge.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/db/src/collection/state.ts

📝 Walkthrough

Walkthrough

This PR adds an acknowledged stage to optimistic mutations, introduces Transaction.acknowledge()/isAcknowledged, propagates $acknowledged through virtual props and group-by output, and updates tests and docs to cover the new lifecycle.

Changes

Acknowledged Optimistic State

Layer / File(s) Summary
Transaction acknowledge lifecycle
packages/db/src/transactions.ts
Adds isAcknowledged and acknowledged, plus acknowledge(), rollback rejection, and commit-path resolution.
Virtual props $acknowledged contract
packages/db/src/virtual-props.ts
Extends the virtual-row contract and helpers to create, enrich, and aggregate $acknowledged.
Collection state manager wiring
packages/db/src/collection/state.ts
Tracks acknowledged rows, updates virtual-prop caching and enrichment, emits ack-only updates, and clears the new state on cleanup.
Group-by acknowledged aggregation
packages/db/src/query/compiler/group-by.ts
Carries $acknowledged through row metadata and grouped result rows.
Acknowledge behavior tests and test utilities
packages/db/tests/transaction-acknowledge.test.ts, packages/db/tests/utils.ts, packages/db/tests/collection-subscribe-changes.test.ts, packages/db-sqlite-persistence-core/tests/persisted.test.ts
Adds acknowledgement lifecycle tests and updates virtual-prop stripping/expectations.
Documentation and changeset
.changeset/acknowledged-optimistic-state.md, docs/guides/live-queries.md, docs/guides/mutations.md
Documents the new acknowledged state and how it relates to sync.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Suggested reviewers: KyleAMathews

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: exposing an acknowledged state for optimistic mutations.
Description check ✅ Passed The description follows the template and includes the change summary, checklist, and release impact section with substantive details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/db/src/collection/state.ts (1)

533-600: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Snapshot previous acknowledgement before rebuilding it.

recomputeOptimisticState() snapshots previous values/deletes/origins, then clears acknowledgedKeys before collectOptimisticChanges() computes previousVirtualProps. A rollback or overwrite after an acknowledged optimistic row can therefore emit previousValue.$acknowledged: false even though subscribers previously saw true.

Proposed fix
     const previousState = new Map(this.optimisticUpserts)
     const previousDeletes = new Set(this.optimisticDeletes)
     const previousRowOrigins = new Map(this.rowOrigins)
+    const previousAcknowledgedKeys = new Set(this.acknowledgedKeys)
@@
       const previousVirtualProps = this.getVirtualPropsSnapshotForState(key, {
         rowOrigins: previousRowOrigins,
         optimisticUpserts: previousUpserts,
         optimisticDeletes: previousDeletes,
+        acknowledgedKeys: previousAcknowledgedKeys,
       })

Also applies to: 799-803

🤖 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 533 - 600,
`recomputeOptimisticState()` is clearing `acknowledgedKeys` too early, so
`collectOptimisticChanges()` can no longer preserve the prior acknowledged
status when building `previousVirtualProps`. Snapshot the current acknowledged
state before the rebuild, then use that snapshot while computing
rollback/overwrite diffs so `previousValue.$acknowledged` stays accurate for
rows previously marked acknowledged. Make the fix in
`recomputeOptimisticState()` and ensure the same preserved state is used by
`collectOptimisticChanges()`.
🧹 Nitpick comments (2)
packages/db/tests/transaction-acknowledge.test.ts (1)

1-17: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider consolidating shared test helpers.

waitForChanges and createGate are generic async test utilities that duplicate patterns likely already present in tests/utils.ts (which already exports shared helpers like stripVirtualProps/omitVirtualProps used across multiple test files). Moving these into tests/utils.ts would avoid re-implementing them if other acknowledge/settle-timing tests are added later.

🤖 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/tests/transaction-acknowledge.test.ts` around lines 1 - 17, The
test file defines generic async helpers that should be shared instead of
duplicated. Move waitForChanges and createGate into the existing tests/utils.ts
alongside other reusable helpers like stripVirtualProps and omitVirtualProps,
then import them here so acknowledge/settle-timing tests can reuse the same
utilities. Keep the current test behavior unchanged while centralizing these
helpers under their existing utility names.
packages/db/tests/utils.ts (1)

17-47: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated stripVirtualProps logic across packages risks drift.

The identical destructuring in stripVirtualProps/omitVirtualProps here is duplicated verbatim in packages/db-sqlite-persistence-core/tests/persisted.test.ts (and similarly in collection-subscribe-changes.test.ts per cross-file evidence). This PR had to update all of these copies in lockstep to add $acknowledged; a shared exported helper (even a small internal test-utils package, or exporting VIRTUAL_PROP_NAMES from virtual-props.ts for these helpers to iterate over) would prevent silent drift the next time a virtual prop is added. As per coding guidelines, "Extract common logic into utility functions when identical or near-identical code blocks appear in multiple places."

🤖 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/tests/utils.ts` around lines 17 - 47, The virtual-prop stripping
logic is duplicated in these test helpers and other test files, which can drift
when new virtual properties are added. Refactor stripVirtualProps and
omitVirtualProps to share a single source of truth, ideally by iterating over a
shared exported VIRTUAL_PROP_NAMES list or by extracting a small reusable test
utility. Update the helpers to use that shared symbol instead of repeating the
destructuring so any future virtual prop change only needs one edit.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/guides/mutations.md`:
- Around line 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.

In `@packages/db/src/collection/state.ts`:
- Around line 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.

In `@packages/db/src/transactions.ts`:
- Around line 565-569: The commit settling path in `Transaction` updates
`acknowledged` and resolves `isAcknowledged`, but it bypasses the same
collection notification flow used by `acknowledge()`. Update the settling logic
in `transactions.ts` so auto-acknowledgement also triggers
`onTransactionAcknowledged()` (or the shared helper it uses) when
`isAcknowledged.isPending()` flips to true, keeping subscriber `$acknowledged`
updates consistent with the manual acknowledge path.

---

Outside diff comments:
In `@packages/db/src/collection/state.ts`:
- Around line 533-600: `recomputeOptimisticState()` is clearing
`acknowledgedKeys` too early, so `collectOptimisticChanges()` can no longer
preserve the prior acknowledged status when building `previousVirtualProps`.
Snapshot the current acknowledged state before the rebuild, then use that
snapshot while computing rollback/overwrite diffs so
`previousValue.$acknowledged` stays accurate for rows previously marked
acknowledged. Make the fix in `recomputeOptimisticState()` and ensure the same
preserved state is used by `collectOptimisticChanges()`.

---

Nitpick comments:
In `@packages/db/tests/transaction-acknowledge.test.ts`:
- Around line 1-17: The test file defines generic async helpers that should be
shared instead of duplicated. Move waitForChanges and createGate into the
existing tests/utils.ts alongside other reusable helpers like stripVirtualProps
and omitVirtualProps, then import them here so acknowledge/settle-timing tests
can reuse the same utilities. Keep the current test behavior unchanged while
centralizing these helpers under their existing utility names.

In `@packages/db/tests/utils.ts`:
- Around line 17-47: The virtual-prop stripping logic is duplicated in these
test helpers and other test files, which can drift when new virtual properties
are added. Refactor stripVirtualProps and omitVirtualProps to share a single
source of truth, ideally by iterating over a shared exported VIRTUAL_PROP_NAMES
list or by extracting a small reusable test utility. Update the helpers to use
that shared symbol instead of repeating the destructuring so any future virtual
prop change only needs one edit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d6290af2-4de0-499a-9e76-3fb402bdd338

📥 Commits

Reviewing files that changed from the base of the PR and between f7da776 and d3e0d35.

📒 Files selected for processing (11)
  • .changeset/acknowledged-optimistic-state.md
  • docs/guides/live-queries.md
  • docs/guides/mutations.md
  • packages/db-sqlite-persistence-core/tests/persisted.test.ts
  • packages/db/src/collection/state.ts
  • packages/db/src/query/compiler/group-by.ts
  • packages/db/src/transactions.ts
  • packages/db/src/virtual-props.ts
  • packages/db/tests/collection-subscribe-changes.test.ts
  • packages/db/tests/transaction-acknowledge.test.ts
  • packages/db/tests/utils.ts

Comment thread docs/guides/mutations.md
Comment thread packages/db/src/collection/state.ts
Comment thread packages/db/src/transactions.ts
mhsnook and others added 3 commits July 2, 2026 19:21
Add a JSDoc to the group-by virtual-prop aggregation helper (the one
function the acknowledged-state change touched that lacked one), covering
the all-synced / all-acknowledged / any-local fold and the $acknowledged→
$synced fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdrWSXNfp9tfYMWrjVxvo6
Acknowledgment was tracked purely by key, so a newer, still-unacknowledged
optimistic write could inherit an older transaction's acknowledged state and
report $acknowledged: true even though the visible row came from the newer
write.

Ack now follows the last visible covering mutation for a key:

- recomputeOptimisticState clears a key's acknowledged flag when the covering
  (last-applied) active transaction is unacknowledged, instead of only ever
  adding it.
- isRowAcknowledged consults the active transaction currently supplying the
  row and returns its acknowledged flag, only falling back to the completed
  overlay / acknowledgedKeys when no active optimistic mutation covers the key.
- onTransactionAcknowledged skips keys now covered by a newer optimistic write
  so an older ack can no longer flip them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HVCjqvqFpBvJcAqJe8SVi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants