Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0bcc429
feat(db): shared live-query observer + migrate all five adapters
kevin-dp Jul 7, 2026
e957121
fix(db): make observer onFirstReady detach-safe
kevin-dp Jul 7, 2026
c05a726
fix(db): address observer/react lifecycle review findings
kevin-dp Jul 9, 2026
fd3c87d
fix(react-db): don't dispose the observer in an unmount effect (Stric…
kevin-dp Jul 9, 2026
7577d40
test(db): cover live-query keyed-state invariant on recompile
kevin-dp Jul 13, 2026
2a0679e
fix(solid-db): clear keyed state before subscribing to a new collection
kevin-dp Jul 13, 2026
593818a
feat(db): republish ordered live queries on an order-only move (RFC #…
kevin-dp Jul 13, 2026
2ea8f1e
ci: apply automated fixes
autofix-ci[bot] Jul 13, 2026
d84fb7b
refactor(db): compare key sequence directly for layoutRevision + fix doc
kevin-dp Jul 13, 2026
d762855
ci: apply automated fixes
autofix-ci[bot] Jul 13, 2026
43f1a7d
test(db): add failing regressions for Kyle's review findings
kevin-dp Jul 15, 2026
c9ec751
fix(db): coalesce layout publications and cover ordered includes chil…
kevin-dp Jul 15, 2026
ff339d8
test(db): guard order-only moves in deeply-nested ordered includes
kevin-dp Jul 15, 2026
4634ea5
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
6db6b75
fix(db): FIFO non-reentrant observer dispatch over subscription records
kevin-dp Jul 20, 2026
7a86eae
fix(db): release the collection subscription on dispose during initia…
kevin-dp Jul 20, 2026
482c82b
fix(db): seed late observer subscribers; reject subscribe after dispose
kevin-dp Jul 20, 2026
d0875e4
fix(db): drive observer snapshots from a collection-owned state revision
kevin-dp Jul 20, 2026
37b5eea
test(angular-db): align the mock collection with the real collection …
kevin-dp Jul 20, 2026
eeafe17
fix(db): publish collection status changes through the canonical path
kevin-dp Jul 20, 2026
4932b73
fix(db): per-consumer initial-state policy; lazy snapshot materializa…
kevin-dp Jul 20, 2026
52b11e9
fix(db): remove deferInitialNotify — event reordering gone by constru…
kevin-dp Jul 20, 2026
5e92926
fix(db): make observer construction inert — sync activates on first s…
kevin-dp Jul 20, 2026
58bd2c1
fix(solid-db): generation-guard the resource's async continuations
kevin-dp Jul 20, 2026
b24d163
docs(db): mark the observer as internal/unstable; honest changeset
kevin-dp Jul 20, 2026
62cb87b
Merge branch 'main' into refactor/live-query-observer
KyleAMathews Jul 20, 2026
650ccb7
fix(db): address live query observer review
tannerlinsley Aug 11, 2026
c4a72f9
fix(db): preserve wholesale consistency reads
tannerlinsley Aug 11, 2026
c91af81
fix(db): capture granular initial loads
tannerlinsley Aug 11, 2026
df9403a
merge: update ordered layout contract for observer refactor
tannerlinsley Aug 11, 2026
619c3a3
fix(db): bind layout revisions to sync transactions
tannerlinsley Aug 11, 2026
da2297b
Merge main into phase4/ordered-layout-contract
tannerlinsley Aug 11, 2026
c27d572
fix(db): address post-merge review feedback
tannerlinsley Aug 11, 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
4 changes: 2 additions & 2 deletions .changeset/live-query-observer.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

Add an internal shared live-query observer and migrate all five framework adapters to it

Introduces `createLiveQueryObserver` in `@tanstack/db`: given a resolved live-query collection (or `null` for a disabled query) it owns the lifecycle every adapter used to re-implement — sync activation on first subscribe, change and status subscriptions, a snapshot with stable identity per state revision for wholesale consumers, and delivery of the raw `ChangeMessage[]` for granular consumers. React, Vue, Svelte, Solid, and Angular's live-query hooks now materialize from the observer instead of their own hand-rolled subscription/status/snapshot machinery, keeping each adapter's native reactivity and each adapter's data-loading policy (wholesale adapters subscribe without initial state; granular adapters seed from it).
Introduces `createLiveQueryObserver` in `@tanstack/db`: given a resolved live-query collection (or `null` for a disabled query) it owns the subscription lifecycle every adapter used to re-implement — change and status subscriptions, a snapshot with stable identity per state revision for wholesale consumers, and delivery of the raw `ChangeMessage[]` for granular consumers. React, Vue, Svelte, Solid, and Angular's live-query hooks now materialize from the observer instead of their own hand-rolled subscription/status/snapshot machinery, keeping each adapter's native reactivity and each adapter's data-loading policy (wholesale adapters subscribe without initial state; granular adapters seed from it).

The observer is an **internal, unstable contract** for TanStack DB's official adapters — it is exported so the adapter packages can consume it, but it is not a public extension point yet and its API may change in any release.

The migration also fixes several live-query lifecycle defects: status-only transitions (`error`, `cleaned-up`) now reach mounted consumers; snapshot identity is stable across unsubscribe/resubscribe and stays fresh while detached; dispatch is FIFO and non-reentrant with subscriptions identified by record rather than callback; disposing during the synchronous initial replay no longer leaks the collection subscription; subscribing after dispose throws instead of registering a dead listener; Solid guards its async resource continuations against superseded collections; and constructing an observer no longer activates sync (activation belongs to the first committed subscription).
The migration also fixes several live-query lifecycle defects: status-only transitions (`error`, `cleaned-up`) now reach mounted consumers; snapshot identity is stable across unsubscribe/resubscribe and stays fresh while detached; dispatch is FIFO and non-reentrant with subscriptions identified by record rather than callback; disposing during the synchronous initial replay no longer leaks the collection subscription; subscribing after dispose throws instead of registering a dead listener; Solid guards its async resource continuations against superseded collections; and constructing an observer no longer activates sync. Observers activate on their first committed subscription unless an adapter has already started a pre-created collection supplied directly or returned from a callback.
13 changes: 13 additions & 0 deletions .changeset/live-query-order-only-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@tanstack/db': patch
---

fix(db): republish ordered live queries on an order-only move

An `orderBy` live query that reordered its rows without changing any projected
row value (an "order-only move") previously emitted nothing, so `useLiveQuery`
kept rendering the stale order. The live-query collection now publishes an
explicit layout-change notification when this happens, and the shared live-query
observer snapshot exposes a `layoutRevision` that increments on any visible
membership, ordering, or order-only-move change. All five framework adapters
pick this up via their existing wholesale re-read.
4 changes: 3 additions & 1 deletion packages/db/src/collection/change-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function createFilteredCallback<
>(
originalCallback: (changes: Array<ChangeMessage<T>>) => void,
options: SubscribeChangesOptions<T, TKey>,
): (changes: Array<ChangeMessage<T>>) => void {
): (changes: Array<ChangeMessage<T>>) => boolean {
const filterFn = createFilterFunctionFromExpression(options.whereExpression!)

return (changes: Array<ChangeMessage<T>>) => {
Expand Down Expand Up @@ -303,7 +303,9 @@ export function createFilteredCallback<
// if the original changes array was empty (which indicates a ready signal)
if (filteredChanges.length > 0 || changes.length === 0) {
originalCallback(filteredChanges)
return true
}
return false
}
}

Expand Down
13 changes: 11 additions & 2 deletions packages/db/src/collection/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export class CollectionChangesManager<
*/
public stateRevision = 0

/**
* Monotonic revision advanced only for explicit layout-only publications.
* This distinguishes them from the legacy empty ready event, since both use
* an empty change batch at the public subscription boundary.
*/
public layoutRevision = 0

/**
* Creates a new CollectionChangesManager instance
*/
Expand Down Expand Up @@ -85,10 +92,12 @@ export class CollectionChangesManager<
public emitEvents(
changes: Array<ChangeMessage<TOutput, TKey>>,
forceEmit = false,
layoutChanged = false,
): void {
// The visible state was already committed by the caller, so the revision
// advances even when the events below end up batched for later emission.
if (changes.length > 0) this.stateRevision++
if (layoutChanged) this.layoutRevision++

// Skip batching for user actions (forceEmit=true) to keep UI responsive
if (this.shouldBatchEvents && !forceEmit) {
Expand All @@ -111,7 +120,7 @@ export class CollectionChangesManager<
this.shouldBatchEvents = false
}

if (rawEvents.length === 0) {
if (rawEvents.length === 0 && !layoutChanged) {
return
}

Expand All @@ -123,7 +132,7 @@ export class CollectionChangesManager<

// Emit to all listeners
for (const subscription of this.changeSubscriptions) {
subscription.emitEvents(enrichedEvents)
subscription.emitEvents(enrichedEvents, layoutChanged)
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,19 @@ export class CollectionImpl<
return this._changes.stateRevision
}

/**
* Monotonic revision of explicit layout-only publications.
* Internal — used to distinguish them from empty ready events.
*/
public get _layoutRevision(): number {
return this._changes.layoutRevision
}

/** Mark the active sync transaction as layout-changing. Internal. */
public _markLayoutChange(): void {
this._sync.markLayoutChange()
}

/**
* Register a callback to be executed when the collection first becomes ready
* Useful for preloading collections
Expand Down
6 changes: 5 additions & 1 deletion packages/db/src/collection/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface PendingSyncedTransaction<
TKey extends string | number = string | number,
> {
committed: boolean
layoutChanged: boolean
operations: Array<OptimisticChangeMessage<T>>
truncate?: boolean
deletedKeys: Set<string | number>
Expand Down Expand Up @@ -836,10 +837,12 @@ export class CollectionStateManager<
uncommittedSyncedTransactions,
hasTruncateSync,
hasImmediateSync,
layoutChanged,
} = this.pendingSyncedTransactions.reduce(
(acc, t) => {
if (t.committed) {
acc.committedSyncedTransactions.push(t)
acc.layoutChanged ||= t.layoutChanged
if (t.truncate) {
acc.hasTruncateSync = true
}
Expand All @@ -860,6 +863,7 @@ export class CollectionStateManager<
>,
hasTruncateSync: false,
hasImmediateSync: false,
layoutChanged: false,
},
)

Expand Down Expand Up @@ -1331,7 +1335,7 @@ export class CollectionStateManager<
}

// End batching and emit all events (combines any batched events with sync events)
this.changes.emitEvents(events, true)
this.changes.emitEvents(events, true, layoutChanged)

this.pendingSyncedTransactions = uncommittedSyncedTransactions

Expand Down
27 changes: 21 additions & 6 deletions packages/db/src/collection/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class CollectionSubscription
// Track the last key sent via requestLimitedSnapshot for cursor-based pagination
private lastSentKey: string | number | undefined

private filteredCallback: (changes: Array<ChangeMessage<any, any>>) => void
private filteredCallback: (changes: Array<ChangeMessage<any, any>>) => boolean

private orderByIndex: IndexInterface<string | number> | undefined

Expand All @@ -99,6 +99,7 @@ export class CollectionSubscription
// This prevents a flash of missing content between deletes and new inserts
private isBufferingForTruncate = false
private truncateBuffer: Array<Array<ChangeMessage<any, any>>> = []
private truncateBufferHasLayoutChange = false
private pendingTruncateRefetches: Set<Promise<void>> = new Set()

public get status(): SubscriptionStatus {
Expand Down Expand Up @@ -132,7 +133,10 @@ export class CollectionSubscription
// Create a filtered callback if where clause is provided
this.filteredCallback = options.whereExpression
? createFilteredCallback(this.callback, options)
: this.callback
: (changes) => {
this.callback(changes)
return true
}

// Listen for truncate events to re-request data after must-refetch
// When a truncate happens (e.g., from a 409 must-refetch), all collection data is cleared.
Expand Down Expand Up @@ -245,11 +249,14 @@ export class CollectionSubscription
// Flatten all buffered changes into a single array for atomic emission
// This ensures consumers see all truncate changes (deletes + inserts) in one callback
const merged = this.truncateBuffer.flat()
if (merged.length > 0) {
this.filteredCallback(merged)
const layoutChanged = this.truncateBufferHasLayoutChange
if (merged.length > 0 || layoutChanged) {
const delivered = this.filteredCallback(merged)
if (layoutChanged && !delivered) this.filteredCallback([])
}

this.truncateBuffer = []
this.truncateBufferHasLayoutChange = false
}

setOrderByIndex(index: IndexInterface<any>) {
Expand Down Expand Up @@ -318,7 +325,10 @@ export class CollectionSubscription
return this.snapshotSent
}

emitEvents(changes: Array<ChangeMessage<any, any>>) {
emitEvents(
changes: Array<ChangeMessage<any, any>>,
layoutChanged = false,
): boolean {
const newChanges = this.filterAndFlipChanges(changes)

if (this.isBufferingForTruncate) {
Expand All @@ -327,8 +337,12 @@ export class CollectionSubscription
if (newChanges.length > 0) {
this.truncateBuffer.push(newChanges)
}
if (layoutChanged) this.truncateBufferHasLayoutChange = true
return false
} else {
this.filteredCallback(newChanges)
const delivered = this.filteredCallback(newChanges)
if (layoutChanged && !delivered) return this.filteredCallback([])
return delivered
}
}

Expand Down Expand Up @@ -728,6 +742,7 @@ export class CollectionSubscription
// Clean up truncate buffer state
this.isBufferingForTruncate = false
this.truncateBuffer = []
this.truncateBufferHasLayoutChange = false
this.pendingTruncateRefetches.clear()

// Unload all subsets that this subscription loaded
Expand Down
6 changes: 6 additions & 0 deletions packages/db/src/collection/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export class CollectionSyncManager<
this._events = deps.events
}

/** Mark the active sync transaction as changing collection layout. */
public markLayoutChange(): void {
this.getActivePendingSyncTransaction().layoutChanged = true
}

/**
* Start the sync process for this collection
* This is called when the collection is first accessed or preloaded
Expand All @@ -92,6 +97,7 @@ export class CollectionSyncManager<
begin: (options?: { immediate?: boolean }) => {
this.state.pendingSyncedTransactions.push({
committed: false,
layoutChanged: false,
operations: [],
deletedKeys: new Set(),
rowMetadataWrites: new Map(),
Expand Down
Loading
Loading