Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .changeset/fix-virtual-row-field-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/db': minor
---

Keep virtual row fields on inferred collection and query row roots without
exposing them on nested user objects or projected child values.
Default `Ref<T>` and `SingleRowRefProxy<T>` annotations now support reusable
helpers for both root and nested refs; helpers that require row metadata should
set their third generic parameter to `true`. Preserve discriminated unions when
removing virtual row fields.
2 changes: 1 addition & 1 deletion packages/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"lint": "eslint . --fix",
"test": "vitest --run",
"test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts",
"test:oracles": "vitest --run --coverage.enabled=false tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/optimistic-settlement-boundaries.test.ts tests/optimistic-history-publication.test.ts tests/optimistic-history-outcomes.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-lifecycle-publication.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts",
"test:oracles": "vitest --run --coverage.enabled=false tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/optimistic-settlement-boundaries.test.ts tests/optimistic-history-publication.test.ts tests/optimistic-history-outcomes.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-lifecycle-publication.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts",
"bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run"
},
"type": "module",
Expand Down
5 changes: 4 additions & 1 deletion packages/db/src/collection/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,10 @@ export class CollectionChangesManager<
const { where, ...opts } = options
let whereExpression = opts.whereExpression
if (where) {
const proxy = createSingleRowRefProxy<WithVirtualProps<TOutput, TKey>>()
const proxy = createSingleRowRefProxy<
WithVirtualProps<TOutput, TKey>,
TKey
>()
const result = where(proxy)
whereExpression = toExpression(result)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@ export class CollectionImpl<
* ```
*/
public createIndex<TIndexType extends IndexConstructor<TKey>>(
indexCallback: (row: SingleRowRefProxy<TOutput>) => any,
indexCallback: (row: SingleRowRefProxy<TOutput, TKey, true>) => any,
config: IndexOptions<TIndexType> = {},
): BaseIndex<TKey> {
return this._indexes.createIndex(indexCallback, config)
Expand Down
4 changes: 2 additions & 2 deletions packages/db/src/collection/indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,13 @@ export class CollectionIndexesManager<
* ```
*/
public createIndex<TIndexType extends IndexConstructor<TKey>>(
indexCallback: (row: SingleRowRefProxy<TOutput>) => any,
indexCallback: (row: SingleRowRefProxy<TOutput, TKey, true>) => any,
config: IndexOptions<TIndexType> = {},
): BaseIndex<TKey> {
this.lifecycle.validateCollectionUsable(`createIndex`)

const indexId = ++this.indexCounter
const singleRowRefProxy = createSingleRowRefProxy<TOutput>()
const singleRowRefProxy = createSingleRowRefProxy<TOutput, TKey>()
const indexExpression = indexCallback(singleRowRefProxy)
const expression = toExpression(indexExpression)

Expand Down
6 changes: 3 additions & 3 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { RefProxy } from './ref-proxy.js'
import type { SingleResult } from '../../types.js'
import type {
Context,
GetRawResult,
GetInlineResult,
RefLeaf,
StringifiableScalar,
} from './types.js'
Expand Down Expand Up @@ -797,7 +797,7 @@ export class MaterializeWrapper<

export function toArray<TContext extends Context>(
query: QueryBuilder<TContext>,
): ToArrayWrapper<GetRawResult<TContext>> {
): ToArrayWrapper<GetInlineResult<TContext>> {
return new ToArrayWrapper(query)
}

Expand Down Expand Up @@ -894,7 +894,7 @@ function isConditionValue(value: CaseWhenValue | undefined): boolean {
export function materialize<TContext extends Context>(
query: QueryBuilder<TContext>,
): MaterializeWrapper<
GetRawResult<TContext>,
GetInlineResult<TContext>,
TContext extends SingleResult ? true : false
> {
return new MaterializeWrapper(query)
Expand Down
29 changes: 20 additions & 9 deletions packages/db/src/query/builder/ref-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,34 +31,41 @@ type SingleRowField<V, TKey extends string | number> = [
] extends [never]
? RefLeaf<V>
: IsPlainObject<NonNullable<V>> extends true
? SingleRowRefProxy<NonNullable<V>, TKey> | Extract<V, null | undefined>
?
| SingleRowRefProxy<NonNullable<V>, TKey, false>
| Extract<V, null | undefined>
: RefLeaf<V>

/**
* Type for creating a RefProxy for a single row/type without namespacing
* Used in collection indexes and where clauses
*
* Includes virtual properties ($synced, $origin, $key, $collectionId) for
* querying on sync status and row metadata.
* Inferred row roots include virtual properties ($synced, $origin, $key,
* $collectionId). The default exported shape is suitable for reusable helpers
* that can accept either roots or recursively traversed user objects. Use the
* third parameter as `true` when a helper specifically requires a row root.
*/
export type SingleRowRefProxy<
T,
TKey extends string | number = string | number,
IncludeVirtualProps extends boolean = false,
> =
T extends Record<string, any>
? {
[K in keyof T]: SingleRowField<T[K], TKey>
} & RefProxy<T> &
VirtualPropsRefProxy<TKey>
: RefProxy<T> & VirtualPropsRefProxy<TKey>
(IncludeVirtualProps extends true ? VirtualPropsRefProxy<TKey> : {})
: RefProxy<T> &
(IncludeVirtualProps extends true ? VirtualPropsRefProxy<TKey> : {})

/**
* Creates a proxy object that records property access paths for a single row
* Used in collection indexes and where clauses
*/
export function createSingleRowRefProxy<
T extends Record<string, any>,
>(): SingleRowRefProxy<T> {
TKey extends string | number = string | number,
>(): SingleRowRefProxy<T, TKey, true> {
const cache = new Map<string, any>()

function createProxy(path: Array<string>): any {
Expand Down Expand Up @@ -101,7 +108,7 @@ export function createSingleRowRefProxy<
}

// Return the root proxy that starts with an empty path
return createProxy([]) as SingleRowRefProxy<T>
return createProxy([]) as SingleRowRefProxy<T, TKey, true>
}

/**
Expand Down Expand Up @@ -215,7 +222,8 @@ export function createRefProxy<T extends Record<string, any>>(
*/
export function createRefProxyWithSelected<T extends Record<string, any>>(
aliases: Array<string>,
): RefProxy<T> & T & { $selected: SingleRowRefProxy<any> } {
): RefProxy<T> &
T & { $selected: SingleRowRefProxy<any, string | number, true> } {
const baseProxy = createRefProxy(aliases)

// Create a proxy for $selected that prefixes all paths with '$selected'
Expand Down Expand Up @@ -290,7 +298,10 @@ export function createRefProxyWithSelected<T extends Record<string, any>>(
}
return Reflect.getOwnPropertyDescriptor(target, prop)
},
}) as RefProxy<T> & T & { $selected: SingleRowRefProxy<any> }
}) as RefProxy<T> &
T & {
$selected: SingleRowRefProxy<any, string | number, true>
}
}

/**
Expand Down
78 changes: 53 additions & 25 deletions packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ type SelectValue =

// Recursive shape for select objects allowing nested projections
type SelectShape = { [key: string]: SelectValue | SelectShape }
// Selection inference accepts both row-root refs (with virtual row fields) and
// nested object refs (without them).
type AnyRef<T = any, Nullable extends boolean = false> =
| Ref<T, Nullable, true>
| Ref<T, Nullable, false>
export type ScalarSelectValue =
| BasicExpression
| Aggregate
Expand Down Expand Up @@ -333,7 +338,7 @@ export type ResultTypeFromSelectValue<TSelectValue> =
? ResultTypeFromCaseWhen<T>
: TSelectValue extends QueryBuilder<infer TChildContext>
? Collection<GetResult<TChildContext>>
: TSelectValue extends Ref<infer _T>
: TSelectValue extends AnyRef<infer _T>
? ExtractRef<TSelectValue>
: TSelectValue extends RefLeaf<infer T>
? IsNullableRef<TSelectValue> extends true
Expand All @@ -348,7 +353,7 @@ export type ResultTypeFromSelectValue<TSelectValue> =
? T | null | undefined
: T | null
: TSelectValue extends
| Ref<infer _T>
| AnyRef<infer _T>
| null
| undefined
?
Expand Down Expand Up @@ -436,7 +441,7 @@ export type ResultTypeFromSelect<TSelectObject> =
TSelectObject[K] extends QueryBuilder<infer TChildContext>
? Collection<GetResult<TChildContext>>
: // Ref (full object ref or spread with RefBrand) - recursively process properties
TSelectObject[K] extends Ref<infer _T>
TSelectObject[K] extends AnyRef<infer _T>
? ExtractRef<TSelectObject[K]>
: // RefLeaf (simple property ref like user.name)
TSelectObject[K] extends RefLeaf<infer T>
Expand All @@ -457,7 +462,7 @@ export type ResultTypeFromSelect<TSelectObject> =
: T | null
: // Nullable and/or optional object-type schema field
TSelectObject[K] extends
| Ref<infer _T>
| AnyRef<infer _T>
| null
| undefined
?
Expand Down Expand Up @@ -522,9 +527,11 @@ type ExtractRef<T> = T extends unknown
// shape rather than a one-directional key-subset check.
type IsTrueRef<T> =
T extends RefLeaf<infer U>
? RefShapeMatches<T, Ref<U, IsNullableRef<T>>> extends true
? RefShapeMatches<T, Ref<U, IsNullableRef<T>, true>> extends true
? true
: false
: RefShapeMatches<T, Ref<U, IsNullableRef<T>, false>> extends true
? true
: false
: false

// Strict structural equivalence between two ref shapes. Unlike plain
Expand Down Expand Up @@ -673,7 +680,7 @@ type ValueOfUnion<T, K extends PropertyKey> = T extends unknown
: never
type RefForContextValue<T, Nullable extends boolean = false> = T extends unknown
? IsPlainObject<T> extends true
? Ref<T, Nullable>
? Ref<T, Nullable, true>
: RefLeaf<T, Nullable>
: never
type RefsSchemaForContext<TContext extends Context> =
Expand Down Expand Up @@ -753,7 +760,7 @@ export type RefsForContext<TContext extends Context> = {
IsNullableContextKey<TContext, K>
>
} & (TContext[`hasResult`] extends true
? { $selected: Ref<TContext[`result`]> }
? { $selected: Ref<TContext[`result`], false, true> }
: {}) &
BranchUnionResultRefs<TContext> &
JoinedRefsForContext<TContext>
Expand Down Expand Up @@ -820,7 +827,7 @@ type NonUndefined<T> = T extends undefined ? never : T
type NonNull<T> = T extends null ? never : T

/**
* Virtual properties available on all Ref types in query builders.
* Virtual properties available on row-root Ref types in query builders.
* These allow querying on sync status, origin, key, and collection ID.
*
* @example
Expand Down Expand Up @@ -855,16 +862,20 @@ type VirtualPropsRef<TKey extends string | number = string | number> = {
* through all nested property accesses, ensuring the result type includes
* `| undefined` for all fields accessed through this ref.
*
* Includes virtual properties ($synced, $origin, $key, $collectionId) for
* querying on sync status and row metadata.
* Inferred row-root refs include virtual properties ($synced, $origin, $key,
* $collectionId) for querying on row metadata. The default exported `Ref<T>`
* shape is suitable for reusable helpers that can accept either a row root or
* a recursively traversed user object, so it does not require those fields.
* Use `Ref<T, false, true>` when a helper specifically requires a row root.
*
* Example usage:
* ```typescript
* // Clean interface - no internal properties visible
* const users: Ref<{ id: number; profile?: { bio: string } }> = { ... }
* users.id // Ref<number> - clean display
* users.profile?.bio // Ref<string> - nested optional access works
* users.$synced // RefLeaf<boolean> - virtual property access
* const rootUsers: Ref<{ id: number }, false, true> = { ... }
* rootUsers.$synced // RefLeaf<boolean> - row-root virtual property access
*
* // Nullable ref (left/right/full join side):
* select(({ dept }) => ({ name: dept.name })) // result: string | undefined
Expand All @@ -873,32 +884,38 @@ type VirtualPropsRef<TKey extends string | number = string | number> = {
* select(({ user }) => ({ ...user })) // Returns User type, not Ref types
* ```
*/
export type Ref<T = any, Nullable extends boolean = false> = T extends unknown
? RefBranch<T, Nullable>
: never
export type Ref<
T = any,
Nullable extends boolean = false,
IncludeVirtualProps extends boolean = false,
> = T extends unknown ? RefBranch<T, Nullable, IncludeVirtualProps> : never

type RefBranch<T, Nullable extends boolean> = {
type RefBranch<
T,
Nullable extends boolean,
IncludeVirtualProps extends boolean,
> = {
[K in keyof T]: IsNonExactOptional<T[K]> extends true
? IsNonExactNullable<T[K]> extends true
? // Both optional and nullable
IsPlainObject<NonNullable<T[K]>> extends true
? Ref<NonNullable<T[K]>, Nullable> | null | undefined
? Ref<NonNullable<T[K]>, Nullable, false> | null | undefined
: RefLeaf<NonUndefined<T[K]>, Nullable> | undefined
: // Optional only
IsPlainObject<NonUndefined<T[K]>> extends true
? Ref<NonUndefined<T[K]>, Nullable> | undefined
? Ref<NonUndefined<T[K]>, Nullable, false> | undefined
: RefLeaf<NonUndefined<T[K]>, Nullable> | undefined
: IsNonExactNullable<T[K]> extends true
? // Nullable only
IsPlainObject<NonNull<T[K]>> extends true
? Ref<NonNull<T[K]>, Nullable> | null
? Ref<NonNull<T[K]>, Nullable, false> | null
: RefLeaf<NonNull<T[K]>, Nullable> | null
: // Required
IsPlainObject<T[K]> extends true
? Ref<T[K], Nullable>
? Ref<T[K], Nullable, false>
: RefLeaf<T[K], Nullable>
} & RefLeaf<T, Nullable> &
VirtualPropsRef
(IncludeVirtualProps extends true ? VirtualPropsRef : {})

/**
* Ref - The user-facing ref type with clean IDE display
Expand Down Expand Up @@ -1034,9 +1051,13 @@ export type InferResultType<TContext extends Context> =
? GetResult<TContext> | undefined
: Array<GetResult<TContext>>

type WithVirtualPropsIfObject<TResult> = TResult extends object
? WithVirtualProps<TResult, string | number>
: TResult
type WithVirtualPropsIfAttachable<TResult> = TResult extends unknown
? TResult extends object
? IsPlainObject<TResult> extends true
? WithVirtualProps<TResult, string | number>
: TResult
: TResult
: never

type PrettifyIfPlainObject<T> = IsPlainObject<T> extends true ? Prettify<T> : T
type FromSourceNamesForOptionality<TContext extends Context> =
Expand Down Expand Up @@ -1079,7 +1100,7 @@ type UnionFromResult<
? JoinedOnlyUnionFromResult<TBaseSchema, TSchema>
: never)
type ResultValue<TContext extends Context> = TContext[`hasResult`] extends true
? WithVirtualPropsIfObject<TContext[`result`]>
? WithVirtualPropsIfAttachable<TContext[`result`]>
: TContext[`hasUnionFrom`] extends true
? UnionFromResult<
TContext[`baseSchema`],
Expand Down Expand Up @@ -1117,6 +1138,13 @@ type ResultValue<TContext extends Context> = TContext[`hasResult`] extends true
*/
export type GetRawResult<TContext extends Context> = ResultValue<TContext>

// Inline materialization bypasses a child Collection, so selected child values
// do not pass through Collection enrichment and must keep their runtime shape.
export type GetInlineResult<TContext extends Context> =
TContext[`hasResult`] extends true
? TContext[`result`]
: GetRawResult<TContext>

export type GetResult<TContext extends Context> = Prettify<
ResultValue<TContext>
>
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,7 @@ export interface SubscribeChangesOptions<
* })
* ```
*/
where?: (row: SingleRowRefProxy<WithVirtualProps<T, TKey>>) => any
where?: (row: SingleRowRefProxy<WithVirtualProps<T, TKey>, TKey, true>) => any
/** Pre-compiled expression for filtering changes */
whereExpression?: BasicExpression<boolean>
/**
Expand Down
4 changes: 3 additions & 1 deletion packages/db/src/virtual-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ export type WithVirtualProps<
* // { id: string; name: string }
* ```
*/
export type WithoutVirtualProps<T> = Omit<T, keyof VirtualRowProps>
export type WithoutVirtualProps<T> = T extends unknown
? Omit<T, keyof VirtualRowProps>
: never

/**
* Checks if a value has virtual properties attached.
Expand Down
Loading
Loading