You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Demand — what a live query needs (predicate + order + window), as distinct from what we chose to fetch.
Coverage — which regions of remote data are locally materialized, who owns them, and when they can be released.
Order — one total order that the local index, the top-K operator, cursor generation, and SSE admission all agree on.
Result — whether a load exhausted its source, and the region it is authoritative for.
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
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
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.
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 existingerror 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:
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)
Epoch counter on collection sync sessions (generalizing subset-dedupe.ts's existing generation); every async completion checks its epoch; every readiness barrier settles exactly once; a preload() issued in any state attaches to a barrier that cleanup/stop must settle — closing the preload-after-cleanup hang class (preload after cleanup on a collection hangs forever #1576) structurally.
No renames, no new states: the existing status enum stays as-is.
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)
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:
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.
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.)
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:
loadSubsetbegan 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: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)
limit(1)); GREEN with fixloadedSubsets/join-key unbounded growth (PR #1554)subset-dedupe.ts. PR is stalestatus: 'error'(#1260)queryFn,retry: falsecollection.status === 'ready'after a failed first fetch; existing testquery.test.ts:3389codifies ready-on-error. The error-handling guide documents error status working — this is a doc/behavior contradictionand(A,B)vsand(B,A)andeq(x,1)vseq(1,x)produce different query keys → two observers, two fetches. query-db has no structural dedup backstop.extractComparisonFieldassumes ref-firstsubscribe('*')→ stuck loading forever (#1521)await subscribe('*')(trailbase.ts:297) sits outside the try/finally holding the onlymarkReady()(:317);start()fired without.catch()(:343). Affects on-demand mode equallygetNextPageParamis a silent runtime no-op@deprecated, never invoked)LoadSubsetFnreturnstrue | Promise<void>— no result signaluseLiveInfiniteQueryinfershasNextPagepurely from locally-materialized rows, so it stalls when the window under-fills even though the source has more data.finally()/.catch(()=>{}))unloadSubset; dedup tracking grows until truncateTwo meta-lessons from verification:
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:
errorstatus (the docs' promise).setWindow's promise rejects instead of resolving indistinguishably, and the live query surfaces the last subset error instead ofconsole.error-and-forget..finally()-only /.catch(() => {})site insubscription.tsandsync.tsis 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
hasNextPageis inferred purely from local rows. The minimal change:voidresolution keeps today's behavior exactly (peek-ahead fallback), so every existing adapter continues to work unchanged. Adapters that already know exhaustion (query-db when thequeryFnreturns fewer rows than requested; TrailBase's pagedload) report it;useLiveInfiniteQueryuses it to stop stalling and to skip the wasteful+1peek 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 beyondhasMore, 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 insubscription.ts,collection-subscriber.ts,live/utils.ts.3.4 Coverage registry, internal only (fixes #836, E11)
Consolidate today's scattered tracking (
DeduplicatedLoadSubsetstate, per-subscriptionloadedSubsets, 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.unloadSubsetalready exists in the public contract; this makes it actually work uniformly:unionWherePredicates/minusWherePredicates) even if rows are retained until truncate, fixing the accumulate-forever behavior (E11) without changing observable row semantics.isPredicateSubset's philosophy.DeduplicatedLoadSubsetsemantics 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/oroperands structurally, normalizeref op valargument order), fixextractComparisonField's ref-first assumption, and give query-db-collection the structural dedup backstop it currently lacks. Todayand(A,B)vsand(B,A)literally double-fetches.3.6 Lifecycle hardening (fixes the E7/E12 class)
subset-dedupe.ts's existinggeneration); every async completion checks its epoch; every readiness barrier settles exactly once; apreload()issued in any state attaches to a barrier that cleanup/stop must settle — closing the preload-after-cleanup hang class (preloadaftercleanupon a collection hangs forever #1576) structurally.markReady()reachable on every path (TrailBase E7), tracking/trigger existence guaranteed before flushes (PowerSync fix(powersync): Don't flush records when diff trigger isn't setup yet when usingon-demandmode. #1585), resume state distinguished from initial sync (Electric fix(electric-db-collection): preserve persisted rows on progressive resume #1493), and waits bounded (fix(electric): bound refresh wait for on-demand subsets #1575).3.7 Tests and docs for what already exists
where; PowerSync ignores orderBy/limit), warn specifically and once. This uses a small internal capability flag set — not a public API.syncModedocumentation (currently zero mention despite shipped behavior), plus a cross-cutting "Partial sync" guide documenting the existingLoadSubsetOptionscontract, 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)
on-demandmode. #1585, fix(query-db-collection): cancel idle on-demand queries #1573; land chore: Rewrite the tests for the loadSubset to cover more clauses and operators #1426's test expansion.Phase 1 — contract fixes (small, independent)
hasMoreexhaustion signal +getNextPageParamremoval/deprecation per §3.2. Fixes useLiveInfiniteQuery doesn't fetch more data when collection has no more items #968, eliminates the wasteful peek fetch where exhaustion is known.Phase 2 — internal consolidation
TotalOrder+WindowStateextraction + fast-check property harness per §3.3. After Phase 0, so the harness inherits fix(db): filter out-of-window SSE inserts in collection-subscriber #1555/fix(db): boundary expansion for multi-column orderBy pagination #1556's tests as fixed points.unloadSubsetacross adapters per §3.4. Fixes SupportloadSubsetdeduplication in query collection #836.Phase 3 — tests and docs
Each phase is independently shippable; nothing is a flag-day rewrite.
5. Adjudicated positions (external review vs. this RFC)
hasMoreonly (bug fix for #968); richer envelope out of scope, but if ever added it rides the resultstop()/reset()/dispose()renameerrorbehavior + expose last subset error; nothing more6. 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:
hasPendingChanges). Requires the result channel to carry snapshot tokens; revisit after §3.2 ships.hasMore) — endCursor/totalCount/pageInfo surfaces, cursor-modeuseLiveInfiniteQuery.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.)