TanStack / TanStack/db

Feature: effect cursor / startAfter for durable replay

Open
#1,394 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
3.9k
Forks
266
Avg merge
1d 4h
Merged PRs (30d)
55

Description

Use Case

We're building a tool where entities wake, run effects, and go idle. On re-wake, the runtime replays effects from persisted manifest entries. The problem: replayed effects fire onEnter for all existing rows that match the query, including rows that were already processed on a previous wake.

The scenario
  1. Entity wakes, creates an effect watching a child entity's runs collection
  2. Effect's onEnter fires when a child's run completes → sends a "child-done" message
  3. Entity goes idle
  4. Entity re-wakes (new message arrives)
  5. Runtime replays the effect from the manifest
  6. Effect fires onEnter again for the already-completed run → duplicate "child-done" message
What we need

A way to tell an effect: "only fire callbacks for mutations that happen after this point." Something like:

createEffect({
  query: (q) => q.from({ runs: childDb.collections.runs }),
  startAfter: cursor, // <-- "skip everything before this point"
  onEnter: (event) => {
    // Only fires for new completions, not ones from previous wakes
  },
})
Current workarounds
  • skipInitial: true — too aggressive. Skips ALL initial data, including events that arrived while the entity was idle (which are exactly the ones we need to process).
  • Making every effect handler idempotent (check state before acting) — works but pushes complexity to every effect author. We'd prefer the runtime handles this.

The Challenge

TanStack DB doesn't have a global offset/sequence number on collection mutations. Effects work on collection state (rows matching a query), not on an event log. So "start after offset X" doesn't have a natural mapping.

Possible approaches
  1. Row-level versioning: Each row gets a monotonic version number on mutation. Effects with startAfter skip rows whose version is ≤ the cursor. New mutations (inserts, updates, deletes) after the cursor fire normally.

  2. Subscription-level cursor: The effect subscription could be configured to treat the current snapshot as "already seen" and only fire callbacks for future changes. This is similar to skipInitial but more precise — it skips the specific rows that exist at creation time, not all initial load data.

  3. Collection-level epoch: Collections track a mutation counter. startAfter: epoch means "fire onEnter/onExit only for mutations with epoch > startAfter." The runtime would store the epoch when the entity goes idle and pass it back when replaying effects.

  4. External filtering in onEnter: The caller filters in the callback (our current workaround). Not ideal but works.

What we're doing with this

Effects are TanStack DB reactive queries that watch entity collections (which are projections of the stream). The entity lifecycle is:

  • Wake → preload stream into collections → create/replay effects → process messages → go idle
  • On re-wake, effects must distinguish "data that was there last time" from "data that arrived while I was asleep"

We're happy to contribute an implementation if there's alignment on the approach. The existing cursor machinery in live/utils.ts (computeOrderedLoadCursor, requestLimitedSnapshot with minValues) looks like it could be extended, but we'd want guidance on the right direction.

Environment

  • @tanstack/db version: 0.5.33
  • Runtime: Node.js / Deno
  • Context: Server-side durable execution, not browser

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading live/utils.ts, especially computeOrderedLoadCursor and requestLimitedSnapshot with minValues, then trace how effects are replayed across the entity lifecycle. The issue needs an agreed cursor model and implementation scope that distinguishes rows present before a wake from mutations arriving while the entity was idle.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend, distributed-systems
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.