Skip to content

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

Draft
kevin-dp wants to merge 2 commits into
refactor/extract-adapter-helpersfrom
refactor/live-query-observer
Draft

feat(db): shared live-query observer + migrate all five adapters (RFC #1623 step 3)#1642
kevin-dp wants to merge 2 commits into
refactor/extract-adapter-helpersfrom
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

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6b483dcf-90f9-4ba5-8a06-7058cce8ca13

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 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: 8ee8e8e

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Size Change: +1.04 kB (+0.83%)

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.01 kB +1.01 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.4 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
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>
@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 👎.

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 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.

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