Skip to content

feat(db): shared live-query observer + migrate all five adapters (RFC #1623 step 3)#1642

Open
kevin-dp wants to merge 17 commits into
mainfrom
refactor/live-query-observer
Open

feat(db): shared live-query observer + migrate all five adapters (RFC #1623 step 3)#1642
kevin-dp wants to merge 17 commits into
mainfrom
refactor/live-query-observer

Conversation

@kevin-dp

@kevin-dp kevin-dp commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

RFC #1623 step 3 — the shared observer that unifies the live-query lifecycle, now adopted by all five adapters. This is where the duplicated lifecycle across adapters actually collapses.

Base: stacked on refactor/extract-adapter-helpers (#1641, step 2) → conformance suite (#1636). Re-target down the stack as each lands.

The observer

createLiveQueryObserver(collection | null, { deferInitialNotify? }) in @tanstack/db. Given a resolved live-query collection (or null for disabled), it owns everything the five adapters used to each re-implement:

  • start sync
  • subscribe to changes (with includeInitialState, so consumers get initial rows + deltas through one aligned channel)
  • the already-ready / loading→ready notify race (onFirstReady, de-duplicated)
  • a stable per-revision snapshot for wholesale consumers (getSnapshot() — reference-stable for useSyncExternalStore)
  • delivery of the raw ChangeMessage[] to subscribers for granular consumers

The last two are the item-6 decision made concrete: the observer carries both a snapshot and the change set, so Vue/Svelte/Solid keep fine-grained keyed-map updates while React/Angular consume the snapshot.

Input resolution stays in each adapter (query fn / config / collection / disabled) — it's framework-reactive and fixed separately (#1637/#1638). The observer owns everything after the input resolves.

All five adapters migrated

Each keeps its native reactivity; the duplicated subscribe/onFirstReady/status/ready-race plumbing is gone:

Adapter Consumer kind How it materializes
React wholesale useSyncExternalStore(observer.subscribe, observer.getSnapshot) — opts into deferInitialNotify
Vue granular applies ChangeMessage[] deltas to its reactive map
Svelte granular applies deltas to its SvelteMap (runes)
Solid granular applies deltas to its ReactiveMap; keeps createResource/Suspense + reconcile
Angular wholesale re-reads the collection into signals on each notify

Two contract refinements the migrations forced (both interesting)

  • includeInitialState is required, not optional. Seeding a granular adapter's map from getSnapshot() and subscribing without initial state desyncs the collection's per-subscriber change stream — deletes arrived as empty batches. The observer must subscribe with initial state so initial rows + deltas flow through one channel.
  • deferInitialNotify is per-consumer. React's useSyncExternalStore must not get a synchronous notify during subscribe, so React opts into deferring the initial notify to a microtask. Effect/watcher adapters (Svelte reads synchronously after flushSync) want it synchronous, which is the default.

Bonus

Angular's config-object-input conformance gap closes here as a side effect — the observer starts sync on the resolved collection regardless of the config path. (PR #1638 still fixes the source path directly.)

Verification

All green: @tanstack/db 2461, react 119, vue 54, svelte 54, solid 62, angular 50 (+1 todo) — including the cross-adapter conformance suite that guards every migration. Observer unit tests cover both wholesale and granular paths, disabled, deferred-notify, and dispose. Minor changeset for db, patch for all five adapters.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a shared live-query observer API (createLiveQueryObserver) available from the main DB package.
    • Exposed live-query state in adapter conformance results/handles (React, Vue, Svelte, Solid, Angular) and added a new conformance scenario.
    • Updated live-query hooks to use the shared observer for consistent behavior across frameworks.
  • Bug Fixes

    • Improved live-query lifecycle correctness (readiness transitions, cleanup/disposal, and preventing stale updates), plus stable snapshot/state consistency across resubscribe and recompile.
  • Tests

    • Added observer, React StrictMode, and additional live-query state/async continuation test coverage.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a shared LiveQueryObserver to @tanstack/db, exposes it through the package index, and migrates React, Vue, Svelte, Solid, and Angular live-query hooks to use it. It also expands lifecycle, conformance, and adapter regression coverage and records release metadata.

Changes

Shared live-query observer migration

Layer / File(s) Summary
LiveQueryObserver implementation, exports, and tests
packages/db/src/live-query-observer.ts, packages/db/src/collection/*, packages/db/src/errors.ts, packages/db/src/index.ts, packages/db/tests/live-query-observer.test.ts
Adds revision-aware snapshots, granular and wholesale delivery modes, status notifications, FIFO dispatch, disposal handling, disabled-query support, and comprehensive lifecycle tests.
Framework hook migrations
packages/react-db/..., packages/vue-db/..., packages/svelte-db/..., packages/solid-db/..., packages/angular-db/...
Routes framework live-query synchronization, status updates, subscriptions, and cleanup through createLiveQueryObserver; Solid guards superseded async continuations and React coverage includes StrictMode behavior.
Angular observer-compatible test collection
packages/angular-db/tests/inject-live-query.test.ts
Updates the mock collection with state revisions, status events, immediate sync activation, and explicit row-level replacement changes.
Conformance state contract and regression coverage
packages/db/tests/conformance/..., packages/*-db/tests/conformance*, packages/solid-db/tests/useLiveQuery.test.tsx, packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx
Exposes keyed state through conformance drivers, verifies stale keys are removed after recompilation, and covers adapter synchronization and notification timing.
Release metadata
.changeset/live-query-observer.md
Documents package version bumps and the shared observer migration.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • TanStack/db#1594: Related to React subscription notification timing and deferred initial updates.
  • TanStack/db#1636: Related to the shared conformance result shape and Angular conformance handling.
  • TanStack/db#1641: Related to migrating framework live-query hooks to shared database helpers.

Suggested reviewers: samwillis

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the shared live-query observer and adapter migration, matching the main change.
Description check ✅ Passed It covers the change, rationale, adapter migration, and verification, but omits the template's checklist and release-impact sections.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/live-query-observer

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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.

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1642

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1642

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1642

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1642

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1642

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1642

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1642

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1642

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1642

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1642

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1642

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1642

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1642

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1642

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1642

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1642

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1642

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1642

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1642

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1642

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1642

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1642

commit: b24d163

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Size Change: +1.6 kB (+1.28%)

Total Size: 127 kB

📦 View Changed
Filename Size Change
packages/db/dist/esm/collection/changes.js 1.4 kB +22 B (+1.59%)
packages/db/dist/esm/collection/index.js 3.73 kB +112 B (+3.09%)
packages/db/dist/esm/errors.js 5.13 kB +37 B (+0.73%)
packages/db/dist/esm/index.js 3.21 kB +44 B (+1.39%)
packages/db/dist/esm/live-query-observer.js 1.39 kB +1.39 kB (new file) 🆕
ℹ️ View Unchanged
Filename Size
packages/db/dist/esm/collection/change-events.js 1.43 kB
packages/db/dist/esm/collection/cleanup-queue.js 810 B
packages/db/dist/esm/collection/events.js 434 B
packages/db/dist/esm/collection/indexes.js 1.99 kB
packages/db/dist/esm/collection/lifecycle.js 1.69 kB
packages/db/dist/esm/collection/mutations.js 2.47 kB
packages/db/dist/esm/collection/state.js 5.48 kB
packages/db/dist/esm/collection/subscription.js 3.74 kB
packages/db/dist/esm/collection/sync.js 2.88 kB
packages/db/dist/esm/collection/transaction-metadata.js 144 B
packages/db/dist/esm/deferred.js 207 B
packages/db/dist/esm/event-emitter.js 748 B
packages/db/dist/esm/indexes/auto-index.js 829 B
packages/db/dist/esm/indexes/base-index.js 767 B
packages/db/dist/esm/indexes/basic-index.js 2.06 kB
packages/db/dist/esm/indexes/btree-index.js 2.19 kB
packages/db/dist/esm/indexes/index-registry.js 820 B
packages/db/dist/esm/indexes/reverse-index.js 557 B
packages/db/dist/esm/live-query-adapter.js 318 B
packages/db/dist/esm/local-only.js 916 B
packages/db/dist/esm/local-storage.js 2.12 kB
packages/db/dist/esm/optimistic-action.js 359 B
packages/db/dist/esm/paced-mutations.js 496 B
packages/db/dist/esm/proxy.js 3.75 kB
packages/db/dist/esm/query/builder/functions.js 1.47 kB
packages/db/dist/esm/query/builder/index.js 5.84 kB
packages/db/dist/esm/query/builder/ref-proxy.js 1.24 kB
packages/db/dist/esm/query/compiler/evaluators.js 1.89 kB
packages/db/dist/esm/query/compiler/expressions.js 430 B
packages/db/dist/esm/query/compiler/group-by.js 3.56 kB
packages/db/dist/esm/query/compiler/index.js 6.67 kB
packages/db/dist/esm/query/compiler/joins.js 2.5 kB
packages/db/dist/esm/query/compiler/lazy-targets.js 923 B
packages/db/dist/esm/query/compiler/order-by.js 1.74 kB
packages/db/dist/esm/query/compiler/select.js 1.53 kB
packages/db/dist/esm/query/effect.js 4.77 kB
packages/db/dist/esm/query/expression-helpers.js 1.43 kB
packages/db/dist/esm/query/ir.js 1.25 kB
packages/db/dist/esm/query/live-query-collection.js 360 B
packages/db/dist/esm/query/live/collection-config-builder.js 9.1 kB
packages/db/dist/esm/query/live/collection-registry.js 264 B
packages/db/dist/esm/query/live/collection-subscriber.js 1.93 kB
packages/db/dist/esm/query/live/internal.js 145 B
packages/db/dist/esm/query/live/utils.js 1.81 kB
packages/db/dist/esm/query/optimizer.js 2.92 kB
packages/db/dist/esm/query/predicate-utils.js 2.97 kB
packages/db/dist/esm/query/query-once.js 359 B
packages/db/dist/esm/query/subset-dedupe.js 960 B
packages/db/dist/esm/scheduler.js 1.3 kB
packages/db/dist/esm/SortedMap.js 1.3 kB
packages/db/dist/esm/strategies/debounceStrategy.js 247 B
packages/db/dist/esm/strategies/queueStrategy.js 428 B
packages/db/dist/esm/strategies/throttleStrategy.js 246 B
packages/db/dist/esm/transactions.js 3.04 kB
packages/db/dist/esm/utils.js 927 B
packages/db/dist/esm/utils/array-utils.js 273 B
packages/db/dist/esm/utils/browser-polyfills.js 304 B
packages/db/dist/esm/utils/btree.js 5.61 kB
packages/db/dist/esm/utils/comparison.js 1.11 kB
packages/db/dist/esm/utils/cursor.js 457 B
packages/db/dist/esm/utils/index-optimization.js 2.39 kB
packages/db/dist/esm/utils/type-guards.js 157 B
packages/db/dist/esm/utils/uuid.js 449 B
packages/db/dist/esm/virtual-props.js 360 B

compressed-size-action::db-package-size

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 4.22 kB

ℹ️ View Unchanged
Filename Size
packages/react-db/dist/esm/index.js 249 B
packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.32 kB
packages/react-db/dist/esm/useLiveQuery.js 1.33 kB
packages/react-db/dist/esm/useLiveQueryEffect.js 355 B
packages/react-db/dist/esm/useLiveSuspenseQuery.js 567 B
packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

@kevin-dp kevin-dp changed the title feat(db): shared live-query observer + React migration (RFC #1623 step 3) feat(db): shared live-query observer + migrate all five adapters (RFC #1623 step 3) Jul 2, 2026
knownGaps: [`config-object-input`],
// The config-object gap is now closed here: the observer starts sync on the
// resolved collection regardless of the config path, so a bare `{ query }`
// syncs. (PR #1638 also fixes the source path directly.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This comment is not needed. It only makes sense with the scope of this PR but no longer makes sense when we will merge it into main.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in cf92efd — the comment was scoped to this PR and wouldn't make sense on main.

knownGaps: [`config-object-input`],
// The config-object gap is now closed here: the observer starts sync on the
// resolved collection regardless of the config path, so a bare `{ query }`
// syncs. (PR #1638 also fixes the source path directly.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

let's remove this comment, it only makes sense within this PR but in main it won't make sense anymore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in cf92efd — the comment was scoped to this PR and wouldn't make sense on main.

@kevin-dp
kevin-dp force-pushed the refactor/extract-adapter-helpers branch from 92f19fa to 84895f6 Compare July 7, 2026 08:22
@kevin-dp
kevin-dp force-pushed the refactor/live-query-observer branch from 4c86c7f to a7e3c5e Compare July 7, 2026 08:22
@kevin-dp
kevin-dp force-pushed the refactor/extract-adapter-helpers branch from 84895f6 to 84f4e93 Compare July 7, 2026 09:55
@kevin-dp
kevin-dp force-pushed the refactor/live-query-observer branch 2 times, most recently from 3b3c92e to cf92efd Compare July 7, 2026 10:05
@kevin-dp
kevin-dp force-pushed the refactor/extract-adapter-helpers branch from 84f4e93 to 4cf25ae Compare July 7, 2026 10:24
@kevin-dp
kevin-dp force-pushed the refactor/live-query-observer branch from cf92efd to 68ea1ca Compare July 7, 2026 10:24
@KyleAMathews

Copy link
Copy Markdown
Collaborator

Code review

Found 1 issue:

  1. onFirstReady registration is not detach-safe. LiveQueryObserverImpl.attach() registers a callback every time the observer gets its first listener while the collection is not ready, but detach() only unsubscribes the change subscription. Since CollectionLifecycleManager.onFirstReady() only pushes into an array and does not return an unsubscribe, a subscribe → unsubscribe before ready → subscribe again sequence leaves two callbacks queued; when markReady() drains them, both call notify(undefined) against the current listener.

// Catch a *later* loading→ready transition that carries no change events
// (e.g. `markReady()` with no rows). Skip when already ready — the initial
// state batch above already covers that, and `onFirstReady` would fire an
// immediate duplicate.
if (collection.status !== `ready`) {
collection.onFirstReady(() => notify(undefined))
}

public onFirstReady(callback: () => void): void {
// If already ready, call immediately
if (this.hasBeenReady) {
callback()
return
}
this.onFirstReadyCallbacks.push(callback)

const callbacks = [...this.onFirstReadyCallbacks]
this.onFirstReadyCallbacks = []
callbacks.forEach((callback) => callback())

I red-tested this locally with a temporary observer test on this branch: after subscribing, unsubscribing before first ready, subscribing again, and then calling markReady, the listener saw 2 synthetic ready notifications where the expected value was 1.

Suggested fix: either make onFirstReady return an unsubscribe and dispose it with collectionUnsub, or arm only one ready callback per observer/collection and guard it with a generation token so stale callbacks no-op after detach/dispose.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@kevin-dp

kevin-dp commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — reproduced it exactly (subscribe → unsubscribe-before-ready → subscribe → markReady gave 2 synthetic ready notifications instead of 1). Fixed in 8ee8e8e with your option B: an attach-generation token, so a callback left behind by a superseded attach no-ops instead of notifying. Went with the observer-local guard rather than changing onFirstReady's signature to keep the fix contained. Added a regression test for the sequence.

Base automatically changed from refactor/extract-adapter-helpers to main July 9, 2026 07:29
@kevin-dp
kevin-dp marked this pull request as ready for review July 9, 2026 07:29
kevin-dp and others added 2 commits July 9, 2026 09:30
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>
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>
@kevin-dp
kevin-dp force-pushed the refactor/live-query-observer branch from 8ee8e8e to e957121 Compare July 9, 2026 07:32

@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: 5

🧹 Nitpick comments (3)
packages/db/src/live-query-observer.ts (1)

24-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the collection generic instead of exposing any.

The observer API can use Collection<T, TKey> here; the default utility generic keeps the API precise without leaking any.

Proposed typing cleanup
-  collection: Collection<T, TKey, any> | undefined
+  collection: Collection<T, TKey> | undefined
...
-  private readonly collection: Collection<T, TKey, any> | null
+  private readonly collection: Collection<T, TKey> | null
...
-    collection: Collection<T, TKey, any> | null,
+    collection: Collection<T, TKey> | null,
...
-  collection: Collection<T, TKey, any> | null | undefined,
+  collection: Collection<T, TKey> | null | undefined,

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: 86-100, 248-253

🤖 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/live-query-observer.ts` at line 24, The observer typings are
leaking `any` through the collection generic, so tighten the `LiveQueryObserver`
API to use `Collection<T, TKey>` (or the existing default utility generic)
instead of `Collection<T, TKey, any>`. Update the `collection` field and the
related observer methods/types in `live-query-observer` that reference this
shape, including the other affected spots noted in the diff, so the generic
stays precise without exposing `any`.

Source: Coding guidelines

packages/db/tests/live-query-observer.test.ts (1)

45-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the as any casts from observer tests.

These casts hide whether the new public factory accepts the collection types it is expected to support.

As per coding guidelines, “Avoid using any types; use unknown instead when the type is truly unknown.”

Also applies to: 60-60, 92-92, 121-123, 138-138

🤖 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-observer.test.ts` at line 45, The observer tests
are masking the real type compatibility of createLiveQueryObserver by casting
sources to any; update the affected test cases to pass properly typed
collection/source values instead of any so the public factory is exercised
against its supported collection types. Use the existing test helpers and types
around createLiveQueryObserver, makeSource, and the other affected observer
assertions to infer the correct typings, and replace any remaining any casts in
the referenced test blocks with explicit, appropriate types.

Source: Coding guidelines

packages/react-db/src/useLiveQuery.ts (1)

435-439: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid erasing the hook return type with any.

The new as any bypasses the overload contracts at the return boundary. Please cast to the hook’s concrete result type or add a typed adapter from LiveQuerySnapshot to the existing public return shape. As per coding guidelines, “Avoid using any types” and “Always provide the most precise return type annotation.”

🤖 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/src/useLiveQuery.ts` around lines 435 - 439, The return in
useLiveQuery is erasing the hook’s type contract with as any, which bypasses the
overload guarantees. Update the useSyncExternalStore result to preserve the
concrete hook return type by casting to the existing public snapshot/result type
or introducing a typed adapter from LiveQuerySnapshot, and keep the return
aligned with the function’s declared overloads instead of using any.

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/src/live-query-observer.ts`:
- Around line 112-115: The snapshot cache in `LiveQueryObserver.getSnapshot()`
can stay stale when `preload()` or a status-only transition makes the collection
ready without bumping `version`. Update the cache invalidation logic so
`preload()` and any readiness/status changes also force a cached snapshot
refresh, not just version changes. Use the existing `cachedVersion`, `version`,
and `getSnapshot()` flow, and make sure the invalidation also covers the related
status transition path referenced in the observer.
- Around line 199-204: The deferred initial notification flush in
LiveQueryObserver can still emit stale changes after a
subscribe/unsubscribe/resubscribe cycle. Update the deferInitialNotify path in
live-query-observer’s attach logic to associate the queued microtask with the
specific attach instance (for example via an incrementing attach token or
generation) and skip flushing if a newer attach has superseded it or the
observer was detached. Ensure the guard is checked before emitting deferred
changes in the queueMicrotask callback.

In `@packages/db/tests/live-query-observer.test.ts`:
- Around line 120-155: Add regression coverage for the remaining lifecycle races
in createLiveQueryObserver: extend the existing observer tests to reproduce a
deferInitialNotify flow where a subscription is unsubscribed and then
resubscribed before the microtask flush, and verify only the latest subscription
receives the deferred notify. Also add a test around getSnapshot() and preload()
that calls getSnapshot() before preload(), then asserts a ready snapshot is
returned after preload() completes. Use the existing createLiveQueryObserver,
subscribe, getSnapshot, and preload behavior to keep the tests aligned with the
bug scenarios.

In `@packages/react-db/src/useLiveQuery.ts`:
- Around line 414-423: Move the observer teardown out of the render path in
useLiveQuery: the needsNewCollection branch should not call
observerRef.current?.dispose() during render, because that can tear down the
committed subscription before React finishes updating. Update useLiveQuery to
perform observer disposal/recreation in a commit-phase effect or cleanup, and
add separate state/flag tracking for initialization so disabled queries do not
recreate the observer on every render when collectionRef.current remains null.

In `@packages/solid-db/src/useLiveQuery.ts`:
- Around line 393-397: Clear the existing Solid state before wiring up the new
observer in useLiveQuery so stale rows from the previous collection are removed
when collection() changes. Add the reset immediately before
createLiveQueryObserver/currentCollection subscription setup, and keep the logic
localized around observer.subscribe so includeInitialState only repopulates the
new collection instead of leaving old keys behind.

---

Nitpick comments:
In `@packages/db/src/live-query-observer.ts`:
- Line 24: The observer typings are leaking `any` through the collection
generic, so tighten the `LiveQueryObserver` API to use `Collection<T, TKey>` (or
the existing default utility generic) instead of `Collection<T, TKey, any>`.
Update the `collection` field and the related observer methods/types in
`live-query-observer` that reference this shape, including the other affected
spots noted in the diff, so the generic stays precise without exposing `any`.

In `@packages/db/tests/live-query-observer.test.ts`:
- Line 45: The observer tests are masking the real type compatibility of
createLiveQueryObserver by casting sources to any; update the affected test
cases to pass properly typed collection/source values instead of any so the
public factory is exercised against its supported collection types. Use the
existing test helpers and types around createLiveQueryObserver, makeSource, and
the other affected observer assertions to infer the correct typings, and replace
any remaining any casts in the referenced test blocks with explicit, appropriate
types.

In `@packages/react-db/src/useLiveQuery.ts`:
- Around line 435-439: The return in useLiveQuery is erasing the hook’s type
contract with as any, which bypasses the overload guarantees. Update the
useSyncExternalStore result to preserve the concrete hook return type by casting
to the existing public snapshot/result type or introducing a typed adapter from
LiveQuerySnapshot, and keep the return aligned with the function’s declared
overloads instead of using any.
🪄 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: a2f170d5-edf2-466d-b219-bdc647b51052

📥 Commits

Reviewing files that changed from the base of the PR and between 6d4c096 and e957121.

📒 Files selected for processing (10)
  • .changeset/live-query-observer.md
  • packages/angular-db/src/index.ts
  • packages/angular-db/tests/conformance.test.ts
  • packages/db/src/index.ts
  • packages/db/src/live-query-observer.ts
  • packages/db/tests/live-query-observer.test.ts
  • packages/react-db/src/useLiveQuery.ts
  • packages/solid-db/src/useLiveQuery.ts
  • packages/svelte-db/src/useLiveQuery.svelte.ts
  • packages/vue-db/src/useLiveQuery.ts

Comment thread packages/db/src/live-query-observer.ts Outdated
Comment thread packages/db/src/live-query-observer.ts Outdated
Comment thread packages/db/tests/live-query-observer.test.ts Outdated
Comment thread packages/react-db/src/useLiveQuery.ts Outdated
Comment thread packages/solid-db/src/useLiveQuery.ts
- 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>

@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

🤖 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/react-db/src/useLiveQuery.ts`:
- Around line 428-434: The unmount cleanup in useLiveQuery’s observer lifecycle
is disposing the shared observer too early, which can leave observerRef.current
pointing to a disposed instance during StrictMode/offscreen replay. Update the
useEffect cleanup to avoid disposing the observer there, or ensure the observer
is recreated before reuse when attach() is called on a disposed instance. Keep
the fix localized to useLiveQuery and the observerRef/useSyncExternalStore
subscription path so the next subscribe always gets a live observer.
🪄 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: a7b1d806-2a37-4fb2-b0ff-7b3a757303ed

📥 Commits

Reviewing files that changed from the base of the PR and between e957121 and c05a726.

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

Comment thread packages/react-db/src/useLiveQuery.ts Outdated
…tMode)

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>
KyleAMathews added a commit that referenced this pull request Jul 10, 2026
…nation with adapter-platform track (#1642)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kevin-dp and others added 2 commits July 13, 2026 11:39
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>
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>
@kevin-dp
kevin-dp force-pushed the refactor/live-query-observer branch from 557c4a0 to 2a0679e Compare July 13, 2026 09:39

@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 (2)
packages/solid-db/tests/useLiveQuery.test.tsx (1)

526-568: 🧹 Nitpick | 🔵 Trivial

LGTM!

The test correctly validates the synchronous state-clearing fix — setMinAge(32) triggers the effect synchronously (outside any batch), state.clear() runs before the observer re-seeds via includeInitialState, and the assertions read state with no settle. The dispose() call properly triggers onCleanup which disposes the observer.

Optional nit: adding expect(rendered.result.state.has('3')).toBe(true) alongside the has('1')/has('2') checks would make the positive case explicit and self-documenting, rather than relying on elimination from size === 1.

✨ Optional: assert the surviving key explicitly
       expect(rendered.result.state.size).toBe(1)
       expect(rendered.result.state.has(`1`)).toBe(false)
       expect(rendered.result.state.has(`2`)).toBe(false)
+      expect(rendered.result.state.has(`3`)).toBe(true)
🤖 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/solid-db/tests/useLiveQuery.test.tsx` around lines 526 - 568, Add an
explicit positive assertion in the stale-key narrowing test after setMinAge(32),
verifying rendered.result.state.has(`3`) is true alongside the existing size and
removed-key assertions.
packages/db/tests/conformance/contract.ts (1)

77-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use unknown instead of any in the new contract property.

This new field represents intentionally unknown key/value types. Prefer ReadonlyMap<unknown, unknown> | undefined; this preserves type safety without weakening the conformance contract.

As per coding guidelines, TypeScript code should avoid any and use unknown when the type is truly unknown.

🤖 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/conformance/contract.ts` around lines 77 - 82, Update the
new contract property state in the conformance contract from ReadonlyMap<any,
any> | undefined to ReadonlyMap<unknown, unknown> | undefined, preserving its
optional semantics and existing documentation.

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/conformance/contract.ts`:
- Around line 77-82: Update the new contract property state in the conformance
contract from ReadonlyMap<any, any> | undefined to ReadonlyMap<unknown, unknown>
| undefined, preserving its optional semantics and existing documentation.

In `@packages/solid-db/tests/useLiveQuery.test.tsx`:
- Around line 526-568: Add an explicit positive assertion in the stale-key
narrowing test after setMinAge(32), verifying rendered.result.state.has(`3`) is
true alongside the existing size and removed-key assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e153c660-9ffb-4f29-b1ec-34a25edff3ec

📥 Commits

Reviewing files that changed from the base of the PR and between fd3c87d and 2a0679e.

📒 Files selected for processing (9)
  • packages/angular-db/tests/conformance.test.ts
  • packages/db/tests/conformance/contract.ts
  • packages/db/tests/conformance/suite.ts
  • packages/react-db/tests/conformance.test.tsx
  • packages/solid-db/src/useLiveQuery.ts
  • packages/solid-db/tests/conformance.test.tsx
  • packages/solid-db/tests/useLiveQuery.test.tsx
  • packages/svelte-db/tests/conformance.svelte.test.ts
  • packages/vue-db/tests/conformance.test.ts

@KyleAMathews KyleAMathews left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The shared observer is a good direction, but focused regression tests against 2a0679e0 uncovered several lifecycle and semantic-clock defects that the existing conformance suite does not cover. I’m requesting changes because every migrated adapter now depends on these foundations.

Blocking findings

1. Bootstrap delivery is being counted as semantic state revisions

A loading empty collection currently emits an initial []; markReady() then emits undefined followed by another []. One readiness transition therefore produces two post-bootstrap revisions. Unsubscribe/resubscribe also creates a new snapshot when data and status are unchanged.

Failing tests observed:

expect(events).toEqual([[], undefined, []]) // actual sequence
expect(postReadyNotifications).toBe(1)      // failed: received 2

const before = observer.getSnapshot()
observer.subscribe(() => {})()
observer.subscribe(() => {})()
expect(observer.getSnapshot()).toBe(before) // failed

Please separate per-subscriber bootstrap delivery from observer-wide semantic revisions, coalesce readiness through one canonical path, and preserve snapshot identity when observable state has not changed.

2. Status-only transitions do not wake consumers

The observer consumes row changes and onFirstReady, but not the collection’s status event channel. A mounted consumer can therefore remain on loading or ready after error/cleaned-up until an unrelated row event occurs. Repeated attach/detach while loading also accumulates non-removable onFirstReady callbacks.

Failing tests observed:

collection._lifecycle.setStatus('error')
collection._lifecycle.setStatus('cleaned-up')
expect(statuses).toContain('error')       // failed
expect(statuses).toContain('cleaned-up') // failed

expect(onFirstReadyCallbacks.size).toBe(0) // failed: 3 retained after 3 cycles

Please make status events part of the canonical publication path and avoid permanent readiness callbacks. The RFC also requires carrying the actual error as unknown, rather than exposing only isError; that appears to require plumbing the originating error through collection lifecycle state.

3. Snapshot freshness and identity are incorrect while detached

Rows changed while detached can leave the cached snapshot stale, while reattachment manufactures revisions even without an observable change.

const unsubscribe = observer.subscribe(() => {})
const before = observer.getSnapshot()
unsubscribe()
mutateCollectionWithoutChangingStatus()
const after = observer.getSnapshot()
expect(after).not.toBe(before)       // failed
expect(after.data).toEqual(expected) // failed: stale rows

The semantic clock needs either a collection-owned observable revision or a canonical observer materialization retained for the observer lifetime. Attachment replay itself should not advance that clock.

4. Dispatch is reentrant and subscription mutation affects in-flight delivery

Direct Set.forEach dispatch allows nested events to overtake outer events. In a focused regression, listener A synchronously deleted a row while handling its insert; listener B received delete before insert and could finish inconsistent with the collection.

The following also reproduced: a listener added during dispatch received the event in progress; a removed listener missed the event in progress; and two logical subscriptions using the same callback broke teardown ownership.

expect(listenerBEvents).toEqual(['insert', 'delete'])
// failed: ['delete', 'insert']
expect(listenerAddedDuringDispatchCalls).toBe(0) // failed
expect(existingListenerCalls).toBe(1)            // failed after in-flight removal
expect(source.subscriberCount).toBe(1)            // failed for duplicate callback

Please queue nested publications FIFO, dispatch each publication over a snapshot of subscription records, and identify logical subscriptions independently of callback identity.

5. Synchronous initial replay can leak the underlying subscription

subscribeChanges() invokes initial delivery before returning, so a listener can dispose the observer while collectionUnsub is still unset. The returned collection subscription is then retained after disposal.

observer.subscribe(() => observer.dispose())
expect(source.subscriberCount).toBe(0) // failed: 1

Please make attachment transactional or immediately unsubscribe if disposal occurred before subscribeChanges() returned.

6. Deferred initial notification can reorder events

A same-tick update can be delivered before the older deferred initial batch.

expect(events).toEqual(['v1', 'v2'])
// failed: ['v2', 'v1']

Please queue subsequent deltas until the deferred bootstrap delivery has flushed.

7. Granular subscriber semantics are incomplete

A second concurrent subscriber does not receive current rows, and subscribe() after dispose() silently registers a listener that can never be called.

expect(secondSubscriberKeys).toEqual(['1', '2']) // failed: []
expect(() => observer.subscribe(listener)).toThrow() // failed after dispose

Please seed every new granular subscriber without advancing the semantic revision, and either reject subscriptions after disposal or explicitly support revival.

8. Observer and React collection resolution perform render-time activation

Construction calls startSyncImmediate(). React also starts direct and callback-returned collections during render, while generated collections use startSync: true. An abandoned concurrent render can therefore activate resources without a committed consumer.

const source = makeIdleSource()
createLiveQueryObserver(source)
expect(source.status).toBe('idle') // failed: loading

Code inspection confirms the same side effect in all three React resolution paths. This conflicts with the RFC requirement that render be inert. Please activate synchronization through committed retain/subscription ownership instead.

9. Forced initial state introduces unfiltered loading and unnecessary materialization

attach() unconditionally requests initial state, which reproduced an unfiltered load:

expect(loadSubset).not.toHaveBeenCalledWith({ where: undefined })
// failed: loadSubset({ where: undefined })

This changes React/Angular behavior. Separately, a granular listener reading only getSnapshot().status caused a full collection entries() enumeration for a one-row delta:

expect(entriesCalls).toBe(0) // failed: 1

Please preserve the adapters’ previous initial-loading policy (or make it explicit/configurable) and avoid materializing all entries when only status is requested.

10. Solid can resurrect superseded async state

After await currentCollection.toArrayWhenReady(), the Solid resource continuation writes keyed state/data/status without checking whether that collection has been superseded. The error continuation has the same issue.

An executable source assertion found unguarded post-await writes; adding a generation check to success and error continuations passed Solid typechecking. Please generation-guard both paths.

11. API and release-note presentation do not match the RFC

The RFC says the observer is for official adapters and may cross package boundaries only as an internal/unstable surface, not as a documented third-party extension point. The PR root-exports it and the changeset advertises createLiveQueryObserver as a minor feature. The changeset also says “No behavior change,” contradicted by the initial-load and Angular behavior changes above.

Please mark/use an internal or unstable surface and update the changeset accordingly. Removing the cross-package export entirely is not necessary if official adapters require it.

Non-blocking cleanup

I would not block this PR on these, but the migration also leaves redundant adapter synchronization, a copied insert/update/delete switch across three adapters, redundant Vue/Svelte teardown, and an unused LiveQueryObserver.preload().

Verification

These findings were tested in fresh worktrees at PR head 2a0679e0 after pnpm install.

  • One regression group initially produced 9/9 failing behavioral assertions; minimal local fixes made the focused observer suite pass 17/17.
  • The semantic-clock/reentrancy group initially produced 5/5 failing focused tests; minimal local fixes made that suite pass 13/13.
  • Solid typechecking passed after locally generation-guarding its async continuations.
  • Adapter builds and focused conformance tests passed after the verification-only fixes.

No verification changes were committed or pushed.

I would not block this PR on phase-four ordered snapshots, the final patch model, or the complete DbClient/SSR ownership architecture. I would block on exactly-once semantic publication, status/error delivery, snapshot freshness and identity, FIFO non-reentrant dispatch, balanced attachment/teardown, event ordering, and render-inert activation.

kevin-dp and others added 11 commits July 20, 2026 11:39
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>
…l 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>
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>
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>
…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>
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>
…tion

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>
…ction

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>
…ubscribe

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>
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>
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>
@kevin-dp

Copy link
Copy Markdown
Contributor Author

Hi @KyleAMathews, i addressed your review. Here's the full breakdown of what was addressed and what was deliberately left out.

Fixed in this PR

1 + 3 — Exactly-once semantic publication; snapshot identity and freshness. The semantic clock is now collection-owned: CollectionChangesManager.emitEvents advances a monotonic _stateRevision once per committed batch, whether or not anything is subscribed. getSnapshot() keys its cache on (stateRevision, status). Bootstrap replay is per-subscriber delivery and never touches the clock, and empty batches are dropped from publication — so one readiness transition publishes exactly once (no more [[], undefined, []]), unsubscribe/resubscribe preserves snapshot identity, and rows committed while detached produce a fresh snapshot on the next read.

2a — Status delivery. Status transitions now publish through collection.on('status:change'), the same canonical path as data changes — error and cleaned-up wake mounted consumers immediately. This also retires onFirstReady entirely, so the unremovable-callback accumulation across attach/detach cycles is gone (on returns a real unsubscribe that detach releases).

4 — Dispatch. Publications are queued and dispatched FIFO — a listener that synchronously mutates the collection can no longer cause a nested event to overtake the outer one. Each publication is delivered over a snapshot of subscription records taken at dispatch: removed-mid-delivery still receives the in-flight publication, added-mid-delivery does not. Subscriptions are identified by record, not callback, so the duplicate-callback teardown break is fixed.

5 — Attachment is transactional. The release hook is registered before subscribeChanges is called, so a listener disposing the observer during the synchronous initial replay releases the collection subscription as soon as the call returns.

6 — Event ordering. deferInitialNotify is deleted outright rather than patched. It existed only so React's useSyncExternalStore wasn't notified during its own subscribe call — and with React on wholesale mode (see 9) nothing is delivered synchronously during subscribe at all, so the deferred bootstrap and its reordering hazard are impossible by construction. Every publication is now delivered synchronously in commit order.

7 — Subscriber semantics. A subscriber arriving while already attached is seeded with the current rows as inserts, delivered to that subscription alone without advancing the clock. subscribe() after dispose() throws LiveQueryObserverDisposedError instead of silently registering a dead listener.

8 — Render-inert activation (observer half). Observer construction is now fully side-effect-free — your createLiveQueryObserver(idleSource)expect(status).toBe('idle') test passes. Sync activates through the first subscription's own addSubscriber path (the identical startSync call the constructor used to make), after the status listener is wired, so a synchronously-starting collection's transitions are observed rather than happening before anyone listens. Zero adapter behavior change: the effect-based adapters subscribe in the same tick they construct.

9 — Initial-loading policy and materialization. The observer takes a mode: 'granular' | 'wholesale' option. Granular (Vue/Svelte/Solid) keeps the initial-state subscription and seeding. Wholesale (React/Angular) subscribes with includeInitialState: false — restoring those adapters' pre-observer loading policy, so no loadSubset({ where: undefined }) against on-demand collections. getSnapshot() now materializes rows lazily on first state/data access, so a consumer reading only status never enumerates the collection.

10 — Solid. Both the success and error continuations after toArrayWhenReady() are generation-guarded; a superseded collection can no longer resurrect its rows or status. The regression test reproduces the bug against the unguarded code.

11 — Surface and changeset. createLiveQueryObserver and the LiveQueryObserver interface are marked @internal: an unstable contract for the official adapters, exported so the adapter packages can consume it, explicitly not a public extension point, may change in any release. The changeset no longer claims "No behavior change" — it describes the lifecycle fixes and the per-adapter loading-policy preservation.

Each finding has regression tests (live-query-observer.test.ts is now 24 tests, plus a Solid supersession test). All suites green: db 2480, react 122, vue 56, solid 66, angular 52.

Deliberately not done, and why

2b — Carrying the actual error as unknown. As you noted yourself, this requires plumbing the originating error through collection lifecycle state — a change to packages/db collection internals, not to the observer. We'd rather land that as an immediate, focused follow-up than grow this PR's blast radius further. The status: 'error' transition itself does reach consumers now (2a), so the follow-up is purely about surfacing the error value.

8 — React's render-path activation. The observer half is fixed (above), but React's input resolution still calls startSyncImmediate() / startSync: true during render. That's pre-existing behavior this refactor deliberately preserved — as your own review put it, React "also starts direct and callback-returned collections during render", i.e. the current state, not a regression of this PR. Moving activation to commit changes React's eager-load timing for every consumer and interacts with concurrent rendering in ways we want to test in isolation, so we'd like to do it as its own PR against the RFC's render-inert goal rather than fold it into a refactor whose charter was behavior preservation. Happy to open the issue now so it doesn't get lost.

Non-blocking cleanups (the copied insert/update/delete switch across granular adapters, redundant teardown, unused preload()). Agreed on all — deferring them per your own framing, as follow-up cleanup.

@kevin-dp
kevin-dp requested a review from KyleAMathews July 20, 2026 11:47

@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 (2)
packages/angular-db/tests/inject-live-query.test.ts (2)

84-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace any types with unknown.

As per coding guidelines, avoid using any types; use unknown instead when the type is truly unknown.

  • packages/angular-db/tests/inject-live-query.test.ts#L84-L86: replace Array<any> and any with Array<unknown> and unknown in subs and statusSubs type parameters.
  • packages/angular-db/tests/inject-live-query.test.ts#L91-L91: replace Array<any> with Array<unknown> in the notify parameter type.
  • packages/angular-db/tests/inject-live-query.test.ts#L114-L117: replace any with unknown in the cb parameter type of on.
  • packages/angular-db/tests/inject-live-query.test.ts#L123-L126: replace Array<any> with Array<unknown> in the cb parameter type of subscribeChanges.
🤖 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/angular-db/tests/inject-live-query.test.ts` around lines 84 - 86,
Replace all any usages in the live-query test callbacks with unknown: update
subs and statusSubs, notify, on, and subscribeChanges. Apply the changes at
packages/angular-db/tests/inject-live-query.test.ts lines 84-86, 91, 114-117,
and 123-126, using Array<unknown> for collections and unknown for callback event
parameters.

Source: Coding guidelines


157-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer array methods and spread operator over manual loops with push.

As per coding guidelines, use array methods for transformations and the spread operator for combining arrays instead of manual loops with push.

♻️ Proposed refactor
-    __replaceAll: (rows: Array<T & Record<`id`, K>>) => {
-      const changes: Array<any> = []
-      for (const [key, value] of map.entries()) {
-        changes.push({ type: `delete`, key, value })
-      }
-      map.clear()
-      for (const r of rows) {
-        map.set(r.id, r)
-        changes.push({ type: `insert`, key: r.id, value: r })
-      }
-      notify(changes)
-    },
+    __replaceAll: (rows: Array<T & Record<`id`, K>>) => {
+      const deletes = Array.from(map.entries()).map(([key, value]) => ({ type: `delete`, key, value }))
+      map.clear()
+      const inserts = rows.map((r) => {
+        map.set(r.id, r)
+        return { type: `insert`, key: r.id, value: r }
+      })
+      notify([...deletes, ...inserts])
+    },
🤖 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/angular-db/tests/inject-live-query.test.ts` around lines 157 - 167,
Refactor __replaceAll to eliminate the manual changes loop and push calls:
derive delete changes from map entries with an array method, clear the map, then
combine those changes with insert changes derived from rows using spread and
array methods before calling notify(changes). Preserve the existing deletion,
replacement, and insertion order.

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/angular-db/tests/inject-live-query.test.ts`:
- Around line 84-86: Replace all any usages in the live-query test callbacks
with unknown: update subs and statusSubs, notify, on, and subscribeChanges.
Apply the changes at packages/angular-db/tests/inject-live-query.test.ts lines
84-86, 91, 114-117, and 123-126, using Array<unknown> for collections and
unknown for callback event parameters.
- Around line 157-167: Refactor __replaceAll to eliminate the manual changes
loop and push calls: derive delete changes from map entries with an array
method, clear the map, then combine those changes with insert changes derived
from rows using spread and array methods before calling notify(changes).
Preserve the existing deletion, replacement, and insertion order.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e9f806c-7f69-4aa4-88fe-8b49954eac15

📥 Commits

Reviewing files that changed from the base of the PR and between 2a0679e and b24d163.

📒 Files selected for processing (12)
  • .changeset/live-query-observer.md
  • packages/angular-db/src/index.ts
  • packages/angular-db/tests/inject-live-query.test.ts
  • packages/db/src/collection/changes.ts
  • packages/db/src/collection/index.ts
  • packages/db/src/errors.ts
  • packages/db/src/live-query-observer.ts
  • packages/db/tests/live-query-observer.test.ts
  • packages/react-db/src/useLiveQuery.ts
  • packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx
  • packages/solid-db/src/useLiveQuery.ts
  • packages/solid-db/tests/useLiveQuery.test.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • .changeset/live-query-observer.md
  • packages/angular-db/src/index.ts
  • packages/react-db/src/useLiveQuery.ts

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.

2 participants