Skip to content

Virtual props implementation#1213

Open
samwillis wants to merge 79 commits intomainfrom
cursor/virtual-props-rfc-implementation-8087
Open

Virtual props implementation#1213
samwillis wants to merge 79 commits intomainfrom
cursor/virtual-props-rfc-implementation-8087

Conversation

@samwillis
Copy link
Collaborator

🎯 Changes

This PR implements the virtual properties system as described in the RFC. This introduces computed, read-only metadata fields prefixed with $ to every row, providing information about its sync status, origin, key, and source collection ID.

Key aspects of this implementation:

  • $synced: Indicates whether a row reflects confirmed state from the backend (true) or has pending optimistic mutations (false).
  • $origin: Specifies if the last confirmed change originated from the local client ('local') or was received via sync ('remote').
  • $key: The primary identifier of the row.
  • $collectionId: The ID of the collection the row originated from.
  • Pass-through semantics: Virtual properties are preserved and passed through in live queries and nested collections using an "add-if-missing" pattern.
  • Queryability: Virtual properties are available for use in query builder expressions (e.g., where(({ item }) => eq(item.$synced, true))).
  • Type Integration: Updated RefProxy and Ref types to include virtual properties, ensuring type safety and discoverability.

This feature enhances data visibility and enables more sophisticated UI logic based on the state and origin of data.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Open in Cursor Open in Web

@cursor
Copy link

cursor bot commented Feb 2, 2026

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@changeset-bot
Copy link

changeset-bot bot commented Feb 2, 2026

🦋 Changeset detected

Latest commit: 837286d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@tanstack/db Patch
@tanstack/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/react-db Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 2, 2026

More templates

@tanstack/angular-db

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

@tanstack/db

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

@tanstack/db-ivm

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

@tanstack/electric-db-collection

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

@tanstack/offline-transactions

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

@tanstack/powersync-db-collection

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

@tanstack/query-db-collection

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

@tanstack/react-db

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

@tanstack/rxdb-db-collection

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

@tanstack/solid-db

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

@tanstack/svelte-db

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

@tanstack/trailbase-db-collection

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

@tanstack/vue-db

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

commit: 837286d

@github-actions
Copy link
Contributor

github-actions bot commented Feb 2, 2026

Size Change: +3.05 kB (+3.32%)

Total Size: 95 kB

Filename Size Change
./packages/db/dist/esm/collection/changes.js 1.38 kB +157 B (+12.83%) ⚠️
./packages/db/dist/esm/collection/index.js 3.45 kB +126 B (+3.79%)
./packages/db/dist/esm/collection/mutations.js 2.47 kB +135 B (+5.77%) 🔍
./packages/db/dist/esm/collection/state.js 4.9 kB +1.42 kB (+40.65%) 🚨
./packages/db/dist/esm/collection/sync.js 2.43 kB +24 B (+1%)
./packages/db/dist/esm/collection/transaction-metadata.js 144 B +144 B (new file) 🆕
./packages/db/dist/esm/index.js 2.72 kB +25 B (+0.93%)
./packages/db/dist/esm/indexes/auto-index.js 777 B +35 B (+4.72%) 🔍
./packages/db/dist/esm/local-only.js 917 B +80 B (+9.56%) ⚠️
./packages/db/dist/esm/query/compiler/group-by.js 2.19 kB +375 B (+20.66%) 🚨
./packages/db/dist/esm/query/compiler/index.js 2.15 kB +137 B (+6.79%) 🔍
./packages/db/dist/esm/utils/index-optimization.js 1.54 kB +36 B (+2.39%)
./packages/db/dist/esm/virtual-props.js 358 B +358 B (new file) 🆕
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.75 kB
./packages/db/dist/esm/collection/subscription.js 3.71 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.7 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 2.17 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 538 B
./packages/db/dist/esm/local-storage.js 2.1 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 733 B
./packages/db/dist/esm/query/builder/index.js 4.09 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.42 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/joins.js 2.07 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.45 kB
./packages/db/dist/esm/query/compiler/select.js 1.06 kB
./packages/db/dist/esm/query/expression-helpers.js 1.43 kB
./packages/db/dist/esm/query/ir.js 673 B
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-config-builder.js 5.43 kB
./packages/db/dist/esm/query/live/collection-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 2.42 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/optimizer.js 2.56 kB
./packages/db/dist/esm/query/predicate-utils.js 2.97 kB
./packages/db/dist/esm/query/subset-dedupe.js 921 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 2.9 kB
./packages/db/dist/esm/utils.js 924 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 952 B
./packages/db/dist/esm/utils/cursor.js 457 B
./packages/db/dist/esm/utils/type-guards.js 157 B

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

@github-actions
Copy link
Contributor

github-actions bot commented Feb 2, 2026

Size Change: 0 B

Total Size: 3.7 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.17 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 559 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

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

@samwillis samwillis changed the title Virtual props RFC implementation Virtual props implementation Feb 7, 2026
@samwillis samwillis marked this pull request as ready for review February 7, 2026 18:32
Copy link
Contributor

@kevin-dp kevin-dp left a comment

Choose a reason for hiding this comment

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

We're adding a bunch of meta properties $key, $origin, etc. Each of these could conflict with user properties. I'd like to minimize potential conflicts by having a single $meta property and having all these props nested under it, so $meta.synced, $meta.origin, etc.


I reviewed this big PR with Claude. Here's our feedback:

Semantic concerns with virtual props in joins and aggregations

Nice work on the virtual props system — the core idea of $synced, $origin, $key, and $collectionId is really useful for single-collection queries. However, I think the "pass-through / add-if-missing" semantics break down in two important cases.

Joins: $collectionId becomes ambiguous

After a join, each result row is a composite of two (or more) collections:

q.from({ todo: todoCollection })
  .join({ list: listCollection }, ({ todo, list }) => eq(list.id, todo.list_id), "inner")
  .select(({ todo, list }) => ({
    id: todo.id,
    text: todo.text,
    listName: list.name,
  }))

A single $collectionId string can't represent this — it will just carry forward whichever value was set first (likely the from source), silently losing provenance from the joined side. If consumers use $collectionId to route mutations back to the right collection, this is actively misleading rather than just incomplete.

Options:

  • Make $collectionId an array or a map keyed by alias after a join (e.g. { todo: "todo-collection-id", list: "list-collection-id" })
  • Strip/nullify $collectionId on join results since it's now ambiguous
  • Keep it but rename it to make clear it only refers to the primary source

Aggregations: $synced, $origin, and $key lose their meaning

After a groupBy + aggregate, each result row represents a group of source rows, not a single entity:

q.from({ user: usersCollection })
  .groupBy(({ user }) => user.departmentId)
  .select(({ user }) => ({
    departmentId: user.departmentId,
    userCount: count(user.id),
    avgAge: avg(user.age),
  }))

For this output:

  • $key: The aggregate row doesn't correspond to any single source row. Whatever key is passed through is arbitrary and meaningless.
  • $synced: A group may contain a mix of synced and unsynced rows. Is the aggregate true only if all rows in the group are synced? Or is it just pass-through from an arbitrary row? The difference matters hugely for UI that conditionally shows "saving..." indicators.
  • $origin: Same problem — the group may mix 'local' and 'remote' rows.

The relax join and group-by type assertions commit and the +20% size bump in group-by.js suggest the implementation is patching around these issues by loosening types and carrying props through regardless, rather than addressing the semantic mismatch.

Suggestion

The pass-through pattern works well for operations that don't change row identity (where, orderBy, limit, select without aggregation). The problem is specifically joins (which merge identities) and groupBy (which destroys individual identity).

I'd suggest one of:

  1. Strip or nullify virtual props after operations that break their semantics. Aggregation strips $key / $synced / $origin; joins make $collectionId an array or strip it. Reflect this in the types.

  2. Make virtual props conditional at the type level — a from().where() result gets the full { $synced, $origin, $key, $collectionId }, but a groupBy() result gets a narrower type that omits $key / $synced / $origin. More work, but more honest.

  3. At minimum, derive aggregate-level $synced meaningfully — e.g. $synced = false if any row in the group is unsynced. This would actually be useful ("some data in this group is still pending"). But $key and $origin still don't have good aggregate semantics and should probably be omitted.

if (groupByClause.length === 0) {
// For single-group aggregation, create a single group with all data
const aggregates: Record<string, any> = {}
const aggregates: Record<string, any> = { ...virtualAggregates }
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really need to make a copy?


// Create aggregate functions for any aggregated columns in the SELECT clause
const aggregates: Record<string, any> = {}
const aggregates: Record<string, any> = { ...virtualAggregates }
Copy link
Contributor

Choose a reason for hiding this comment

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

Same, really needed to copy?

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.

3 participants