Skip to content

Feature: effect cursor / startAfter for durable replay #1394

@KyleAMathews

Description

@KyleAMathews

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions