Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
774f4a3
test(db): add loadSubset and pagination oracles
KyleAMathews Aug 18, 2026
f6aa713
test(db): tighten loadSubset oracle boundaries
KyleAMathews Aug 19, 2026
fc2f4f0
test(db): close loadSubset oracle gaps
KyleAMathews Aug 19, 2026
7c9734c
test(db): finish loadSubset review coverage
KyleAMathews Aug 19, 2026
a1696dd
test(db): tighten loadSubset coverage oracle
KyleAMathews Aug 19, 2026
360d821
fix(db): propagate initial query errors
KyleAMathews Aug 19, 2026
58ceb98
chore: add error propagation changeset
KyleAMathews Aug 19, 2026
3ab8fb7
test(db): exclude empty distinct windows
KyleAMathews Aug 20, 2026
7798883
test(db): address oracle review findings
KyleAMathews Aug 20, 2026
7f34c1a
test(db): close oracle review gaps
KyleAMathews Aug 20, 2026
3846fe0
Merge branch 'main' of https://github.com/TanStack/db into codex/load…
tannerlinsley Aug 20, 2026
973cb72
test: account for load subset abort signal
tannerlinsley Aug 20, 2026
011fe2a
test(db): tighten loadSubset oracle boundaries
KyleAMathews Aug 20, 2026
7b739a4
Merge remote-tracking branch 'origin/codex/loadsubset-pagination-orac…
KyleAMathews Aug 20, 2026
551ed3c
Merge remote-tracking branch 'origin/codex/loadsubset-pagination-orac…
KyleAMathews Aug 21, 2026
fa9d2f9
test: align includes preload error expectation
tannerlinsley Aug 21, 2026
5c31272
test(db): harden loadSubset oracle boundaries
KyleAMathews Aug 21, 2026
68f5b57
fix(db): harden initial sync error lifecycles
KyleAMathews Aug 21, 2026
6c92e03
Merge remote-tracking branch 'origin/codex/loadsubset-error-propagati…
KyleAMathews Aug 21, 2026
390f5ca
fix(db): report incremental subset errors
KyleAMathews Aug 21, 2026
37d7589
chore: add incremental subset error changeset
KyleAMathews Aug 21, 2026
1e48f9a
Merge remote-tracking branch 'origin/main' into codex/loadsubset-incr…
tannerlinsley Aug 21, 2026
4a26e80
fix(db): harden subset error lifecycles
KyleAMathews Aug 21, 2026
fb20cd9
Merge remote-tracking branch 'origin/codex/loadsubset-incremental-err…
KyleAMathews Aug 21, 2026
7c75b0c
fix(db): clean up failed subset operations
KyleAMathews Aug 21, 2026
d764a3d
fix(db): harden subset failure cleanup
KyleAMathews Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/report-incremental-subset-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Report incremental subset-load failures through subscriptions, live-query utilities, and effects while keeping cached source rows available.
36 changes: 36 additions & 0 deletions docs/guides/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,42 @@ Error tracking methods:
- **`errorCount`**: Returns the number of consecutive sync failures. This counter is incremented only when queries fail completely (not per retry attempt) and is reset on successful queries:
- **`clearError()`**: Clears the error state and triggers a refetch of the query. This method resets both `lastError` and `errorCount`:

## Incremental Subset Load Errors

An incremental `loadSubset` failure does not discard rows that are already
available or put the shared source collection into `error`. The failure belongs
to the subscription that requested that subset:

```ts
const subscription = todoCollection.subscribeChanges(handleChanges, {
includeInitialState: false,
})

subscription.on('loadSubset:error', ({ error, options }) => {
console.error('Subset failed', options, error)
})

subscription.requestSnapshot()

// The most recent failure remains available for diagnostics.
console.log(subscription.lastError)
```

For ordered live queries, `utils.setWindow()` rejects with the same error. The
last failure is also available as `utils.lastSubsetError`, while the last
successful snapshot remains readable:

```ts
try {
await liveTodos.utils.setWindow({ offset: 0, limit: 100 })
} catch (error) {
console.error(liveTodos.utils.lastSubsetError)
}
```

Effects report subset failures through `onSourceError` and dispose because
their incremental result can no longer be kept complete.

## Collection Status and Error States

Collections track their status and transition between states:
Expand Down
4 changes: 4 additions & 0 deletions packages/db/skills/db-core/custom-adapter/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ return the fetched rows. `parseLoadSubsetOptions()` returns only `filters`,
opaque backend cursor; translate or combine those expressions for your API.
Return `unloadSubset` only when `loadSubset` creates an ongoing resource, such
as a per-subset server subscription, that must be released.
Ownership transfers to core only when `loadSubset` returns `true` or a promise.
If it throws synchronously after partial setup, release that partial resource
before throwing; core will not call `unloadSubset` for a request that never
returned.

### Managing optimistic state duration

Expand Down
98 changes: 61 additions & 37 deletions packages/db/src/collection/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,6 @@ export class CollectionChangesManager<
) => void,
options: SubscribeChangesOptions<TOutput, TKey> = {},
): CollectionSubscription {
// Start sync and track subscriber
this.addSubscriber()

// Compile where callback to whereExpression if provided
if (options.where && options.whereExpression) {
throw new Error(
Expand All @@ -240,37 +237,56 @@ export class CollectionChangesManager<
whereExpression = toExpression(result)
}

const subscription = new CollectionSubscription(this.collection, callback, {
...opts,
whereExpression,
onUnsubscribe: () => {
this.removeSubscriber()
this.changeSubscriptions.delete(subscription)
},
})

// Register status listener BEFORE requesting snapshot to avoid race condition.
// This ensures the listener catches all status transitions, even if the
// loadSubset promise resolves synchronously or very quickly.
if (options.onStatusChange) {
subscription.on(`status:change`, options.onStatusChange)
}
// Acquire ownership only after all fallible option validation and
// user-provided predicate compilation has completed.
this.addSubscriber()

if (options.includeInitialState) {
subscription.requestSnapshot({
trackLoadSubsetPromise: false,
orderBy: options.orderBy,
limit: options.limit,
onLoadSubsetResult: options.onLoadSubsetResult,
let subscription: CollectionSubscription | undefined
try {
subscription = new CollectionSubscription(this.collection, callback, {
...opts,
whereExpression,
onUnsubscribe: () => {
this.removeSubscriber()
if (subscription) this.changeSubscriptions.delete(subscription)
},
})
} else if (options.includeInitialState === false) {
// When explicitly set to false (not just undefined), mark all state as "seen"
// so that all future changes (including deletes) pass through unfiltered.
subscription.markAllStateAsSeen()
}

// Add to batched listeners
this.changeSubscriptions.add(subscription)
// Register status listener BEFORE requesting snapshot to avoid race condition.
// This ensures the listener catches all status transitions, even if the
// loadSubset promise resolves synchronously or very quickly.
if (options.onStatusChange) {
subscription.on(`status:change`, options.onStatusChange)
}

if (options.includeInitialState) {
subscription.requestSnapshot({
trackLoadSubsetPromise: false,
orderBy: options.orderBy,
limit: options.limit,
onLoadSubsetResult: options.onLoadSubsetResult,
})
} else if (options.includeInitialState === false) {
// When explicitly set to false (not just undefined), mark all state as "seen"
// so that all future changes (including deletes) pass through unfiltered.
subscription.markAllStateAsSeen()
}

// Add to batched listeners
this.changeSubscriptions.add(subscription)
} catch (error) {
if (subscription) {
try {
subscription.unsubscribe()
} catch {
// Preserve the setup error. Cleanup still releases subscriber
// ownership and attempts every subset unload before it throws.
}
} else {
this.removeSubscriber()
}
throw error
}

return subscription
}
Expand All @@ -283,12 +299,20 @@ export class CollectionChangesManager<
this.activeSubscribersCount++
this.lifecycle.cancelGCTimer()

// Start sync if collection was cleaned up
if (
this.lifecycle.status === `cleaned-up` ||
this.lifecycle.status === `idle`
) {
this.sync.startSync()
try {
// Start sync if collection was cleaned up
if (
this.lifecycle.status === `cleaned-up` ||
this.lifecycle.status === `idle`
) {
this.sync.startSync()
}
} catch (error) {
this.activeSubscribersCount = previousSubscriberCount
if (this.activeSubscribersCount === 0) {
this.lifecycle.startGCTimer()
}
throw error
}

this.events.emitSubscribersChange(
Expand Down
Loading
Loading