Skip to content

fix(db): emit change on order-only reorder in ordered live queries - #1601

Open
v-anton wants to merge 4 commits into
TanStack:mainfrom
v-anton:fix/orderby-reorder-emission
Open

fix(db): emit change on order-only reorder in ordered live queries#1601
v-anton wants to merge 4 commits into
TanStack:mainfrom
v-anton:fix/orderby-reorder-emission

Conversation

@v-anton

@v-anton v-anton commented Jun 19, 2026

Copy link
Copy Markdown

🎯 Changes

A live query with orderBy does not emit a change when a row's position changes but its projected value does not. For example:

q.from({ s: source })
 .orderBy(({ s }) => s.value)
 .select(({ s }) => ({ id: s.id }))   // sort field not in projection

When a row's value changes so its position moves, the projected value ({ id }) is byte-identical, so the collection's value-diff (deepEquals in state.ts) suppresses the update. The live query's SortedMap re-sorts internally, but subscribeChanges emits nothing — so useLiveQuery keeps rendering the stale order. Any projection that omits the changing orderBy field hits this (it is not specific to id-only).

Fix

The orderBy operator already streams the move as a retract (old value + old orderByIndex) plus an insert (new value + new index). We capture the retracted side in accumulateChanges and, after each flush, emit an update directly only when the index changed and the value is unchanged — the exact case the value-diff swallows. This mirrors the existing includes-materialization direct-emit pattern and leaves generic collection code untouched.

  • Zero cost for collections without orderBy (the gate short-circuits on undefined index).
  • No double-emit: the gate (value unchanged) is mutually exclusive with state.ts's emit (value changed).
  • Only adds emits; never removes or alters existing ones.

✅ Checklist

  • Tested locally with pnpm test.

New tests: packages/db/tests/live-query-orderby-reorder.test.ts (id-only reorder emits; exactly-one-emit when sort field projected; no spurious emit; limit window) and packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx (useLiveQuery renders correct order). Full @tanstack/db (2391) and @tanstack/react-db (95) suites pass.

🚀 Release Impact

  • Affects published code; changeset included (@tanstack/db patch).

Summary by CodeRabbit

Bug Fixes

  • Fixed live queries with orderBy to correctly detect and publish “order-only” reorders, even when item projected values remain unchanged.
  • Prevented duplicate change notifications during reorders, including cases where the sort field is included or excluded from the selected projection.

Tests

  • Expanded live-query orderBy reorder coverage (projection behavior, limit/offset edge cases, single-item, and consecutive reorders).
  • Added React useLiveQuery coverage to verify UI ordering updates after reorder writes even when the sort field isn’t selected.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9dd1957e-adc9-46f6-b344-b0b75a9d7061

📥 Commits

Reviewing files that changed from the base of the PR and between 02a6863 and 989cfb0.

📒 Files selected for processing (1)
  • packages/db/tests/live-query-orderby-reorder.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/db/tests/live-query-orderby-reorder.test.ts

📝 Walkthrough

Walkthrough

Extends Changes<T> with optional previousValue and previousOrderByIndex fields, then modifies CollectionConfigBuilder to record those fields on retracts, preserve them across key-based re-merges, and call a new emitOrderOnlyMoves method after flushing parent diffs. That method force-emits update events for rows whose projected value is deep-equal but whose orderByIndex changed. Tests and a changeset entry are added.

Changes

orderBy reorder emission for live queries

Layer / File(s) Summary
Metadata extension and accumulation
packages/db/src/query/live/types.ts, packages/db/src/query/live/collection-config-builder.ts
Adds optional previousValue and previousOrderByIndex fields to Changes<T>; imports deepEquals for value comparison; records retract-side metadata during accumulateChanges().
Move detection and forced emission
packages/db/src/query/live/collection-config-builder.ts
Preserves previous metadata across key-based re-merges; invokes emitOrderOnlyMoves() after parent diff flush; implements the method to scan changesToApply for deep-equal-value/different-orderByIndex pairs and force-emit update events via _changes.emitEvents().
Core live query orderBy reorder tests
packages/db/tests/live-query-orderby-reorder.test.ts
Introduces test helpers and suite covering reorder emission with projection, single-event behavior when sort field is projected, silence on non-sort field updates, limit+orderBy window reorders, and edge cases with limit(0), offset beyond data, single-element collections, and back-to-back reorders.
React hook integration and changeset
packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx, .changeset/orderby-reorder-emission.md
Adds useLiveQuery hook test asserting rendered id order updates after a reorder-triggering write; documents the @tanstack/db patch fix in changeset.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Suggested reviewers

  • samwillis

Poem

🐇 A hop, a skip, the order's changed,
But deep-equal values left me deranged!
Now previousIndex saves the day,
forceEmit tells subscribers: "Hey!"
The rows are sorted — hip hooray! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: fixing a bug where orderBy-only reorders weren't emitting changes in live queries.
Description check ✅ Passed The description comprehensively covers the problem, solution, testing approach, and includes a completed checklist with changeset confirmation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@v-anton

v-anton commented Jun 19, 2026

Copy link
Copy Markdown
Author

Relationship to other open orderBy issues

While investigating I checked the nearby open issues in the ordered-query area. This fix is related but distinct from both:

This PR specifically fixes the case where a top-level ordered live query's projection omits the changing orderBy field, so a reorder is suppressed by the collection's value-diff (state.ts deepEquals) and never reaches subscribers.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
packages/db/tests/live-query-orderby-reorder.test.ts (2)

128-153: ⚡ Quick win

Add explicit limit/offset boundary tests for reorder behavior.

You already cover a standard limit path; please add limit(0) and offset-beyond-length cases to lock down empty-window semantics during reorder updates.

As per coding guidelines, "Handle limit and offset edge cases: consider what happens when limit is 0, undefined, or when offset exceeds data length" and test corner cases in *.test.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/live-query-orderby-reorder.test.ts` around lines 128 - 153,
The current test covers a standard limit case with limit(3). Add two additional
test cases following the same pattern: one testing the reorder behavior when
limit(0) is used to create an empty result window, and another testing when
offset exceeds the data length to verify empty-window semantics during reorder
updates. Use the same manualCollection, createLiveQueryCollection, write, and
subscribeChanges pattern to verify that reorder updates are handled correctly
when the query window is empty or beyond available data boundaries.

Source: Coding guidelines


9-9: ⚡ Quick win

Remove any from test helper/projection paths and use explicit test-local types.

Typed helper contracts and projected row types keep regressions catchable at compile-time and make assertions safer.

As per coding guidelines, "Avoid using any types; use unknown instead when the type is truly unknown, and provide proper type annotations for return values".

Also applies to: 54-55, 87-87, 95-95, 124-124, 140-140, 152-152

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/live-query-orderby-reorder.test.ts` at line 9, Replace all
instances of the `any` type throughout the file (found at the variable
declaration `let fns: any` and at the other locations noted in lines 54-55, 87,
95, 124, 140, 152) with explicit type annotations. For test helper functions and
projection row types, define appropriate test-local types that clearly express
what these variables and functions should return, rather than using `any` or
`unknown`. This ensures better type safety and makes assertions more robust by
allowing the compiler to catch type-related regressions.

Source: Coding guidelines

packages/db/src/query/live/collection-config-builder.ts (1)

1024-1030: 🏗️ Heavy lift

Avoid reaching into _changes via any; delegate through a typed internal method.

This hard-couples CollectionConfigBuilder to Collection internals and weakens compile-time safety. Prefer an internal delegate on Collection/sync methods for forced emits, then call that API here.

♻️ Proposed direction
- const changesManager = (config.collection as any)._changes as {
-   emitEvents: (
-     changes: Array<ChangeMessage<TResult>>,
-     forceEmit?: boolean,
-   ) => void
- }
- changesManager.emitEvents(moves, true)
+ config.collection.emitInternalChanges(moves, { forceEmit: true })

As per coding guidelines, "Encapsulate implementation details within responsible classes; use delegation to maintain clean boundaries between components" and "Avoid exposing internal properties directly; instead add public methods that delegate to internal implementations".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/query/live/collection-config-builder.ts` around lines 1024 -
1030, The code directly accesses the private _changes property on the collection
object through any type casting to call emitEvents, which breaks encapsulation
and type safety. Instead of reaching into _changes via any, create an internal
delegate method on the Collection class (such as emitChangesForced or similar)
that encapsulates the _changes.emitEvents call, then replace the direct
changesManager assignment and emitEvents invocation in the current code with a
call to this new internal method on the collection object. This maintains clean
boundaries between components while preserving the forced emit behavior.

Source: Coding guidelines

packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx (2)

37-37: ⚡ Quick win

Replace any in result mapping with a concrete row type.

This keeps hook assertions type-safe and avoids masking contract drift in useLiveQuery data shape.

As per coding guidelines, "Avoid using any types; use unknown instead when the type is truly unknown, and provide proper type annotations for return values".

Also applies to: 44-44

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx` at line 37,
The map function in the result.current.data transformation uses `any` type
annotation for the row parameter, which reduces type safety. Identify the
concrete row type that useLiveQuery returns (likely defined in your test setup
or data models), and replace the `any` type annotation with that concrete type
in both the mapping operation at line 37 and the additional occurrence at line
44. This ensures type-safe assertions and prevents masking any contract drift in
the useLiveQuery data shape.

Source: Coding guidelines


21-47: ⚡ Quick win

Add a rapid consecutive updates test to cover hook-level async race ordering.

A case with back-to-back writes that reorder in opposite directions would strengthen confidence that stale order does not render between emissions.

As per coding guidelines, test corner cases including "async race conditions where operations may resolve in unexpected order".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx` around lines
21 - 47, Add a new test case within the existing describe block for useLiveQuery
orderBy + select(id-only) that tests rapid consecutive updates with reordering
in opposite directions. This test should perform multiple back-to-back writes
using act() that cause the items to reorder in alternating directions (for
example, one update that moves an item to the top, followed immediately by
another that moves it to the bottom), and then verify using waitFor() that the
final rendered order matches the expected result without any stale intermediate
orders appearing. This will ensure the hook properly handles async race
conditions where multiple reordering operations occur in quick succession.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/tests/live-query-orderby-reorder.test.ts`:
- Line 4: The `flush` function uses a hardcoded setTimeout with a fixed 20ms
delay, making tests timing-sensitive under CI load. Instead of relying on this
sleep-based approach, replace the `flush` function implementation to use
condition-based waits by leveraging testing utilities like `waitFor` to
explicitly wait on observable state changes. Identify the specific order or
event expectations that each test needs to verify after calling `flush`, and
replace the fixed sleep with explicit waits on those observable state conditions
to ensure reliable test execution regardless of system load.

---

Nitpick comments:
In `@packages/db/src/query/live/collection-config-builder.ts`:
- Around line 1024-1030: The code directly accesses the private _changes
property on the collection object through any type casting to call emitEvents,
which breaks encapsulation and type safety. Instead of reaching into _changes
via any, create an internal delegate method on the Collection class (such as
emitChangesForced or similar) that encapsulates the _changes.emitEvents call,
then replace the direct changesManager assignment and emitEvents invocation in
the current code with a call to this new internal method on the collection
object. This maintains clean boundaries between components while preserving the
forced emit behavior.

In `@packages/db/tests/live-query-orderby-reorder.test.ts`:
- Around line 128-153: The current test covers a standard limit case with
limit(3). Add two additional test cases following the same pattern: one testing
the reorder behavior when limit(0) is used to create an empty result window, and
another testing when offset exceeds the data length to verify empty-window
semantics during reorder updates. Use the same manualCollection,
createLiveQueryCollection, write, and subscribeChanges pattern to verify that
reorder updates are handled correctly when the query window is empty or beyond
available data boundaries.
- Line 9: Replace all instances of the `any` type throughout the file (found at
the variable declaration `let fns: any` and at the other locations noted in
lines 54-55, 87, 95, 124, 140, 152) with explicit type annotations. For test
helper functions and projection row types, define appropriate test-local types
that clearly express what these variables and functions should return, rather
than using `any` or `unknown`. This ensures better type safety and makes
assertions more robust by allowing the compiler to catch type-related
regressions.

In `@packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx`:
- Line 37: The map function in the result.current.data transformation uses `any`
type annotation for the row parameter, which reduces type safety. Identify the
concrete row type that useLiveQuery returns (likely defined in your test setup
or data models), and replace the `any` type annotation with that concrete type
in both the mapping operation at line 37 and the additional occurrence at line
44. This ensures type-safe assertions and prevents masking any contract drift in
the useLiveQuery data shape.
- Around line 21-47: Add a new test case within the existing describe block for
useLiveQuery orderBy + select(id-only) that tests rapid consecutive updates with
reordering in opposite directions. This test should perform multiple
back-to-back writes using act() that cause the items to reorder in alternating
directions (for example, one update that moves an item to the top, followed
immediately by another that moves it to the bottom), and then verify using
waitFor() that the final rendered order matches the expected result without any
stale intermediate orders appearing. This will ensure the hook properly handles
async race conditions where multiple reordering operations occur in quick
succession.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 45978f9c-2f09-4ecd-b14c-7c28e43ad837

📥 Commits

Reviewing files that changed from the base of the PR and between 9a5e8e5 and 66bf854.

📒 Files selected for processing (5)
  • .changeset/orderby-reorder-emission.md
  • packages/db/src/query/live/collection-config-builder.ts
  • packages/db/src/query/live/types.ts
  • packages/db/tests/live-query-orderby-reorder.test.ts
  • packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx

Comment thread packages/db/tests/live-query-orderby-reorder.test.ts Outdated
…ary cases

- Drop fixed setTimeout flushes (live query is synchronous on commit)
- Type the sync helper and projections instead of any
- Add limit(0) and offset-beyond-length empty-window reorder cases
@v-anton

v-anton commented Jun 19, 2026

Copy link
Copy Markdown
Author

Thanks @coderabbitai — addressed the test feedback in 18f158d5:

  • Flaky fixed setTimeout flushes → removed. The live query processes synchronously on commit, so the tests now assert directly after each write (no sleeps, deterministic).
  • limit/offset boundary cases → added limit(0) and offset-beyond-length tests asserting the empty window stays empty and silent across a reorder.
  • any in the test → typed the sync helper (SyncFns via SyncConfig) and projections (idsOf).

On the (config.collection as any)._changes.emitEvents(...) nitpick: I've left it as-is intentionally. It mirrors the existing includes-materialization path in the same file (collection-config-builder.ts, the inlineReEmitKeys block), which uses the identical (collection as any)._changes pattern to force-emit. Introducing a new public Collection.emitInternalChanges(...) delegate to satisfy this one call would add public API surface and leave that established precedent inconsistent — better handled as a separate, focused refactor of both sites if the maintainers want it. Happy to do that follow-up on request.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@v-anton: Thanks for the thorough follow-up — the test improvements look solid:

  • Deterministic assertions after synchronous commits is strictly better than timer-based flushing.
  • limit(0) and offset-beyond-length boundary cases are exactly the kind of edge coverage that prevents regressions.
  • Typed projections and sync helper removes the loose any from test code where it was avoidable.

On the (config.collection as any)._changes.emitEvents(...) point — your rationale is well-reasoned. Keeping it consistent with the existing inlineReEmitKeys precedent in the same file is the right call; a targeted refactor of both sites together makes more sense than asymmetrically introducing a new public delegate just for this one call. Happy to track that as a follow-up if you'd like a separate issue opened for it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/db/tests/live-query-orderby-reorder.test.ts (1)

49-203: ⚡ Quick win

Add missing corner-case tests required by test guidelines (single-element + async race).

This suite now covers the main repro and limit/offset edges, but it still misses guideline-required corner coverage for a single-element collection and an async race/ordering scenario (e.g., back-to-back updates that reorder in opposite directions and assert final order + emitted events are not stale).

As per coding guidelines, "**/*.test.{ts,tsx,js}: Test corner cases including: ... single-element collections ... async race conditions ... and limit/offset edge cases".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/live-query-orderby-reorder.test.ts` around lines 49 - 203,
Add two new test cases to the test suite that cover the required corner cases.
First, add a single-element collection test using the same pattern as the
existing tests (manualCollection with one item, createLiveQueryCollection with
orderBy and select, write an update that triggers a reorder, and verify the
collection remains correct and emits appropriate events). Second, add an async
race condition test that writes two back-to-back updates causing opposite
reorders (for example, first update moves an item to the front, then immediately
updates another item to push the first item back), and verify that the final
order is correct and that the emitted events reflect the actual state
transitions without stale or duplicate events.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/db/tests/live-query-orderby-reorder.test.ts`:
- Around line 49-203: Add two new test cases to the test suite that cover the
required corner cases. First, add a single-element collection test using the
same pattern as the existing tests (manualCollection with one item,
createLiveQueryCollection with orderBy and select, write an update that triggers
a reorder, and verify the collection remains correct and emits appropriate
events). Second, add an async race condition test that writes two back-to-back
updates causing opposite reorders (for example, first update moves an item to
the front, then immediately updates another item to push the first item back),
and verify that the final order is correct and that the emitted events reflect
the actual state transitions without stale or duplicate events.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bcc9819d-c473-496c-89f0-93da1040c68e

📥 Commits

Reviewing files that changed from the base of the PR and between 66bf854 and 18f158d.

📒 Files selected for processing (1)
  • packages/db/tests/live-query-orderby-reorder.test.ts

@v-anton

v-anton commented Jun 22, 2026

Copy link
Copy Markdown
Author

Added the two requested corner cases in 02a6863f: a single-element collection (sort-field change can't reorder → stays silent) and back-to-back opposite reorders (move to front, then push back → asserts correct final order and that each reorder is observed). Suite now 8 cases; full @tanstack/db suite green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/db/tests/live-query-orderby-reorder.test.ts (1)

261-263: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Tighten the emission-count assertion to catch duplicate-emission regressions.

At Line 262, toBeGreaterThanOrEqual(2) still passes if each reorder emits more than once. Since this scenario is validating reorder emission behavior, assert exactly one callback per write.

Suggested change
     // move a to the front (2 -> 0)
     write(`update`, { id: `a`, name: `Alice`, value: 0 })
     expect(idsOf(live)).toEqual([`a`, `b`])
+    expect(emitted).toBe(1)

     // push a back behind b (0 -> 5)
     write(`update`, { id: `a`, name: `Alice`, value: 5 })
     expect(idsOf(live)).toEqual([`b`, `a`])

     // each reorder is observed; no stale/missing final state
-    expect(emitted).toBeGreaterThanOrEqual(2)
+    expect(emitted).toBe(2)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/live-query-orderby-reorder.test.ts` around lines 261 - 263,
The assertion `expect(emitted).toBeGreaterThanOrEqual(2)` on line 262 is too
permissive and will not catch duplicate emission regressions. Change the matcher
from `toBeGreaterThanOrEqual(2)` to `toBe(2)` in the expect statement to verify
that exactly 2 emissions occur (one per reorder), which will fail if any
duplicate emissions occur during the reorder operations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/db/tests/live-query-orderby-reorder.test.ts`:
- Around line 261-263: The assertion `expect(emitted).toBeGreaterThanOrEqual(2)`
on line 262 is too permissive and will not catch duplicate emission regressions.
Change the matcher from `toBeGreaterThanOrEqual(2)` to `toBe(2)` in the expect
statement to verify that exactly 2 emissions occur (one per reorder), which will
fail if any duplicate emissions occur during the reorder operations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89844ed8-2fad-4903-a790-0ebc3f4601bf

📥 Commits

Reviewing files that changed from the base of the PR and between 18f158d and 02a6863.

📒 Files selected for processing (1)
  • packages/db/tests/live-query-orderby-reorder.test.ts

@v-anton

v-anton commented Jun 22, 2026

Copy link
Copy Markdown
Author

Good call — tightened in the latest commit: the back-to-back test now asserts toBe(1) after the first reorder and toBe(2) after the second, so it fails on any duplicate emission (the exact property this PR guards). Still green.

KyleAMathews added a commit that referenced this pull request Aug 12, 2026
…e 4) (#1669)

* feat(db): shared live-query observer + migrate all five adapters

Add createLiveQueryObserver to @tanstack/db. Given a resolved collection (or
null for disabled), it owns the shared lifecycle: start sync, subscribe with
initial state, the loading→ready notify, a stable per-revision snapshot for
wholesale consumers, and delivery of the raw ChangeMessage[] for granular
consumers (deferInitialNotify defers the initial notify for useSyncExternalStore
consumers like React).

React, Vue, Svelte, Solid, and Angular all materialize from the observer,
removing their duplicated subscribe/status/ready-race plumbing while keeping
native reactivity: Vue/Svelte/Solid apply the change deltas granularly to their
reactive maps; React/Angular consume the snapshot wholesale.

Observer unit tests cover the wholesale and granular paths, disabled,
deferred-notify, and dispose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(db): make observer onFirstReady detach-safe

onFirstReady returns no unsubscribe and detach() couldn't remove it, so a
subscribe → unsubscribe-before-ready → subscribe sequence left a stale ready
callback that also fired on markReady — the current listener saw two synthetic
ready notifications instead of one. Guard the callback with an attach-generation
token so only the current attachment's callback notifies. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(db): address observer/react lifecycle review findings

- observer: getSnapshot() rebuilds when collection.status changes without a
  version bump (status-only loading→ready / preload with no active subscription),
  so a cached snapshot can't go stale.
- observer: guard the deferred initial-notify microtask with the attach
  generation + listener count, so a superseded attach can't flush a stale
  initial batch to a later listener.
- react: don't dispose the previous observer during render (unsafe under
  concurrent rendering) — useSyncExternalStore detaches it when the subscribe
  changes; dispose the current observer in an unmount effect instead.
- tests: regressions for the deferred-notify race and the status-only snapshot
  refresh (both verified red before the fixes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(react-db): don't dispose the observer in an unmount effect (StrictMode)

The unmount-effect dispose could run during StrictMode/offscreen effect replay
(mount → cleanup → mount) without a re-render, leaving observerRef pointing at a
disposed observer; the next subscribe hit attach()'s disposed guard and the
store stopped resubscribing. Remove the explicit dispose — useSyncExternalStore
already detaches the observer on unsubscribe/unmount, so the collection
subscription is torn down and the observer is GC'd. Adds a StrictMode regression
test (verified red before the fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(db): cover live-query keyed-state invariant on recompile

Expose the keyed `state` map in the shared conformance harness (added to
ConformanceResult and read by all five adapter drivers) and add a
steady-state `recompile-drops-stale-keys` scenario asserting the map stays
in sync with `data` across a narrowing recompile.

Also add a solid-db regression (in useLiveQuery.test.tsx) that inspects
`state` synchronously in the window after a recompile, where solid-db leaks
the previous collection's keys until its async resource reconciles. This
test fails until the follow-up fix (state.clear() before re-subscribing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(solid-db): clear keyed state before subscribing to a new collection

When the query recompiles to a different collection, the observer re-seeds
via `includeInitialState`, which only inserts current rows and never deletes
keys from the previous collection. Without clearing first, the dropped keys
lingered in `state` until the async resource reconciled — a transient window
where `state` exposed stale rows (though `data`, rebuilt wholesale, stayed
correct). Clear synchronously before re-subscribing, matching vue-db and
svelte-db.

Fixes the solid-db stale-keys regression added in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(db): republish ordered live queries on an order-only move (RFC #1623 phase 4)

An `orderBy` live query that reorders its rows without changing any projected
row value (an "order-only move") was swallowed by the collection's value-diff:
`.values()`/`.entries()` re-sorted, but no change event fired, so subscribers
kept the stale order. This is the last universal expected-fail in the
cross-adapter conformance suite (issue #1601).

Phase 4 of the live-query platform RFC calls for an explicit layout-revision
contract rather than a forged row `update`. This does that:

- The live-query flush captures the retracted side of each change and, after
  commit, detects an order-only move (value deep-equal, `orderByIndex` moved)
  and publishes a first-class empty layout-change notification via a new
  `CollectionChangesManager.emitLayoutChangeEvent()`.
- The shared observer snapshot gains `layoutRevision`, which increments on any
  visible membership, ordering, or order-only-move change.

All five adapters pick this up through their existing wholesale re-read, so the
`order-only-move` conformance scenario is removed from UNIVERSAL_EXPECTED_FAIL
and now passes on React, Vue, Svelte, Solid, and Angular.

Distinct from PR #1601 (v-anton), which fixes the same bug via a forced row
`update`; this uses the RFC's layout-revision approach instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: apply automated fixes

* refactor(db): compare key sequence directly for layoutRevision + fix doc

Addresses independent review of the layoutRevision contract:

- The join-with-separator signature could collide: a key value equal to the
  concatenation of neighboring keys around the separator produces the same
  string as two separate keys, so a real layout change (a membership change
  whose combined key spans the separator) was missed. Compare the ordered key
  sequence directly instead - collision-free, and it avoids materializing a
  large string on every snapshot rebuild (a new key array is only allocated
  when the layout actually moved). Adds a regression test.
- Correct the layoutRevision doc comment: it is NOT in lockstep with snapshot
  identity (a value-only update yields a new snapshot but the same
  layoutRevision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: apply automated fixes

* test(db): add failing regressions for Kyle's review findings

Two gaps in the order-only-move handling, reproduced as failing tests (to be
fixed in a follow-up commit):

1. A commit containing both an ordinary value update and an order-only move
   publishes twice (commit's row batch + the separate empty layout event),
   where exactly one publication is expected.
2. Ordered child collections produced by `includes` don't consume the
   insertion-side order metadata or publish a layout-only move, so an ordered
   child stays in its old order after a child order-only move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(db): coalesce layout publications and cover ordered includes children

Addresses Kyle's review of the order-only-move handling:

1. A commit containing both an ordinary value update and an order-only move
   published twice: commit() emitted the row batch and then the separate layout
   event fired redundantly. Replace hasOrderOnlyMove with
   needsLayoutOnlyPublication, which fires the layout event only when the commit
   published nothing else (any real insert/delete/value-changed update already
   notifies subscribers, who re-read the re-sorted collection).

2. Ordered child collections produced by includes did not reorder on an
   order-only child move:
   - The child accumulate replaced value on the insert side but left the
     retracted orderByIndex, so the child collection re-sorted against a stale
     index. Update orderByIndex on insert and capture the retract side (both the
     single-level and nested-includes accumulate blocks).
   - The child flush committed without a layout-only publication when the
     projected child value was unchanged. Publish one through the same
     mechanism (emitLayoutChange) when the child commit published nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(db): guard order-only moves in deeply-nested ordered includes

The includes flush is recursive, so the order-only-move handling must hold
beyond one level. Adds a two-level ordered-includes regression (org -> teams
-> members): moving a grandchild whose projected value is unchanged must
re-sort its collection and publish exactly once. Verified red when the
child-flush layout publication is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: apply automated fixes

* fix(db): FIFO non-reentrant observer dispatch over subscription records

A listener that synchronously mutates the collection used to trigger a
nested, reentrant dispatch: later subscribers could observe the nested
event (e.g. a delete) before the outer one (the insert) it reacted to.
Publications are now queued and dispatched FIFO.

Each publication is delivered over a snapshot of subscription records
taken when it is dispatched: a subscription removed mid-delivery still
receives the in-flight publication, one added mid-delivery does not.
Records — not raw callbacks — identify subscriptions, so subscribing the
same function twice no longer collapses into one Set entry whose first
unsubscribe tore down both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): release the collection subscription on dispose during initial replay

subscribeChanges delivers the initial state synchronously, so a listener
could dispose the observer before the subscription handle was stored —
detach() then had nothing to release and the collection subscription
leaked past disposal. The release hook is now registered before the
subscription is created, making attachment transactional: if detach()
fired mid-replay, the subscription is undone as soon as subscribeChanges
returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): seed late observer subscribers; reject subscribe after dispose

The initial-state replay only happened on the first attach, so a second
concurrent subscriber started with no rows and could never converge —
its keyed map silently stayed empty. A subscriber arriving while the
observer is already attached is now seeded with the collection's current
rows as inserts, delivered to that subscription alone without advancing
the observer revision.

subscribe() after dispose() used to register a listener that could never
fire; it now throws LiveQueryObserverDisposedError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): drive observer snapshots from a collection-owned state revision

The observer counted every delivery — including per-attach bootstrap
replays and empty ready flushes — as a semantic revision. One readiness
transition published three times ([], undefined, []), a plain
unsubscribe/resubscribe manufactured a new snapshot identity with
unchanged data, and rows committed while nothing was attached left the
cached snapshot stale.

The semantic clock now lives on the collection: emitEvents advances a
monotonic stateRevision once per committed batch, whether or not anyone
is subscribed. getSnapshot keys its cache on (stateRevision, status), so
detached snapshots stay fresh and attachment replay can no longer
advance the clock. Empty change batches are dropped from publication —
only real deltas and the synthetic ready notify go out — so a readiness
transition publishes exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(angular-db): align the mock collection with the real collection contract

The hand-rolled mock notified subscribers with empty change batches as a
wake-up signal — something real collections never do — and lacked the
state revision and status event channel the observer relies on. It now
advances _stateRevision on committed changes, emits real delete/insert
deltas from __replaceAll, and publishes status transitions through
on('status:change') instead of an empty notify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): publish collection status changes through the canonical path

The observer consumed row changes and onFirstReady but not the
collection's status events: a mounted consumer could sit on a stale
loading/ready status after an error or cleaned-up transition until an
unrelated row event happened to arrive. Status changes now publish a
synthetic notify through the same canonical path as data changes.

This also retires the onFirstReady registration, whose callbacks could
not be unsubscribed and accumulated across attach/detach cycles while
loading — collection.on('status:change') returns a real unsubscribe that
detach releases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): per-consumer initial-state policy; lazy snapshot materialization

Forcing includeInitialState on every attach was a behavior change for
the wholesale adapters: React and Angular never requested an initial
snapshot before the observer, and the forced request issued an
unfiltered loadSubset({ where: undefined }) against on-demand
collections. The observer now takes a mode option: granular (default —
Vue/Svelte/Solid) keeps the initial-state subscription and late-
subscriber seeding; wholesale (React/Angular) subscribes with
includeInitialState: false, restoring the pre-observer loading policy
while deletes still flow through as notifies.

getSnapshot() now materializes rows lazily on first state/data access,
so a consumer that only reads status never enumerates the collection.

The React already-ready microtask notify is gone with the bootstrap
replay; it existed because the pre-observer per-subscription version
could miss a ready transition between render and subscribe, which the
collection-owned revision plus useSyncExternalStore's post-subscribe
re-read now cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): remove deferInitialNotify — event reordering gone by construction

The deferred initial notify could be overtaken by a same-tick delta:
the bootstrap batch waited in a microtask while later changes emitted
synchronously, so a granular consumer could see v2 before v1.

The mechanism existed solely so React's useSyncExternalStore was not
notified during its own subscribe call. With React on wholesale mode
there is no bootstrap replay to defer — nothing is delivered
synchronously during a wholesale subscribe — so the deferral, its
attach-generation guard, and the reordering hazard are all removed.
Every publication is now delivered synchronously in commit order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): make observer construction inert — sync activates on first subscribe

Constructing an observer called startSyncImmediate(), so building one in
a render that is later abandoned (React concurrent rendering) activated
sync with no committed consumer. Construction is now side-effect-free:
activation happens through the first subscription's own addSubscriber
path — the identical startSync call — after the status listener is
wired, so the loading/ready transitions of a synchronously-starting
collection are observed and published instead of happening silently
before anyone listens.

The adapters' behavior is unchanged: React's input-resolution paths
start sync in render themselves (pre-existing, unchanged here), and the
effect-based adapters subscribe in the same tick they construct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(solid-db): generation-guard the resource's async continuations

Solid discards a superseded fetch's return value, but the fetcher's
post-await writes are side effects into hook-scoped state: switching
collections while toArrayWhenReady() was pending let the old
continuation resurrect the replaced collection's rows and status over
the new one's. Both the success and error continuations now check a
generation counter and no-op when superseded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(db): mark the observer as internal/unstable; honest changeset

The observer is a contract for TanStack DB's official adapters, not a
public extension point — the exported factory and interface now say so
(@internal, may change in any release). The changeset drops the false
"No behavior change" claim and describes the lifecycle fixes and the
per-adapter loading-policy preservation instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): address live query observer review

* fix(db): preserve wholesale consistency reads

* fix(db): capture granular initial loads

* fix(db): bind layout revisions to sync transactions

* fix(db): address post-merge review feedback

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com>
Co-authored-by: Tanner Linsley <tannerlinsley@gmail.com>
KyleAMathews added a commit that referenced this pull request Aug 12, 2026
)

* feat(db): shared live-query observer + migrate all five adapters

Add createLiveQueryObserver to @tanstack/db. Given a resolved collection (or
null for disabled), it owns the shared lifecycle: start sync, subscribe with
initial state, the loading→ready notify, a stable per-revision snapshot for
wholesale consumers, and delivery of the raw ChangeMessage[] for granular
consumers (deferInitialNotify defers the initial notify for useSyncExternalStore
consumers like React).

React, Vue, Svelte, Solid, and Angular all materialize from the observer,
removing their duplicated subscribe/status/ready-race plumbing while keeping
native reactivity: Vue/Svelte/Solid apply the change deltas granularly to their
reactive maps; React/Angular consume the snapshot wholesale.

Observer unit tests cover the wholesale and granular paths, disabled,
deferred-notify, and dispose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(db): make observer onFirstReady detach-safe

onFirstReady returns no unsubscribe and detach() couldn't remove it, so a
subscribe → unsubscribe-before-ready → subscribe sequence left a stale ready
callback that also fired on markReady — the current listener saw two synthetic
ready notifications instead of one. Guard the callback with an attach-generation
token so only the current attachment's callback notifies. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(db): address observer/react lifecycle review findings

- observer: getSnapshot() rebuilds when collection.status changes without a
  version bump (status-only loading→ready / preload with no active subscription),
  so a cached snapshot can't go stale.
- observer: guard the deferred initial-notify microtask with the attach
  generation + listener count, so a superseded attach can't flush a stale
  initial batch to a later listener.
- react: don't dispose the previous observer during render (unsafe under
  concurrent rendering) — useSyncExternalStore detaches it when the subscribe
  changes; dispose the current observer in an unmount effect instead.
- tests: regressions for the deferred-notify race and the status-only snapshot
  refresh (both verified red before the fixes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(react-db): don't dispose the observer in an unmount effect (StrictMode)

The unmount-effect dispose could run during StrictMode/offscreen effect replay
(mount → cleanup → mount) without a re-render, leaving observerRef pointing at a
disposed observer; the next subscribe hit attach()'s disposed guard and the
store stopped resubscribing. Remove the explicit dispose — useSyncExternalStore
already detaches the observer on unsubscribe/unmount, so the collection
subscription is torn down and the observer is GC'd. Adds a StrictMode regression
test (verified red before the fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(db): cover live-query keyed-state invariant on recompile

Expose the keyed `state` map in the shared conformance harness (added to
ConformanceResult and read by all five adapter drivers) and add a
steady-state `recompile-drops-stale-keys` scenario asserting the map stays
in sync with `data` across a narrowing recompile.

Also add a solid-db regression (in useLiveQuery.test.tsx) that inspects
`state` synchronously in the window after a recompile, where solid-db leaks
the previous collection's keys until its async resource reconciles. This
test fails until the follow-up fix (state.clear() before re-subscribing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(solid-db): clear keyed state before subscribing to a new collection

When the query recompiles to a different collection, the observer re-seeds
via `includeInitialState`, which only inserts current rows and never deletes
keys from the previous collection. Without clearing first, the dropped keys
lingered in `state` until the async resource reconciled — a transient window
where `state` exposed stale rows (though `data`, rebuilt wholesale, stayed
correct). Clear synchronously before re-subscribing, matching vue-db and
svelte-db.

Fixes the solid-db stale-keys regression added in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(db): republish ordered live queries on an order-only move (RFC #1623 phase 4)

An `orderBy` live query that reorders its rows without changing any projected
row value (an "order-only move") was swallowed by the collection's value-diff:
`.values()`/`.entries()` re-sorted, but no change event fired, so subscribers
kept the stale order. This is the last universal expected-fail in the
cross-adapter conformance suite (issue #1601).

Phase 4 of the live-query platform RFC calls for an explicit layout-revision
contract rather than a forged row `update`. This does that:

- The live-query flush captures the retracted side of each change and, after
  commit, detects an order-only move (value deep-equal, `orderByIndex` moved)
  and publishes a first-class empty layout-change notification via a new
  `CollectionChangesManager.emitLayoutChangeEvent()`.
- The shared observer snapshot gains `layoutRevision`, which increments on any
  visible membership, ordering, or order-only-move change.

All five adapters pick this up through their existing wholesale re-read, so the
`order-only-move` conformance scenario is removed from UNIVERSAL_EXPECTED_FAIL
and now passes on React, Vue, Svelte, Solid, and Angular.

Distinct from PR #1601 (v-anton), which fixes the same bug via a forced row
`update`; this uses the RFC's layout-revision approach instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: apply automated fixes

* refactor(db): compare key sequence directly for layoutRevision + fix doc

Addresses independent review of the layoutRevision contract:

- The join-with-separator signature could collide: a key value equal to the
  concatenation of neighboring keys around the separator produces the same
  string as two separate keys, so a real layout change (a membership change
  whose combined key spans the separator) was missed. Compare the ordered key
  sequence directly instead - collision-free, and it avoids materializing a
  large string on every snapshot rebuild (a new key array is only allocated
  when the layout actually moved). Adds a regression test.
- Correct the layoutRevision doc comment: it is NOT in lockstep with snapshot
  identity (a value-only update yields a new snapshot but the same
  layoutRevision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: apply automated fixes

* test(db): add failing regressions for Kyle's review findings

Two gaps in the order-only-move handling, reproduced as failing tests (to be
fixed in a follow-up commit):

1. A commit containing both an ordinary value update and an order-only move
   publishes twice (commit's row batch + the separate empty layout event),
   where exactly one publication is expected.
2. Ordered child collections produced by `includes` don't consume the
   insertion-side order metadata or publish a layout-only move, so an ordered
   child stays in its old order after a child order-only move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(db): coalesce layout publications and cover ordered includes children

Addresses Kyle's review of the order-only-move handling:

1. A commit containing both an ordinary value update and an order-only move
   published twice: commit() emitted the row batch and then the separate layout
   event fired redundantly. Replace hasOrderOnlyMove with
   needsLayoutOnlyPublication, which fires the layout event only when the commit
   published nothing else (any real insert/delete/value-changed update already
   notifies subscribers, who re-read the re-sorted collection).

2. Ordered child collections produced by includes did not reorder on an
   order-only child move:
   - The child accumulate replaced value on the insert side but left the
     retracted orderByIndex, so the child collection re-sorted against a stale
     index. Update orderByIndex on insert and capture the retract side (both the
     single-level and nested-includes accumulate blocks).
   - The child flush committed without a layout-only publication when the
     projected child value was unchanged. Publish one through the same
     mechanism (emitLayoutChange) when the child commit published nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(db): guard order-only moves in deeply-nested ordered includes

The includes flush is recursive, so the order-only-move handling must hold
beyond one level. Adds a two-level ordered-includes regression (org -> teams
-> members): moving a grandchild whose projected value is unchanged must
re-sort its collection and publish exactly once. Verified red when the
child-flush layout publication is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: apply automated fixes

* feat(db): shared live-query window controller (RFC #1623 phase 5)

Extracts the forward-pagination state machine out of react-db's
useLiveInfiniteQuery into a framework-agnostic createLiveQueryWindowController
in @tanstack/db, composing the shared live-query observer.

The controller owns loadedPageCount, the peek-ahead window (via
collection.utils.setWindow), page slicing, and hasNextPage/isFetchingNextPage,
and exposes a reactivity-free getSnapshot/subscribe/fetchNextPage/reset/dispose
surface mirroring the observer. react-db's useLiveInfiniteQuery is now a thin
binding over it with no public API change; its existing suite stays green.
Vue/Svelte/Solid/Angular can build infinite queries on the same controller
instead of re-porting React's logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: apply automated fixes

* fix(react-db): restore useLiveQuery type-only import for return type

UseLiveInfiniteQueryReturn references ReturnType<typeof useLiveQuery>, but the
import was dropped in the controller rewrite. vitest's typecheck missed it;
the package build (strict tsc) caught it (TS2304). Re-add as a type-only import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(react-db): react to runtime pageSize changes + restore window-mismatch warn

Addresses review of the window-controller extraction:

- pageSize/initialPageParam are now part of the controller-recreation check, so
  changing them at runtime re-windows and re-slices (the old hook had them in
  its effect/memo deps; the first controller draft baked them in at creation).
- Restore the one-time console.warn when a pre-created collection's existing
  window differs from the first page the hook enforces (dropped in the rewrite).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: apply automated fixes

* fix(db): FIFO non-reentrant observer dispatch over subscription records

A listener that synchronously mutates the collection used to trigger a
nested, reentrant dispatch: later subscribers could observe the nested
event (e.g. a delete) before the outer one (the insert) it reacted to.
Publications are now queued and dispatched FIFO.

Each publication is delivered over a snapshot of subscription records
taken when it is dispatched: a subscription removed mid-delivery still
receives the in-flight publication, one added mid-delivery does not.
Records — not raw callbacks — identify subscriptions, so subscribing the
same function twice no longer collapses into one Set entry whose first
unsubscribe tore down both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): release the collection subscription on dispose during initial replay

subscribeChanges delivers the initial state synchronously, so a listener
could dispose the observer before the subscription handle was stored —
detach() then had nothing to release and the collection subscription
leaked past disposal. The release hook is now registered before the
subscription is created, making attachment transactional: if detach()
fired mid-replay, the subscription is undone as soon as subscribeChanges
returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): seed late observer subscribers; reject subscribe after dispose

The initial-state replay only happened on the first attach, so a second
concurrent subscriber started with no rows and could never converge —
its keyed map silently stayed empty. A subscriber arriving while the
observer is already attached is now seeded with the collection's current
rows as inserts, delivered to that subscription alone without advancing
the observer revision.

subscribe() after dispose() used to register a listener that could never
fire; it now throws LiveQueryObserverDisposedError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): drive observer snapshots from a collection-owned state revision

The observer counted every delivery — including per-attach bootstrap
replays and empty ready flushes — as a semantic revision. One readiness
transition published three times ([], undefined, []), a plain
unsubscribe/resubscribe manufactured a new snapshot identity with
unchanged data, and rows committed while nothing was attached left the
cached snapshot stale.

The semantic clock now lives on the collection: emitEvents advances a
monotonic stateRevision once per committed batch, whether or not anyone
is subscribed. getSnapshot keys its cache on (stateRevision, status), so
detached snapshots stay fresh and attachment replay can no longer
advance the clock. Empty change batches are dropped from publication —
only real deltas and the synthetic ready notify go out — so a readiness
transition publishes exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(angular-db): align the mock collection with the real collection contract

The hand-rolled mock notified subscribers with empty change batches as a
wake-up signal — something real collections never do — and lacked the
state revision and status event channel the observer relies on. It now
advances _stateRevision on committed changes, emits real delete/insert
deltas from __replaceAll, and publishes status transitions through
on('status:change') instead of an empty notify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): publish collection status changes through the canonical path

The observer consumed row changes and onFirstReady but not the
collection's status events: a mounted consumer could sit on a stale
loading/ready status after an error or cleaned-up transition until an
unrelated row event happened to arrive. Status changes now publish a
synthetic notify through the same canonical path as data changes.

This also retires the onFirstReady registration, whose callbacks could
not be unsubscribed and accumulated across attach/detach cycles while
loading — collection.on('status:change') returns a real unsubscribe that
detach releases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): per-consumer initial-state policy; lazy snapshot materialization

Forcing includeInitialState on every attach was a behavior change for
the wholesale adapters: React and Angular never requested an initial
snapshot before the observer, and the forced request issued an
unfiltered loadSubset({ where: undefined }) against on-demand
collections. The observer now takes a mode option: granular (default —
Vue/Svelte/Solid) keeps the initial-state subscription and late-
subscriber seeding; wholesale (React/Angular) subscribes with
includeInitialState: false, restoring the pre-observer loading policy
while deletes still flow through as notifies.

getSnapshot() now materializes rows lazily on first state/data access,
so a consumer that only reads status never enumerates the collection.

The React already-ready microtask notify is gone with the bootstrap
replay; it existed because the pre-observer per-subscription version
could miss a ready transition between render and subscribe, which the
collection-owned revision plus useSyncExternalStore's post-subscribe
re-read now cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): remove deferInitialNotify — event reordering gone by construction

The deferred initial notify could be overtaken by a same-tick delta:
the bootstrap batch waited in a microtask while later changes emitted
synchronously, so a granular consumer could see v2 before v1.

The mechanism existed solely so React's useSyncExternalStore was not
notified during its own subscribe call. With React on wholesale mode
there is no bootstrap replay to defer — nothing is delivered
synchronously during a wholesale subscribe — so the deferral, its
attach-generation guard, and the reordering hazard are all removed.
Every publication is now delivered synchronously in commit order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): make observer construction inert — sync activates on first subscribe

Constructing an observer called startSyncImmediate(), so building one in
a render that is later abandoned (React concurrent rendering) activated
sync with no committed consumer. Construction is now side-effect-free:
activation happens through the first subscription's own addSubscriber
path — the identical startSync call — after the status listener is
wired, so the loading/ready transitions of a synchronously-starting
collection are observed and published instead of happening silently
before anyone listens.

The adapters' behavior is unchanged: React's input-resolution paths
start sync in render themselves (pre-existing, unchanged here), and the
effect-based adapters subscribe in the same tick they construct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(solid-db): generation-guard the resource's async continuations

Solid discards a superseded fetch's return value, but the fetcher's
post-await writes are side effects into hook-scoped state: switching
collections while toArrayWhenReady() was pending let the old
continuation resurrect the replaced collection's rows and status over
the new one's. Both the success and error continuations now check a
generation counter and no-op when superseded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(db): mark the observer as internal/unstable; honest changeset

The observer is a contract for TanStack DB's official adapters, not a
public extension point — the exported factory and interface now say so
(@internal, may change in any release). The changeset drops the false
"No behavior change" claim and describes the lifecycle fixes and the
per-adapter loading-policy preservation instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): address live query observer review

* fix(db): preserve wholesale consistency reads

* fix(db): capture granular initial loads

* fix(db): harden live query window subscriptions

* fix(db): bind layout revisions to sync transactions

* fix(db): address post-merge review feedback

* fix(db): harden live query window ownership

* fix(db): close window controller race gaps

* fix(db): preserve live query observer invariants

* fix(react-db): handle pagination load failures

* fix(db): harden live query window coordination

* docs: update window controller release notes

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com>
Co-authored-by: Tanner Linsley <tannerlinsley@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant