Skip to content
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.
13 changes: 13 additions & 0 deletions packages/db/src/collection/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ export class CollectionChangesManager<
}
}

/**
* Notify subscribers that the visible layout (row order) changed without any
* row value changing — e.g. an order-only move in an `orderBy` live query.
* Emits an empty batch directly (bypassing the empty-array check) so ordered
* consumers re-read the now re-sorted collection. This is a first-class layout
* signal, deliberately not a forged row `update`.
*/
public emitLayoutChangeEvent(): void {
for (const subscription of this.changeSubscriptions) {
subscription.emitEvents([])
}
}

/**
* Enriches a change message with virtual properties ($synced, $origin, $key, $collectionId).
* Uses the "add-if-missing" pattern to preserve virtual properties from upstream collections.
Expand Down
38 changes: 38 additions & 0 deletions packages/db/src/live-query-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export interface LiveQuerySnapshot<
data: T | ReadonlyArray<T> | undefined
/** The underlying collection, or `undefined` when disabled. */
collection: Collection<T, TKey, any> | undefined
/**
* Monotonic counter bumped whenever the visible layout (the ordered key
* sequence) changes — membership, ordering, or an order-only move. Lets
* consumers detect a reorder that changed no row value (which `data`/`state`
* identity alone can't express once row values are structurally shared).
*
* It is NOT in lockstep with snapshot identity: a value-only update produces a
* new snapshot while `layoutRevision` stays put. A `layoutRevision` change
* always accompanies a new snapshot, but not vice versa.
*/
layoutRevision: number
status: CollectionStatus | `disabled`
isLoading: boolean
isReady: boolean
Expand Down Expand Up @@ -70,6 +81,7 @@ const DISABLED_SNAPSHOT: LiveQuerySnapshot<any, any> = {
state: undefined,
data: undefined,
collection: undefined,
layoutRevision: 0,
status: `disabled`,
isLoading: false,
isReady: true,
Expand All @@ -89,6 +101,8 @@ class LiveQueryObserverImpl<
private cachedVersion = -1
private cachedStatus: CollectionStatus | undefined
private cachedSnapshot: LiveQuerySnapshot<T, TKey> = DISABLED_SNAPSHOT
private layoutRevision = 0
private lastLayoutKeys: Array<TKey> | undefined
private readonly listeners = new Set<LiveQueryObserverListener<T, TKey>>()
private collectionUnsub: (() => void) | null = null
// Bumped on each attach. `onFirstReady` can't be unsubscribed, so a callback
Expand Down Expand Up @@ -124,6 +138,29 @@ class LiveQueryObserverImpl<
let stateCache: Map<TKey, T> | null = null
let dataCache: Array<T> | null = null

// Bump the layout revision when the ordered key sequence changes
// (membership, ordering, or an order-only move). Compare the key sequence
// directly rather than via a serialized signature: a joined-with-separator
// signature can collide when a key value equals the concatenation of
// neighboring keys around the separator. Comparing keys also avoids
// materializing a large string on every rebuild; a new key array is only
// allocated when the layout actually moved.
const prevKeys = this.lastLayoutKeys
let layoutChanged =
prevKeys === undefined || prevKeys.length !== entries.length
if (!layoutChanged) {
for (let i = 0; i < entries.length; i++) {
if (prevKeys![i] !== entries[i]![0]) {
layoutChanged = true
break
}
}
}
if (layoutChanged) {
this.lastLayoutKeys = entries.map(([key]) => key)
this.layoutRevision++
}

this.cachedSnapshot = {
get state() {
if (!stateCache) stateCache = new Map(entries)
Expand All @@ -134,6 +171,7 @@ class LiveQueryObserverImpl<
return singleResult ? dataCache[0] : dataCache
},
collection,
layoutRevision: this.layoutRevision,
status: collection.status,
...getLiveQueryStatusFlags(collection.status),
isEnabled: true,
Expand Down
82 changes: 82 additions & 0 deletions packages/db/src/query/live/collection-config-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '../../errors.js'
import { transactionScopedScheduler } from '../../scheduler.js'
import { getActiveTransaction } from '../../transactions.js'
import { deepEquals } from '../../utils.js'
import { CollectionSubscriber } from './collection-subscriber.js'
import { getCollectionBuilder } from './collection-registry.js'
import { LIVE_QUERY_INTERNAL } from './internal.js'
Expand Down Expand Up @@ -799,6 +800,11 @@ export class CollectionConfigBuilder<
existing.orderByIndex = changes.orderByIndex
}
}
// Keep the retracted (old) side for order-only-move detection.
if (changes.deletes > 0) {
existing.previousValue = changes.previousValue
existing.previousOrderByIndex = changes.previousOrderByIndex
}
} else {
merged.set(customKey, { ...changes })
}
Expand All @@ -811,6 +817,16 @@ export class CollectionConfigBuilder<
begin()
changesToApply.forEach(this.applyChanges.bind(this, config))
commit()
// An order-only move (the row's projected value is unchanged but its
// `orderByIndex` moved) is swallowed by the collection's value-diff, so
// `commit()` emits nothing even though `.values()`/`.entries()` are now
// re-sorted. Publish an explicit layout-change notification so ordered
// consumers re-read — a first-class signal, not a forged row `update`.
// Only when the commit published nothing else: any real row change
// already notifies subscribers, who re-read the re-sorted collection.
if (needsLayoutOnlyPublication(changesToApply)) {
emitLayoutChange(config.collection)
}
}
pendingChanges = new Map()

Expand Down Expand Up @@ -893,9 +909,14 @@ export class CollectionConfigBuilder<

if (multiplicity < 0) {
existing.deletes += Math.abs(multiplicity)
existing.previousValue = childResult
existing.previousOrderByIndex = _orderByIndex
} else if (multiplicity > 0) {
existing.inserts += multiplicity
existing.value = childResult
if (_orderByIndex !== undefined) {
existing.orderByIndex = _orderByIndex
}
}

byChild.set(childKey, existing)
Expand Down Expand Up @@ -1320,9 +1341,14 @@ function setupNestedPipelines(

if (multiplicity < 0) {
existing.deletes += Math.abs(multiplicity)
existing.previousValue = childResult
existing.previousOrderByIndex = _orderByIndex
} else if (multiplicity > 0) {
existing.inserts += multiplicity
existing.value = childResult
if (_orderByIndex !== undefined) {
existing.orderByIndex = _orderByIndex
}
}

byChild.set(childKey, existing)
Expand Down Expand Up @@ -1985,6 +2011,13 @@ function flushIncludesState(
}
}
entry.syncMethods.commit()
// Same order-only-move handling as the parent flush: a child row that
// moved without a value change is swallowed by the value-diff, so
// publish a layout-only notification when the child commit published
// nothing else.
if (needsLayoutOnlyPublication(childChanges)) {
emitLayoutChange(entry.syncMethods.collection)
}
}

// Update routing index for nested includes
Expand Down Expand Up @@ -2315,6 +2348,10 @@ function accumulateChanges<T>(
}
if (multiplicity < 0) {
changes.deletes += Math.abs(multiplicity)
// Remember the retracted (old) value + position so the flush can tell an
// order-only move apart from a real value change.
changes.previousValue = value
changes.previousOrderByIndex = orderByIndex
} else if (multiplicity > 0) {
changes.inserts += multiplicity
// Update value to the latest version for this key
Expand All @@ -2326,3 +2363,48 @@ function accumulateChanges<T>(
acc.set(key, changes)
return acc
}

/**
* Decide whether a flush needs a standalone layout-change publication.
*
* An "order-only move" — a row updated in place whose `orderByIndex` moved but
* whose projected value is deep-equal to before — is swallowed by the value-diff
* and needs an explicit layout notification. But that notification is only
* needed when the same `commit()` published nothing else: any real change
* (insert, delete, or a value-changed update) already notifies subscribers, who
* re-read the re-sorted collection, so a second layout event would be redundant.
*
* Returns true iff there is at least one order-only move AND no change that the
* commit itself publishes.
*/
function needsLayoutOnlyPublication<T>(
changesToApply: Map<unknown, Changes<T>>,
): boolean {
let layoutMoved = false
let commitPublishes = false
for (const changes of changesToApply.values()) {
const isUpdate = changes.inserts > 0 && changes.deletes > 0
if (!isUpdate) {
// A net insert or delete always publishes a row change.
commitPublishes = true
} else if (
changes.previousValue === undefined ||
!deepEquals(changes.previousValue, changes.value)
) {
// In-place update whose value changed — publishes a normal `update`.
commitPublishes = true
} else if (changes.orderByIndex !== changes.previousOrderByIndex) {
// Value unchanged, position moved — swallowed by the value-diff.
layoutMoved = true
}
}
return layoutMoved && !commitPublishes
}

/** Emit a layout-only change notification on a live-query collection. */
function emitLayoutChange(collection: Collection<any, any, any>): void {
const changesManager = (collection as any)._changes as {
emitLayoutChangeEvent: () => void
}
changesManager.emitLayoutChangeEvent()
}
6 changes: 6 additions & 0 deletions packages/db/src/query/live/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export type Changes<T> = {
inserts: number
value: T
orderByIndex: string | undefined
// Captured from the retract side of a change so the flush can detect an
// "order-only move": a row whose projected value is unchanged but whose
// `orderByIndex` moved. Such a move is swallowed by the collection's
// value-diff, so it needs an explicit layout notification.
previousValue?: T
previousOrderByIndex?: string | undefined
}

export type SyncState = {
Expand Down
2 changes: 1 addition & 1 deletion packages/db/tests/conformance/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const ISSUES: Array<Issue> = [
]

/** Keys that are expected to fail on ALL adapters (core gaps, not adapter drift). */
const UNIVERSAL_EXPECTED_FAIL = new Set<string>([`order-only-move`])
const UNIVERSAL_EXPECTED_FAIL = new Set<string>([])

export function runSuite(rawDriver: LiveQueryDriver) {
const { ops } = rawDriver
Expand Down
26 changes: 26 additions & 0 deletions packages/db/tests/live-query-observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,30 @@ describe(`createLiveQueryObserver`, () => {
expect(observer.getSnapshot().status).toBe(`ready`)
observer.dispose()
})

it(`bumps layoutRevision on a membership change that a joined key signature would collide on`, () => {
// Two keys "a","b" vs a single key "a\u0000b" join to the same string under
// any separator that can appear in a key. The revision compares the key
// sequence directly, so it must still register the membership change.
const source = makeSource([
{ id: `a`, name: `A` },
{ id: `b`, name: `B` },
])
const observer = createLiveQueryObserver<Row, string>(source as any)
observer.subscribe(() => {})

const revBefore = observer.getSnapshot().layoutRevision

source.utils.begin()
source.utils.write({ type: `delete`, value: { id: `a`, name: `A` } })
source.utils.write({ type: `delete`, value: { id: `b`, name: `B` } })
source.utils.write({
type: `insert`,
value: { id: `a\u0000b`, name: `AB` },
})
source.utils.commit()

expect(observer.getSnapshot().layoutRevision).not.toBe(revBefore)
observer.dispose()
})
})
Loading
Loading