Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
157316c
feat: add virtual properties core support
cursoragent Feb 2, 2026
2bf2216
test: update expectations for virtual props
cursoragent Feb 2, 2026
955beed
ci: apply automated fixes
autofix-ci[bot] Feb 2, 2026
b7df638
Fix virtual props semantics and types
cursoragent Feb 3, 2026
9cd517f
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 3, 2026
de6822d
ci: apply automated fixes
autofix-ci[bot] Feb 3, 2026
3a5f14c
Deduplicate test helpers and emit synced flips
cursoragent Feb 5, 2026
c61b4e8
ci: apply automated fixes
autofix-ci[bot] Feb 5, 2026
69a4d0f
Fix tests for virtual props helpers
cursoragent Feb 5, 2026
743e251
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 5, 2026
e85989b
Fix type constraints in tests
cursoragent Feb 5, 2026
b273cf6
Update local-only and test typings
cursoragent Feb 5, 2026
9166d2f
ci: apply automated fixes
autofix-ci[bot] Feb 5, 2026
b818243
Fix type expectations for virtual props
cursoragent Feb 5, 2026
e6e0518
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 5, 2026
1febe19
Align query key types in tests
cursoragent Feb 5, 2026
fb6d66f
Adjust key types in type tests
cursoragent Feb 5, 2026
61d3e03
Update query type tests for virtual props
cursoragent Feb 5, 2026
f05c55f
Align query types with expectations
cursoragent Feb 5, 2026
2dfe8d8
Use string in query type tests
cursoragent Feb 5, 2026
665dbea
Expect numeric in query types
cursoragent Feb 5, 2026
03901b1
Use union in query type tests
cursoragent Feb 5, 2026
780c177
Relax query type assertions for
cursoragent Feb 5, 2026
03fd4d8
Loosen join query key expectations
cursoragent Feb 5, 2026
20a9d2b
ci: apply automated fixes
autofix-ci[bot] Feb 5, 2026
ede2324
Default OutputWithVirtual key to union
cursoragent Feb 5, 2026
ecfd565
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 5, 2026
06babee
Update query tests for virtual props
cursoragent Feb 5, 2026
f533c29
test: strip virtual props in query expectations
cursoragent Feb 5, 2026
98c70cd
test: normalize virtual props in truncate/group/indexes
cursoragent Feb 5, 2026
cf09899
ci: apply automated fixes
autofix-ci[bot] Feb 5, 2026
59876cc
test: strip virtual props in collection/local-only
cursoragent Feb 5, 2026
011e150
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 5, 2026
2a363eb
ci: apply automated fixes
autofix-ci[bot] Feb 5, 2026
068b667
test: fix remaining virtual props expectations
cursoragent Feb 5, 2026
c6bc2d7
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 5, 2026
ee949dd
fix: preserve local origin across sync confirmation
cursoragent Feb 5, 2026
988bde4
ci: apply automated fixes
autofix-ci[bot] Feb 5, 2026
8bbc7fb
fix: account for virtual sync confirmations
cursoragent Feb 5, 2026
614294d
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 5, 2026
a84cb8b
ci: apply automated fixes
autofix-ci[bot] Feb 5, 2026
6677677
fix: keep optimistic state until sync
cursoragent Feb 5, 2026
ad8b812
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 5, 2026
de6beda
fix: track pending local origins
cursoragent Feb 5, 2026
0a90580
fix: persist direct optimistic state
cursoragent Feb 5, 2026
b56e118
fix: handle virtual prop filters and origins
cursoragent Feb 5, 2026
49fba22
fix: carry virtual props through query output
cursoragent Feb 5, 2026
e3679bc
feat: cache virtual props with WeakMap
cursoragent Feb 7, 2026
45e8a5a
ci: apply automated fixes
autofix-ci[bot] Feb 7, 2026
6f0a439
fix: restore virtual props helper import
cursoragent Feb 7, 2026
9750ebb
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 7, 2026
ea2842e
chore: reuse db test utils in query-db
cursoragent Feb 7, 2026
3162eaf
test: align electric type tests with virtual props
cursoragent Feb 7, 2026
9997179
ci: apply automated fixes
autofix-ci[bot] Feb 7, 2026
7d03326
test: normalize electric tag state for virtual props
cursoragent Feb 7, 2026
eda191d
merge: resolve electric test type conflicts
cursoragent Feb 7, 2026
a46df0b
ci: apply automated fixes
autofix-ci[bot] Feb 7, 2026
ed9e642
test: strip virtual props in electric integration assertions
cursoragent Feb 7, 2026
6af6613
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 7, 2026
9931bf3
test: normalize electric collection state comparisons
cursoragent Feb 7, 2026
83d71fb
test: strip virtual props in rxdb assertions
cursoragent Feb 7, 2026
9a46438
test: align react useLiveQuery types with virtual props
cursoragent Feb 7, 2026
ee59082
test: strip virtual props in query-db-collection tests
cursoragent Feb 7, 2026
1b8a756
test: strip virtual props in query-db cache comparisons
cursoragent Feb 7, 2026
a0e47ed
test: strip virtual props in trailbase state assertions
cursoragent Feb 7, 2026
ce04c74
test: align vue useLiveQuery types with virtual props
cursoragent Feb 7, 2026
576ed57
fix query select types include virtual props
cursoragent Feb 7, 2026
5b93c1d
ci: apply automated fixes
autofix-ci[bot] Feb 7, 2026
d047981
update subquery type tests for virtual props
cursoragent Feb 7, 2026
c90879f
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 7, 2026
4db3df3
loosen select result key type for virtual props
cursoragent Feb 7, 2026
bad2d5a
relax join and group-by type assertions
cursoragent Feb 7, 2026
7edea53
align subscribeChanges options with virtual props
cursoragent Feb 7, 2026
eb1940d
document virtual props in live query docs
cursoragent Feb 7, 2026
3e1c019
document virtual props in live queries guide
cursoragent Feb 7, 2026
9afa0ec
add changeset for virtual props updates
cursoragent Feb 7, 2026
ec9a0a6
ci: apply automated fixes
autofix-ci[bot] Feb 7, 2026
ee7bc6c
update changeset summary for virtual props
cursoragent Feb 7, 2026
837286d
Merge branch 'cursor/virtual-props-rfc-implementation-8087' of https:…
cursoragent Feb 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/shiny-planes-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/db': patch
---

Implement virtual properties end-to-end, including live query behavior and
typing support for virtual metadata on rows.
12 changes: 12 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ const activeUsers = createCollection(liveQueryCollectionOptions({

The result types are automatically inferred from your query structure, providing full TypeScript support. When you use a `select` clause, the result type matches your projection. Without `select`, you get the full schema with proper join optionality.

## Virtual properties

Live query results include computed, read-only virtual properties on every row:

- `$synced`: `true` when the row is confirmed by sync; `false` when it is still optimistic.
- `$origin`: `"local"` if the last confirmed change came from this client, otherwise `"remote"`.
- `$key`: the row key for the result.
- `$collectionId`: the source collection ID.

These props can be used in `where`, `select`, and `orderBy` clauses. They are added to
query outputs automatically and should not be persisted back to storage.

## Table of Contents

- [Creating Live Query Collections](#creating-live-query-collections)
Expand Down
22 changes: 13 additions & 9 deletions packages/db/src/collection/change-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
import type { CollectionImpl } from './index.js'
import type { SingleRowRefProxy } from '../query/builder/ref-proxy'
import type { BasicExpression, OrderBy } from '../query/ir.js'
import type { WithVirtualProps } from '../virtual-props.js'

/**
* Returns the current state of the collection as an array of changes
Expand Down Expand Up @@ -58,14 +59,14 @@ export function currentStateAsChanges<
T extends object,
TKey extends string | number,
>(
collection: CollectionLike<T, TKey>,
collection: CollectionLike<WithVirtualProps<T, TKey>, TKey>,
options: CurrentStateAsChangesOptions = {},
): Array<ChangeMessage<T>> | void {
): Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> | void {
// Helper function to collect filtered results
const collectFilteredResults = (
filterFn?: (value: T) => boolean,
): Array<ChangeMessage<T>> => {
const result: Array<ChangeMessage<T>> = []
filterFn?: (value: WithVirtualProps<T, TKey>) => boolean,
): Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> => {
const result: Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> = []
for (const [key, value] of collection.entries()) {
// If no filter function is provided, include all items
if (filterFn?.(value) ?? true) {
Expand Down Expand Up @@ -106,7 +107,7 @@ export function currentStateAsChanges<
}

// Convert keys to change messages
const result: Array<ChangeMessage<T>> = []
const result: Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> = []
for (const key of orderedKeys) {
const value = collection.get(key)
if (value !== undefined) {
Expand Down Expand Up @@ -138,7 +139,7 @@ export function currentStateAsChanges<

if (optimizationResult.canOptimize) {
// Use index optimization
const result: Array<ChangeMessage<T>> = []
const result: Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> = []
for (const key of optimizationResult.matchingKeys) {
const value = collection.get(key)
if (value !== undefined) {
Expand Down Expand Up @@ -241,9 +242,12 @@ export function createFilterFunctionFromExpression<T extends object>(
* @param options - The subscription options containing the where clause
* @returns A filtered callback function
*/
export function createFilteredCallback<T extends object>(
export function createFilteredCallback<
T extends object,
TKey extends string | number = string | number,
>(
originalCallback: (changes: Array<ChangeMessage<T>>) => void,
options: SubscribeChangesOptions,
options: SubscribeChangesOptions<T, TKey>,
): (changes: Array<ChangeMessage<T>>) => void {
const filterFn = createFilterFunctionFromExpression(options.whereExpression!)

Expand Down
37 changes: 30 additions & 7 deletions packages/db/src/collection/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { CollectionLifecycleManager } from './lifecycle.js'
import type { CollectionSyncManager } from './sync.js'
import type { CollectionEventsManager } from './events.js'
import type { CollectionImpl } from './index.js'
import type { CollectionStateManager } from './state.js'
import type { WithVirtualProps } from '../virtual-props.js'

export class CollectionChangesManager<
TOutput extends object = Record<string, unknown>,
Expand All @@ -21,6 +23,7 @@ export class CollectionChangesManager<
private sync!: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
private events!: CollectionEventsManager
private collection!: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
private state!: CollectionStateManager<TOutput, TKey, TSchema, TInput>

public activeSubscribersCount = 0
public changeSubscriptions = new Set<CollectionSubscription>()
Expand All @@ -37,11 +40,13 @@ export class CollectionChangesManager<
sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
events: CollectionEventsManager
collection: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
state: CollectionStateManager<TOutput, TKey, TSchema, TInput>
}) {
this.lifecycle = deps.lifecycle
this.sync = deps.sync
this.events = deps.events
this.collection = deps.collection
this.state = deps.state
}

/**
Expand All @@ -55,6 +60,16 @@ export class CollectionChangesManager<
}
}

/**
* Enriches a change message with virtual properties ($synced, $origin, $key, $collectionId).
* Uses the "add-if-missing" pattern to preserve virtual properties from upstream collections.
*/
private enrichChangeWithVirtualProps(
change: ChangeMessage<TOutput, TKey>,
): ChangeMessage<WithVirtualProps<TOutput, TKey>, TKey> {
return this.state.enrichChangeMessage(change)
}

/**
* Emit events either immediately or batch them for later emission
*/
Expand All @@ -70,35 +85,43 @@ export class CollectionChangesManager<
}

// Either we're not batching, or we're forcing emission (user action or ending batch cycle)
let eventsToEmit = changes
let rawEvents = changes

if (forceEmit) {
// Force emit is used to end a batch (e.g. after a sync commit). Combine any
// buffered optimistic events with the final changes so subscribers see the
// whole picture, even if the sync diff is empty.
if (this.batchedEvents.length > 0) {
eventsToEmit = [...this.batchedEvents, ...changes]
rawEvents = [...this.batchedEvents, ...changes]
}
this.batchedEvents = []
this.shouldBatchEvents = false
}

if (eventsToEmit.length === 0) {
if (rawEvents.length === 0) {
return
}

// Enrich all change messages with virtual properties
// This uses the "add-if-missing" pattern to preserve pass-through semantics
const enrichedEvents: Array<
ChangeMessage<WithVirtualProps<TOutput, TKey>, TKey>
> = rawEvents.map((change) => this.enrichChangeWithVirtualProps(change))

// Emit to all listeners
for (const subscription of this.changeSubscriptions) {
subscription.emitEvents(eventsToEmit)
subscription.emitEvents(enrichedEvents)
}
}

/**
* Subscribe to changes in the collection
*/
public subscribeChanges(
callback: (changes: Array<ChangeMessage<TOutput>>) => void,
options: SubscribeChangesOptions<TOutput> = {},
callback: (
changes: Array<ChangeMessage<WithVirtualProps<TOutput, TKey>>>,
) => void,
options: SubscribeChangesOptions<TOutput, TKey> = {},
): CollectionSubscription {
// Start sync and track subscriber
this.addSubscriber()
Expand All @@ -113,7 +136,7 @@ export class CollectionChangesManager<
const { where, ...opts } = options
let whereExpression = opts.whereExpression
if (where) {
const proxy = createSingleRowRefProxy<TOutput>()
const proxy = createSingleRowRefProxy<WithVirtualProps<TOutput, TKey>>()
const result = where(proxy)
whereExpression = toExpression(result)
}
Expand Down
68 changes: 50 additions & 18 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { SingleRowRefProxy } from '../query/builder/ref-proxy'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { BTreeIndex } from '../indexes/btree-index.js'
import type { IndexProxy } from '../indexes/lazy-index.js'
import type { WithVirtualProps } from '../virtual-props.js'

/**
* Enhanced Collection interface that includes both data type T and utilities TUtils
Expand Down Expand Up @@ -340,6 +341,7 @@ export class CollectionImpl<
lifecycle: this._lifecycle,
sync: this._sync,
events: this._events,
state: this._state, // Required for enriching changes with virtual properties
})
this._events.setDeps({
collection: this, // Required for adding to emitted events
Expand Down Expand Up @@ -451,8 +453,8 @@ export class CollectionImpl<
/**
* Get the current value for a key (virtual derived state)
*/
public get(key: TKey): TOutput | undefined {
return this._state.get(key)
public get(key: TKey): WithVirtualProps<TOutput, TKey> | undefined {
return this._state.getWithVirtualProps(key)
}

/**
Expand All @@ -479,40 +481,68 @@ export class CollectionImpl<
/**
* Get all values (virtual derived state)
*/
public *values(): IterableIterator<TOutput> {
yield* this._state.values()
public *values(): IterableIterator<WithVirtualProps<TOutput, TKey>> {
for (const key of this._state.keys()) {
const value = this.get(key)
if (value !== undefined) {
yield value
}
}
}

/**
* Get all entries (virtual derived state)
*/
public *entries(): IterableIterator<[TKey, TOutput]> {
yield* this._state.entries()
public *entries(): IterableIterator<[TKey, WithVirtualProps<TOutput, TKey>]> {
for (const key of this._state.keys()) {
const value = this.get(key)
if (value !== undefined) {
yield [key, value]
}
}
}

/**
* Get all entries (virtual derived state)
*/
public *[Symbol.iterator](): IterableIterator<[TKey, TOutput]> {
yield* this._state[Symbol.iterator]()
public *[Symbol.iterator](): IterableIterator<
[TKey, WithVirtualProps<TOutput, TKey>]
> {
yield* this.entries()
}

/**
* Execute a callback for each entry in the collection
*/
public forEach(
callbackfn: (value: TOutput, key: TKey, index: number) => void,
callbackfn: (
value: WithVirtualProps<TOutput, TKey>,
key: TKey,
index: number,
) => void,
): void {
return this._state.forEach(callbackfn)
let index = 0
for (const [key, value] of this.entries()) {
callbackfn(value, key, index++)
}
}

/**
* Create a new array with the results of calling a function for each entry in the collection
*/
public map<U>(
callbackfn: (value: TOutput, key: TKey, index: number) => U,
callbackfn: (
value: WithVirtualProps<TOutput, TKey>,
key: TKey,
index: number,
) => U,
): Array<U> {
return this._state.map(callbackfn)
const result: Array<U> = []
let index = 0
for (const [key, value] of this.entries()) {
result.push(callbackfn(value, key, index++))
}
return result
}

public getKeyFromItem(item: TOutput): TKey {
Expand Down Expand Up @@ -755,7 +785,7 @@ export class CollectionImpl<
* }
*/
get state() {
const result = new Map<TKey, TOutput>()
const result = new Map<TKey, WithVirtualProps<TOutput, TKey>>()
for (const [key, value] of this.entries()) {
result.set(key, value)
}
Expand All @@ -768,7 +798,7 @@ export class CollectionImpl<
*
* @returns Promise that resolves to a Map containing all items in the collection
*/
stateWhenReady(): Promise<Map<TKey, TOutput>> {
stateWhenReady(): Promise<Map<TKey, WithVirtualProps<TOutput, TKey>>> {
// If we already have data or collection is ready, resolve immediately
if (this.size > 0 || this.isReady()) {
return Promise.resolve(this.state)
Expand All @@ -793,7 +823,7 @@ export class CollectionImpl<
*
* @returns Promise that resolves to an Array containing all items in the collection
*/
toArrayWhenReady(): Promise<Array<TOutput>> {
toArrayWhenReady(): Promise<Array<WithVirtualProps<TOutput, TKey>>> {
// If we already have data or collection is ready, resolve immediately
if (this.size > 0 || this.isReady()) {
return Promise.resolve(this.toArray)
Expand Down Expand Up @@ -823,7 +853,7 @@ export class CollectionImpl<
*/
public currentStateAsChanges(
options: CurrentStateAsChangesOptions = {},
): Array<ChangeMessage<TOutput>> | void {
): Array<ChangeMessage<WithVirtualProps<TOutput, TKey>>> | void {
return currentStateAsChanges(this, options)
}

Expand Down Expand Up @@ -870,8 +900,10 @@ export class CollectionImpl<
* })
*/
public subscribeChanges(
callback: (changes: Array<ChangeMessage<TOutput>>) => void,
options: SubscribeChangesOptions<TOutput> = {},
callback: (
changes: Array<ChangeMessage<WithVirtualProps<TOutput, TKey>>>,
) => void,
options: SubscribeChangesOptions<TOutput, TKey> = {},
): CollectionSubscription {
return this._changes.subscribeChanges(callback, options)
}
Expand Down
Loading
Loading