Skip to content

RFC: Fixing the loadSubset / pagination core (no new features) #1657

Description

@KyleAMathews

Status: draft · Date: 2026-07-08

This RFC merges two analyses of the pagination/partitioning/loadSubset issue cluster — an internal codebase survey and an external architectural review — into one problem statement and a concrete PR plan. Scope is deliberately limited to bug fixes, internal consolidation, testing, and documentation of existing behavior. No new user-facing features. Feature proposals surfaced by the analyses (stable pagination, cursor metadata) are recorded in §6 as out of scope. Every load-bearing factual claim below was verified against origin/main (a93abf6), several via red/green test runs.

1. Problem statement

TanStack DB's partial-sync surface grew feature-by-feature: loadSubset began as "trigger a fetch", then accumulated predicates, orderBy/limit hints, cursors, offsets, dedup, truncate-replay, and join key requests. The result is that five concepts exist only implicitly, encoded across booleans, promises, arrays, and adapter-specific conventions:

  1. Demand — what a live query needs (predicate + order + window), as distinct from what we chose to fetch.
  2. Coverage — which regions of remote data are locally materialized, who owns them, and when they can be released.
  3. Order — one total order that the local index, the top-K operator, cursor generation, and SSE admission all agree on.
  4. Result — whether a load exhausted its source, and the region it is authoritative for.
  5. Lifecycle — readiness, degradation, error, and restart as explicit states rather than emergent promise behavior.

Because these are implicit, each bug fix re-derives a fragment of them, and each adapter improvises its own semantics. Making them explicit internally is how we stop the recurring bug classes — without changing what the library does for users.

2. Verified evidence (what's actually true on main today)

# Claim Verification Result
E1 Out-of-window SSE inserts corrupt the ordered window (PR #1555) PR's regression test run against baseline RED confirmed (out-of-window item promoted on delete); GREEN with fix
E2 Multi-column orderBy boundary drops ties (PR #1556) PR's regression tests against baseline RED confirmed (3/8 fail, incl. wrong item at limit(1)); GREEN with fix
E3 Computed orderBy alias leaks bad pushdown hints (PR #1520) PR's regression test against baseline No repro — obviated by main's evolved select/ref handling. PR is stale
E4 loadedSubsets/join-key unbounded growth (PR #1554) PR's regression tests against baseline No repro — superseded by subset-dedupe.ts. PR is stale
E5 Async fetch errors never reach status: 'error' (#1260) Temp red test: throwing queryFn, retry: false RED confirmed: collection.status === 'ready' after a failed first fetch; existing test query.test.ts:3389 codifies ready-on-error. The error-handling guide documents error status working — this is a doc/behavior contradiction
E6 Predicate identity is non-canonical JSON tsx script against real IR Confirmed: and(A,B) vs and(B,A) and eq(x,1) vs eq(1,x) produce different query keys → two observers, two fetches. query-db has no structural dedup backstop. extractComparisonField assumes ref-first
E7 TrailBase 403 on subscribe('*') → stuck loading forever (#1521) Code read Confirmed: await subscribe('*') (trailbase.ts:297) sits outside the try/finally holding the only markReady() (:317); start() fired without .catch() (:343). Affects on-demand mode equally
E8 getNextPageParam is a silent runtime no-op Code read Confirmed (declared @deprecated, never invoked)
E9 LoadSubsetFn returns true | Promise<void> — no result signal types.ts:316 + grep Confirmed — root of #968: useLiveInfiniteQuery infers hasNextPage purely from locally-materialized rows, so it stalls when the window under-fills even though the source has more data
E10 Subset promise rejections swallowed (.finally() / .catch(()=>{})) subscription.ts:298-311, 209-210; sync.ts:445-475 Confirmed
E11 Electric/TrailBase provide no unloadSubset; dedup tracking grows until truncate electric.ts return block; trailbase.ts:350-356 Confirmed
E12 Electric progressive resume truncated persisted rows (#1493); PowerSync flushed before diff trigger (#1585); Electric refresh wait unbounded (#1575) Code read of baselines each PR modifies Confirmed as described

Two meta-lessons from verification:

  • Open PRs are not a reliable bug inventory. Two of four "core window bugs" were already fixed by parallel work. Both analyses initially over-trusted the PR queue.
  • E4's staleness strengthens the consolidation argument: what obviated fix(db): deduplicate loadedSubsets and join key requests #1554 was precisely a structural dedup layer replacing point checks — the pattern this RFC generalizes internally.

3. Fixes and hardening

3.1 Restore documented error behavior (fixes #1260, E5, E10)

The error-handling guide already documents that collections report error status; the implementation contradicts it. This is a bug fix, not new API:

  • A failed initial load with nothing materialized transitions the collection to the existing error status (the docs' promise).
  • A failed incremental subset load on a ready collection keeps data available (today's intent) but stops being silent: the subscription records the failure, setWindow's promise rejects instead of resolving indistinguishably, and the live query surfaces the last subset error instead of console.error-and-forget.
  • Every .finally()-only / .catch(() => {}) site in subscription.ts and sync.ts is replaced with explicit success/failure handling. Errors scope to the subset that failed — one bad subset must not poison unrelated queries on the same collection.

3.2 Fix infinite-query stalling with a minimal exhaustion signal (fixes #968, E8, E9)

#968 cannot be fixed without some way for the sync layer to say "the source is exhausted for this scope" — today hasNextPage is inferred purely from local rows. The minimal change:

// before: (options: LoadSubsetOptions) => true | Promise<void>
// after:
type LoadSubsetFn = (
  options: LoadSubsetOptions,
) => true | Promise<void | { hasMore?: boolean }>

void resolution keeps today's behavior exactly (peek-ahead fallback), so every existing adapter continues to work unchanged. Adapters that already know exhaustion (query-db when the queryFn returns fewer rows than requested; TrailBase's paged load) report it; useLiveInfiniteQuery uses it to stop stalling and to skip the wasteful +1 peek fetch when known. This is the smallest possible repair of broken behavior — cursors, totalCount, checkpoints, and page metadata APIs are out of scope (§6). Both analyses independently rejected #863's mutable session-keyed side channel; if metadata ever grows beyond hasMore, it must travel with the result, not through collection-level mutable state.

getNextPageParam (E8): remove the dead option (or hard-deprecate with a runtime warning) and fix the doc examples that still showcase it. No replacement is introduced.

3.3 One TotalOrder, one WindowState (fixes the E1/E2 bug class)

The verified bugs E1 and E2 are both order-disagreement bugs: the BTree pages by (first_col, key) while D2 ranks by the full comparator; SSE admission filtered by a different boundary than the window's. Fix the class, not the instances — an internal refactor with zero API change:

  • TotalOrder: compiled order terms + explicit null ordering + an always-appended primary-key tie-breaker. The same comparator drives local index bounds, top-K maintenance, cursor predicate generation (whereFrom/whereCurrent), SSE insert admission, and boundary refill. Boundary = complete sort tuple, not first-column-plus-expansion special cases.
  • WindowState: one module owning window membership (sentKeys), boundary tuple, candidate admission, cursor computation, and load-request dedup — extracted from the fragments in subscription.ts, collection-subscriber.ts, live/utils.ts.
  • Property harness (fast-check, already a devDep): random multi-column orderBy specs × random interleavings of {insert, delete, sort-key update, loadNextItems response, truncate, setWindow}; invariant: materialized window ≡ top-K of a naive full sort. E1 and E2 both fall out of this harness; future variants will too.

3.4 Coverage registry, internal only (fixes #836, E11)

Consolidate today's scattered tracking (DeduplicatedLoadSubset state, per-subscription loadedSubsets, query-db's row/query maps and refcounts) into one internal registry where coverage entries — not rows, not queries — are the owned, refcounted resources. Queries lease coverage; the data provider's lifetime is tied to the entry's refcount, not to whichever query fetched first — this is the structural answer to #836's stale-dedup problem. unloadSubset already exists in the public contract; this makes it actually work uniformly:

  • Electric/TrailBase get bounded tracking: last-unload removes the predicate from dedup state (recompute via existing unionWherePredicates/minusWherePredicates) even if rows are retained until truncate, fixing the accumulate-forever behavior (E11) without changing observable row semantics.
  • Antichain, not append-only history: keep only maximal coverage under the subset relation.
  • Bounded algebra: exact subsumption only for key sets, scalar intervals, ordered prefixes; conservatively refetch otherwise. A redundant request is preferable to a false claim of coverage — already isPredicateSubset's philosophy.
  • Destructive diffs scoped to their coverage: a load may only delete rows previously claimed by its coverage entry — the fix for the warm-start class where one empty disjoint query wipes another query's hydrated rows.
  • v1 is a strangler: wrap existing DeduplicatedLoadSubset semantics behind the registry interface; migrate Electric and query-db first. No public API changes.

3.5 Canonical predicate identity (fixes E6)

Small and immediate, independent of the registry: canonicalize before serializing (sort commutative and/or operands structurally, normalize ref op val argument order), fix extractComparisonField's ref-first assumption, and give query-db-collection the structural dedup backstop it currently lacks. Today and(A,B) vs and(B,A) literally double-fetches.

3.6 Lifecycle hardening (fixes the E7/E12 class)

3.7 Tests and docs for what already exists

  • Conformance suite: one shared test suite run against core + every adapter, generated traces over overlapping predicates, multi-column order with duplicates/nulls, growing windows, failed/hanging loads, cleanup/restart/GC, late completions. Invariants: visible rows ≡ reference result when coverage complete; old-epoch completions never mutate current state; every promise settles exactly once; fresh covered demand issues no request; tracking stays bounded. Land chore: Rewrite the tests for the loadSubset to cover more clauses and operators #1426's expansion as part of this.
  • Warnings, not silence: where an adapter can't honor a pushdown (TrailBase drops composite where; PowerSync ignores orderBy/limit), warn specifically and once. This uses a small internal capability flag set — not a public API.
  • Docs: Electric and TrailBase syncMode documentation (currently zero mention despite shipped behavior), plus a cross-cutting "Partial sync" guide documenting the existing LoadSubsetOptions contract, dedup behavior, unload semantics, and the per-adapter differences table. Documenting existing behavior is in scope; the guide is also what makes the contract reviewable.

4. PR plan

Phase 0 — queue hygiene (days, no design dependencies)

Phase 1 — contract fixes (small, independent)

Phase 2 — internal consolidation

Phase 3 — tests and docs

  • PR-7: conformance suite + degradation warnings per §3.7.
  • PR-8: docs — Electric & TrailBase syncMode documentation + "Partial sync" guide.

Each phase is independently shippable; nothing is a flag-day rewrite.

5. Adjudicated positions (external review vs. this RFC)

Topic External position This RFC
Pagination metadata rich result envelope, not side channel Minimal hasMore only (bug fix for #968); richer envelope out of scope, but if ever added it rides the result
Coverage ownership coverage leases, not per-query refcounts Agree, internal-only, strangler over existing dedup
Demand/plan split w/ branded types, freezing, interning full split up front Principle yes (post-#1348 code already does trackingOptions/loadOptions); type ceremony not adopted
stop()/reset()/dispose() rename do it Not adopted — epochs + settled barriers deliver the fix without breaking API
Multidimensional public status lifecycle × availability × freshness × fetchStatus Not adopted as public API — restore documented error behavior + expose last subset error; nothing more
Runtime abstraction inject now Parked pending upstream TanStack Query (#1250)
Partitions planning strategy, not child collections Moot#315 was superseded by the includes work; not pursued
Sequencing architecture-first Point fixes first — two of their cited PRs were already stale; merge the verified ones, refactor behind their tests

6. Explicitly out of scope (recorded, not planned)

These came out of the analyses and are worth keeping on file, but they are features and this effort does not introduce features:

  1. Stable pagination / deferred updates (Support Stable Pagination and Deferred Update Handling for Large Query Results #21) — correctly decomposes into a consistency axis (live/anchored/snapshot, capability-negotiated) × a presentation axis (immediate/deferred + hasPendingChanges). Requires the result channel to carry snapshot tokens; revisit after §3.2 ships.
  2. Cursor/page metadata APIs (Add collection-level pagination metadata support to avoid wasteful peek-ahead pattern #863 beyond hasMore) — endCursor/totalCount/pageInfo surfaces, cursor-mode useLiveInfiniteQuery.
  3. Public adapter capability negotiation and explainLoading() devtools — the internal warning flags (§3.7) are the no-feature version; a public planner/explain surface is future work.

(Partitioned collections, #315, previously appeared here — it was superseded by the includes work and is dropped from this analysis entirely.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions