Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
877bc23
feat(db): shared live-query window controller (RFC #1623 phase 5)
kevin-dp Jul 15, 2026
fa26a67
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
ab9b37e
fix(react-db): restore useLiveQuery type-only import for return type
kevin-dp Jul 15, 2026
8d82827
fix(react-db): react to runtime pageSize changes + restore window-mis…
kevin-dp Jul 15, 2026
5d50a0c
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
cab79fb
Merge phase4/ordered-layout-contract into phase5/window-controller
tannerlinsley Aug 11, 2026
6db5b1a
fix(db): harden live query window subscriptions
tannerlinsley Aug 11, 2026
619c3a3
fix(db): bind layout revisions to sync transactions
tannerlinsley Aug 11, 2026
8e2f777
Merge phase4 layout transaction fix
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
1ca1a07
Merge final phase4 updates
tannerlinsley Aug 11, 2026
f994c55
Merge main into phase5/window-controller
tannerlinsley Aug 12, 2026
1bf883f
fix(db): harden live query window ownership
tannerlinsley Aug 12, 2026
d704b95
fix(db): close window controller race gaps
tannerlinsley Aug 12, 2026
ed51adb
fix(db): preserve live query observer invariants
KyleAMathews Aug 12, 2026
e730980
fix(react-db): handle pagination load failures
KyleAMathews Aug 12, 2026
b7172ea
fix(db): harden live query window coordination
KyleAMathews Aug 12, 2026
4c44bcd
docs: update window controller release notes
KyleAMathews Aug 12, 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
10 changes: 10 additions & 0 deletions .changeset/live-query-window-controller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/db': patch
'@tanstack/react-db': patch
Comment thread
coderabbitai[bot] marked this conversation as resolved.
---

Add the unstable, internal `createLiveQueryWindowController` primitive for
forward pagination. It coordinates collection-scoped window leases, commits
pages only after subset loads succeed, restores windows after failures and
cleanup, and lets React's `useLiveInfiniteQuery` become a thin binding without
changing its public API or resetting pages for structurally equal dependencies.
19 changes: 16 additions & 3 deletions packages/db/src/collection/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class CollectionChangesManager<
public changeSubscriptions = new Set<CollectionSubscription>()
public batchedEvents: Array<ChangeMessage<TOutput, TKey>> = []
public shouldBatchEvents = false
private layoutChangeListeners = new Set<() => void>()

/**
* Monotonic revision of the collection's visible state, advanced once per
Expand All @@ -41,8 +42,7 @@ export class CollectionChangesManager<

/**
* 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.
* Observers use it to detect reordered rows whose values did not change.
*/
public layoutRevision = 0

Expand Down Expand Up @@ -124,6 +124,13 @@ export class CollectionChangesManager<
return
}

// Notify both internal layout consumers and the public subscription API.
// Public subscribers historically receive an empty batch for order-only
// moves because there is no row-value ChangeMessage to publish.
if (rawEvents.length === 0) {
for (const listener of this.layoutChangeListeners) listener()
}

// Enrich all change messages with virtual properties
// This uses the "add-if-missing" pattern to preserve pass-through semantics
const enrichedEvents: Array<
Expand All @@ -132,10 +139,16 @@ export class CollectionChangesManager<

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

/** Subscribe to layout-only publications. Internal observer channel. */
public subscribeLayoutChanges(listener: () => void): () => void {
this.layoutChangeListeners.add(listener)
return () => this.layoutChangeListeners.delete(listener)
}

/**
* Subscribe to changes in the collection
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,11 @@ export class CollectionImpl<
return this._changes.layoutRevision
}

/** Subscribe to layout-only publications. Internal observer channel. */
public _subscribeLayoutChanges(listener: () => void): () => void {
return this._changes.subscribeLayoutChanges(listener)
}

/** Mark the active sync transaction as layout-changing. Internal. */
public _markLayoutChange(): void {
this._sync.markLayoutChange()
Expand Down
24 changes: 6 additions & 18 deletions packages/db/src/collection/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ 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 @@ -249,14 +248,9 @@ 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()
const layoutChanged = this.truncateBufferHasLayoutChange
if (merged.length > 0 || layoutChanged) {
const delivered = this.filteredCallback(merged)
if (layoutChanged && !delivered) this.filteredCallback([])
}
if (merged.length > 0) this.filteredCallback(merged)

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

setOrderByIndex(index: IndexInterface<any>) {
Expand Down Expand Up @@ -308,12 +302,13 @@ export class CollectionSubscription
this.pendingLoadSubsetPromises.add(syncResult)
this.setStatus(`loadingSubset`)

syncResult.finally(() => {
const finish = () => {
this.pendingLoadSubsetPromises.delete(syncResult)
if (this.pendingLoadSubsetPromises.size === 0) {
this.setStatus(`ready`)
}
})
}
void syncResult.then(finish, finish)
}
}

Expand All @@ -325,10 +320,7 @@ export class CollectionSubscription
return this.snapshotSent
}

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

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

Expand Down Expand Up @@ -742,7 +731,6 @@ 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
17 changes: 15 additions & 2 deletions packages/db/src/collection/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,18 @@ export class CollectionSyncManager<
return this.pendingLoadSubsetPromises.size > 0
}

/** Wait for the subset loads that are active during the current operation. */
public waitForCurrentLoadSubset(): true | Promise<void> {
if (this.pendingLoadSubsetPromises.size === 0) return true
return this.waitForPendingLoadSubset()
}

private async waitForPendingLoadSubset(): Promise<void> {
do {
await Promise.all([...this.pendingLoadSubsetPromises])
} while (this.pendingLoadSubsetPromises.size > 0)
}

/**
* Tracks a load promise for isLoadingSubset state.
* @internal This is for internal coordination (e.g., live-query glue code), not for general use.
Expand All @@ -462,7 +474,7 @@ export class CollectionSyncManager<
})
}

promise.finally(() => {
const finish = () => {
const loadingEnding =
this.pendingLoadSubsetPromises.size === 1 &&
this.pendingLoadSubsetPromises.has(promise)
Expand All @@ -477,7 +489,8 @@ export class CollectionSyncManager<
loadingSubsetTransition: `end`,
})
}
})
}
void promise.then(finish, finish)
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ export class LiveQueryObserverDisposedError extends CollectionStateError {
}
}

export class LiveQueryWindowControllerDisposedError extends CollectionStateError {
constructor() {
super(`Cannot subscribe to a disposed LiveQueryWindowController`)
}
}

// Collection Operation Errors
export class CollectionOperationError extends TanStackDBError {
constructor(message: string) {
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export * from './query/index.js'
export * from './optimistic-action'
export * from './live-query-adapter'
export * from './live-query-observer'
/** @internal Unstable adapter primitive for RFC #1623. */
export * from './live-query-window-controller'
export * from './local-only'
export * from './local-storage'
export * from './errors'
Expand Down
26 changes: 23 additions & 3 deletions packages/db/src/live-query-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ export interface LiveQuerySnapshot<
isEnabled: boolean
}

/** Listener payload: the change set, or `undefined` for the synthetic ready notify. */
/**
* Listener payload: changes, `[]` for an internal layout-only publication, or
* `undefined` for a synthetic status/ready notification.
*/
export type LiveQueryObserverListener<
T extends object,
TKey extends string | number,
Expand Down Expand Up @@ -357,11 +360,16 @@ class LiveQueryObserverImpl<
const notify = (
changes: Array<ChangeMessage<T, TKey>> | undefined,
status: CollectionStatus = collection.status,
explicitLayoutChange = false,
) => {
if (this.disposed || this.subscriptions.size === 0) return
const layoutRevision = this.getCollectionLayoutRevision(collection)
let layoutChanged = false
if (changes !== undefined && changes.length === 0) {
let layoutChanged = explicitLayoutChange
if (
!explicitLayoutChange &&
changes !== undefined &&
changes.length === 0
) {
// Empty ready events predate the explicit layout signal and share its
// empty-array payload. Only forward an empty batch when the collection
// confirms that a new layout-only publication occurred.
Expand Down Expand Up @@ -401,6 +409,17 @@ class LiveQueryObserverImpl<
const statusUnsub = collection.on(`status:change`, ({ status }) =>
notify(undefined, status),
)
const subscribeLayoutChanges = (
collection as Collection<T, TKey, any> & {
_subscribeLayoutChanges?: (listener: () => void) => () => void
}
)._subscribeLayoutChanges
const layoutUnsub =
typeof subscribeLayoutChanges === `function`
? subscribeLayoutChanges.call(collection, () =>
notify([], collection.status, true),
)
: () => {}

// `subscribeChanges` delivers the initial state synchronously, so a
// listener can dispose the observer while the collection subscription is
Expand All @@ -410,6 +429,7 @@ class LiveQueryObserverImpl<
let subscription: { unsubscribe: () => void } | null = null
const release = () => {
statusUnsub()
layoutUnsub()
subscription?.unsubscribe()
}
this.collectionUnsub = release
Expand Down
Loading
Loading