Skip to content

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

Open
kevin-dp wants to merge 6 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 6 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) exposed from the main DB package.
    • Updated live-query hooks (React, Vue, Svelte, Solid, Angular) to use the shared observer for consistent snapshots and subscriptions.
  • Bug Fixes
    • Improved live-query lifecycle handling across readiness transitions, disabled queries, and cleanup/disposal to ensure updates stop reliably.
    • Ensured state stays in sync during recompile/parameter narrowing scenarios.
  • Tests
    • Added observer and React StrictMode test coverage.
    • Extended conformance drivers to expose state and added a new “recompile drops stale keys” scenario.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a shared LiveQueryObserver to @tanstack/db, exposes it publicly, and migrates React, Vue, Svelte, Solid, and Angular live-query hooks to use it. It also expands observer and conformance coverage and records package 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/index.ts, packages/db/tests/live-query-observer.test.ts
Adds observer contracts, cached snapshots, lifecycle management, change subscriptions, readiness handling, disabled-query support, and related 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; adds a React StrictMode regression test.
Conformance state contract and coverage
packages/db/tests/conformance/..., packages/*-db/tests/conformance*, packages/solid-db/tests/useLiveQuery.test.tsx
Exposes keyed state through conformance drivers, verifies stale keys are removed after recompilation, and updates Angular’s known gaps.
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 issues

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 | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the change well, but it omits the template's checklist and release-impact sections. Add the ## ✅ Checklist and ## 🚀 Release Impact sections, including the pnpm test status and whether a changeset was generated.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: a shared live-query observer adopted across all five adapters.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% 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

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: 2a0679e

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Size Change: +1.06 kB (+0.85%)

Total Size: 126 kB

📦 View Changed
Filename Size Change
packages/db/dist/esm/index.js 3.19 kB +24 B (+0.76%)
packages/db/dist/esm/live-query-observer.js 1.04 kB +1.04 kB (new file) 🆕
ℹ️ View Unchanged
Filename Size
packages/db/dist/esm/collection/change-events.js 1.43 kB
packages/db/dist/esm/collection/changes.js 1.38 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/index.js 3.62 kB
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/errors.js 5.1 kB
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
Comment thread packages/db/tests/live-query-observer.test.ts
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.

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