diff --git a/.changeset/fix-virtual-row-field-types.md b/.changeset/fix-virtual-row-field-types.md new file mode 100644 index 000000000..58f9a4a38 --- /dev/null +++ b/.changeset/fix-virtual-row-field-types.md @@ -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` and `SingleRowRefProxy` 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. diff --git a/packages/db/package.json b/packages/db/package.json index 1b9c8a8e8..7eaa10642 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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", diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index ad1487424..bed4bf665 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -274,7 +274,10 @@ export class CollectionChangesManager< const { where, ...opts } = options let whereExpression = opts.whereExpression if (where) { - const proxy = createSingleRowRefProxy>() + const proxy = createSingleRowRefProxy< + WithVirtualProps, + TKey + >() const result = where(proxy) whereExpression = toExpression(result) } diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 16e7a4428..3528a68d6 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -754,7 +754,7 @@ export class CollectionImpl< * ``` */ public createIndex>( - indexCallback: (row: SingleRowRefProxy) => any, + indexCallback: (row: SingleRowRefProxy) => any, config: IndexOptions = {}, ): BaseIndex { return this._indexes.createIndex(indexCallback, config) diff --git a/packages/db/src/collection/indexes.ts b/packages/db/src/collection/indexes.ts index 84e45d6fc..3fb3aa68f 100644 --- a/packages/db/src/collection/indexes.ts +++ b/packages/db/src/collection/indexes.ts @@ -249,13 +249,13 @@ export class CollectionIndexesManager< * ``` */ public createIndex>( - indexCallback: (row: SingleRowRefProxy) => any, + indexCallback: (row: SingleRowRefProxy) => any, config: IndexOptions = {}, ): BaseIndex { this.lifecycle.validateCollectionUsable(`createIndex`) const indexId = ++this.indexCounter - const singleRowRefProxy = createSingleRowRefProxy() + const singleRowRefProxy = createSingleRowRefProxy() const indexExpression = indexCallback(singleRowRefProxy) const expression = toExpression(indexExpression) diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index b44b0deb9..17b101044 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -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' @@ -797,7 +797,7 @@ export class MaterializeWrapper< export function toArray( query: QueryBuilder, -): ToArrayWrapper> { +): ToArrayWrapper> { return new ToArrayWrapper(query) } @@ -894,7 +894,7 @@ function isConditionValue(value: CaseWhenValue | undefined): boolean { export function materialize( query: QueryBuilder, ): MaterializeWrapper< - GetRawResult, + GetInlineResult, TContext extends SingleResult ? true : false > { return new MaterializeWrapper(query) diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index 3b1933087..2a0f2cd68 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -31,26 +31,32 @@ type SingleRowField = [ ] extends [never] ? RefLeaf : IsPlainObject> extends true - ? SingleRowRefProxy, TKey> | Extract + ? + | SingleRowRefProxy, TKey, false> + | Extract : RefLeaf /** * 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 ? { [K in keyof T]: SingleRowField } & RefProxy & - VirtualPropsRefProxy - : RefProxy & VirtualPropsRefProxy + (IncludeVirtualProps extends true ? VirtualPropsRefProxy : {}) + : RefProxy & + (IncludeVirtualProps extends true ? VirtualPropsRefProxy : {}) /** * Creates a proxy object that records property access paths for a single row @@ -58,7 +64,8 @@ export type SingleRowRefProxy< */ export function createSingleRowRefProxy< T extends Record, ->(): SingleRowRefProxy { + TKey extends string | number = string | number, +>(): SingleRowRefProxy { const cache = new Map() function createProxy(path: Array): any { @@ -101,7 +108,7 @@ export function createSingleRowRefProxy< } // Return the root proxy that starts with an empty path - return createProxy([]) as SingleRowRefProxy + return createProxy([]) as SingleRowRefProxy } /** @@ -215,7 +222,8 @@ export function createRefProxy>( */ export function createRefProxyWithSelected>( aliases: Array, -): RefProxy & T & { $selected: SingleRowRefProxy } { +): RefProxy & + T & { $selected: SingleRowRefProxy } { const baseProxy = createRefProxy(aliases) // Create a proxy for $selected that prefixes all paths with '$selected' @@ -290,7 +298,10 @@ export function createRefProxyWithSelected>( } return Reflect.getOwnPropertyDescriptor(target, prop) }, - }) as RefProxy & T & { $selected: SingleRowRefProxy } + }) as RefProxy & + T & { + $selected: SingleRowRefProxy + } } /** diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index ed860c7f1..f1a5757e9 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -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 = + | Ref + | Ref export type ScalarSelectValue = | BasicExpression | Aggregate @@ -333,7 +338,7 @@ export type ResultTypeFromSelectValue = ? ResultTypeFromCaseWhen : TSelectValue extends QueryBuilder ? Collection> - : TSelectValue extends Ref + : TSelectValue extends AnyRef ? ExtractRef : TSelectValue extends RefLeaf ? IsNullableRef extends true @@ -348,7 +353,7 @@ export type ResultTypeFromSelectValue = ? T | null | undefined : T | null : TSelectValue extends - | Ref + | AnyRef | null | undefined ? @@ -436,7 +441,7 @@ export type ResultTypeFromSelect = TSelectObject[K] extends QueryBuilder ? Collection> : // Ref (full object ref or spread with RefBrand) - recursively process properties - TSelectObject[K] extends Ref + TSelectObject[K] extends AnyRef ? ExtractRef : // RefLeaf (simple property ref like user.name) TSelectObject[K] extends RefLeaf @@ -457,7 +462,7 @@ export type ResultTypeFromSelect = : T | null : // Nullable and/or optional object-type schema field TSelectObject[K] extends - | Ref + | AnyRef | null | undefined ? @@ -522,9 +527,11 @@ type ExtractRef = T extends unknown // shape rather than a one-directional key-subset check. type IsTrueRef = T extends RefLeaf - ? RefShapeMatches>> extends true + ? RefShapeMatches, true>> extends true ? true - : false + : RefShapeMatches, false>> extends true + ? true + : false : false // Strict structural equivalence between two ref shapes. Unlike plain @@ -673,7 +680,7 @@ type ValueOfUnion = T extends unknown : never type RefForContextValue = T extends unknown ? IsPlainObject extends true - ? Ref + ? Ref : RefLeaf : never type RefsSchemaForContext = @@ -753,7 +760,7 @@ export type RefsForContext = { IsNullableContextKey > } & (TContext[`hasResult`] extends true - ? { $selected: Ref } + ? { $selected: Ref } : {}) & BranchUnionResultRefs & JoinedRefsForContext @@ -820,7 +827,7 @@ type NonUndefined = T extends undefined ? never : T type NonNull = 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 @@ -855,8 +862,11 @@ type VirtualPropsRef = { * 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` + * 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` when a helper specifically requires a row root. * * Example usage: * ```typescript @@ -864,7 +874,8 @@ type VirtualPropsRef = { * const users: Ref<{ id: number; profile?: { bio: string } }> = { ... } * users.id // Ref - clean display * users.profile?.bio // Ref - nested optional access works - * users.$synced // RefLeaf - virtual property access + * const rootUsers: Ref<{ id: number }, false, true> = { ... } + * rootUsers.$synced // RefLeaf - row-root virtual property access * * // Nullable ref (left/right/full join side): * select(({ dept }) => ({ name: dept.name })) // result: string | undefined @@ -873,32 +884,38 @@ type VirtualPropsRef = { * select(({ user }) => ({ ...user })) // Returns User type, not Ref types * ``` */ -export type Ref = T extends unknown - ? RefBranch - : never +export type Ref< + T = any, + Nullable extends boolean = false, + IncludeVirtualProps extends boolean = false, +> = T extends unknown ? RefBranch : never -type RefBranch = { +type RefBranch< + T, + Nullable extends boolean, + IncludeVirtualProps extends boolean, +> = { [K in keyof T]: IsNonExactOptional extends true ? IsNonExactNullable extends true ? // Both optional and nullable IsPlainObject> extends true - ? Ref, Nullable> | null | undefined + ? Ref, Nullable, false> | null | undefined : RefLeaf, Nullable> | undefined : // Optional only IsPlainObject> extends true - ? Ref, Nullable> | undefined + ? Ref, Nullable, false> | undefined : RefLeaf, Nullable> | undefined : IsNonExactNullable extends true ? // Nullable only IsPlainObject> extends true - ? Ref, Nullable> | null + ? Ref, Nullable, false> | null : RefLeaf, Nullable> | null : // Required IsPlainObject extends true - ? Ref + ? Ref : RefLeaf } & RefLeaf & - VirtualPropsRef + (IncludeVirtualProps extends true ? VirtualPropsRef : {}) /** * Ref - The user-facing ref type with clean IDE display @@ -1034,9 +1051,13 @@ export type InferResultType = ? GetResult | undefined : Array> -type WithVirtualPropsIfObject = TResult extends object - ? WithVirtualProps - : TResult +type WithVirtualPropsIfAttachable = TResult extends unknown + ? TResult extends object + ? IsPlainObject extends true + ? WithVirtualProps + : TResult + : TResult + : never type PrettifyIfPlainObject = IsPlainObject extends true ? Prettify : T type FromSourceNamesForOptionality = @@ -1079,7 +1100,7 @@ type UnionFromResult< ? JoinedOnlyUnionFromResult : never) type ResultValue = TContext[`hasResult`] extends true - ? WithVirtualPropsIfObject + ? WithVirtualPropsIfAttachable : TContext[`hasUnionFrom`] extends true ? UnionFromResult< TContext[`baseSchema`], @@ -1117,6 +1138,13 @@ type ResultValue = TContext[`hasResult`] extends true */ export type GetRawResult = ResultValue +// 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[`hasResult`] extends true + ? TContext[`result`] + : GetRawResult + export type GetResult = Prettify< ResultValue > diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 44b958aa8..f6300fb08 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1014,7 +1014,7 @@ export interface SubscribeChangesOptions< * }) * ``` */ - where?: (row: SingleRowRefProxy>) => any + where?: (row: SingleRowRefProxy, TKey, true>) => any /** Pre-compiled expression for filtering changes */ whereExpression?: BasicExpression /** diff --git a/packages/db/src/virtual-props.ts b/packages/db/src/virtual-props.ts index 3f600a500..a39c1493f 100644 --- a/packages/db/src/virtual-props.ts +++ b/packages/db/src/virtual-props.ts @@ -132,7 +132,9 @@ export type WithVirtualProps< * // { id: string; name: string } * ``` */ -export type WithoutVirtualProps = Omit +export type WithoutVirtualProps = T extends unknown + ? Omit + : never /** * Checks if a value has virtual properties attached. diff --git a/packages/db/tests/query/includes.test-d.ts b/packages/db/tests/query/includes.test-d.ts index 5cc733668..da1f6f591 100644 --- a/packages/db/tests/query/includes.test-d.ts +++ b/packages/db/tests/query/includes.test-d.ts @@ -240,9 +240,10 @@ describe(`includes subquery types`, () => { expectTypeOf(result.$origin).toEqualTypeOf<`local` | `remote`>() expectTypeOf(result.$key).toEqualTypeOf() expectTypeOf(result.$collectionId).toEqualTypeOf() - expectTypeOf(result.issues[0]!).toMatchTypeOf< - WithVirtualProps<{ id: number; title: string }> - >() + expectTypeOf(result.issues[0]!).toEqualTypeOf<{ + id: number + title: string + }>() }) test(`toArray includes without select infers child type`, () => { @@ -295,13 +296,11 @@ describe(`includes subquery types`, () => { expectTypeOf(result.$origin).toEqualTypeOf<`local` | `remote`>() expectTypeOf(result.$key).toEqualTypeOf() expectTypeOf(result.$collectionId).toEqualTypeOf() - expectTypeOf(result.issues[0]!).toMatchTypeOf< - WithVirtualProps<{ - id: number - title: string - comments: Array> - }> - >() + expectTypeOf(result.issues[0]!).toEqualTypeOf<{ + id: number + title: string + comments: Array<{ id: number; body: string }> + }>() }) test(`toArray supports scalar child subquery selects`, () => { @@ -444,8 +443,8 @@ describe(`includes subquery types`, () => { const result = collection.toArray[0]! expectTypeOf(result.id).toEqualTypeOf() expectTypeOf(result.name).toEqualTypeOf() - expectTypeOf(result.issues).toMatchTypeOf< - Array> + expectTypeOf(result.issues).toEqualTypeOf< + Array<{ id: number; title: string }> >() }) @@ -470,8 +469,8 @@ describe(`includes subquery types`, () => { const result = collection.toArray[0]! expectTypeOf(result.id).toEqualTypeOf() expectTypeOf(result.title).toEqualTypeOf() - expectTypeOf(result.project).toMatchTypeOf< - WithVirtualProps<{ id: number; name: string }> | undefined + expectTypeOf(result.project).toEqualTypeOf< + { id: number; name: string } | undefined >() }) @@ -537,15 +536,11 @@ describe(`includes subquery types`, () => { ) const result = collection.toArray[0]! - expectTypeOf(result.issues[0]!).toMatchTypeOf< - WithVirtualProps<{ - id: number - title: string - firstComment: - | WithVirtualProps<{ id: number; body: string }> - | undefined - }> - >() + expectTypeOf(result.issues[0]!).toEqualTypeOf<{ + id: number + title: string + firstComment: { id: number; body: string } | undefined + }>() }) }) }) diff --git a/packages/db/tests/query/query-api-type-algebra.test-d.ts b/packages/db/tests/query/query-api-type-algebra.test-d.ts index 7ef833f3f..1eb7fe921 100644 --- a/packages/db/tests/query/query-api-type-algebra.test-d.ts +++ b/packages/db/tests/query/query-api-type-algebra.test-d.ts @@ -422,7 +422,7 @@ describe(`query API type algebra`, () => { expectTypeOf(row.exactNull).toEqualTypeOf>() expectTypeOf(row.exactUndefined).toEqualTypeOf>() expectTypeOf(row.nullishObject).toEqualTypeOf< - Ref<{ label: string }> | null | undefined + Ref<{ label: string }, false, false> | null | undefined >() return row.nullish }) @@ -515,6 +515,7 @@ describe(`query API type algebra`, () => { > | null>() expectTypeOf(nullableObject).toEqualTypeOf | null>() return { nullableNumber, nullableText, nullableObject } diff --git a/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts b/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts new file mode 100644 index 000000000..c7d7897b2 --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts @@ -0,0 +1,257 @@ +/** + * # Which types represent virtual-field-bearing rows? + * + * Law and source: `VirtualRowProps` gives Collection and live-query row roots + * `$synced`, `$origin`, `$key`, and `$collectionId`. The reusable `Ref` + * contract in `docs/guides/live-queries.md` accepts nested refs without claiming + * those refs are rows. The output contract established by `includes.test.ts` + * keeps inline `toArray` and `materialize` selections in their selected shape. + * + * The local type relations classify row roots and unprojected whole-row + * children as rows. Projected children, nested refs, nested values, and opaque + * values are values. Discriminated row unions must survive virtual-field + * removal. + * + * Legal forms include required, optional, and nullable nested `Ref` and + * `SingleRowRefProxy` helpers; virtual-field-specific root helpers; and inline + * `toArray` or `materialize` queries with whole-row, object, nested-object, + * array, `findOne`, and Date results. + * + * The production type paths are `RefsForContext`, `SingleRowRefProxy`, and + * `GetInlineResult`. The checkpoint is the inferred callback or published + * `collection.toArray` type. Structural equality checks positive facts; + * `@ts-expect-error` checks reject virtual fields on values. + * + * This oracle does not cover joins, runtime metadata values, publication + * timing, or mutations. The paired runtime oracle checks published values. + * Reach is visible through both production callbacks and every selected output. + * Hostile controls proved that virtual-field-bearing defaults break six + * nested-helper cells and `GetRawResult` enrichment breaks eleven + * projected-value cells. + */ +import { describe, expectTypeOf, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { + createLiveQueryCollection, + eq, + materialize, + toArray, +} from '../../src/query/index.js' +import { mockSyncCollectionOptions } from '../utils.js' +import type { RefLeaf } from '../../src/query/builder/types.js' +import type { SingleRowRefProxy } from '../../src/query/builder/ref-proxy.js' +import type { Ref } from '../../src/query/index.js' +import type { + WithVirtualProps, + WithoutVirtualProps, +} from '../../src/virtual-props.js' + +type Variant = + | { kind: `person`; name: string } + | { kind: `company`; legalName: string } + +type VirtualFieldSubject = + | `row-root` + | `whole-row-child` + | `projected-child` + | `nested-ref` + | `nested-value` + | `opaque-value` + +// Only row roots and unprojected whole-row children carry virtual row fields. +type PublishedValueFor< + T extends object, + TSubject extends VirtualFieldSubject, +> = TSubject extends `row-root` | `whole-row-child` + ? WithVirtualProps + : T + +type QueryRefFor< + T, + TSubject extends `row-root` | `nested-ref`, +> = TSubject extends `row-root` ? Ref : Ref + +type IndexRefFor< + T extends object, + TSubject extends `row-root` | `nested-ref`, +> = TSubject extends `row-root` + ? SingleRowRefProxy + : SingleRowRefProxy + +type VirtualVariant = PublishedValueFor + +type Profile = { label: string } +type Row = { + id: string + profile: Profile + optionalProfile?: Profile + nullableProfile: Profile | null + createdAt: Date + tags: Array +} + +const rows = createCollection( + mockSyncCollectionOptions({ + id: `virtual-row-fields-type-oracle`, + getKey: (row) => row.id, + initialData: [], + }), +) + +describe(`virtual row field type boundary`, () => { + test(`WithoutVirtualProps preserves discriminated row unions`, () => { + expectTypeOf>().toEqualTypeOf() + }) + + test(`query refs expose virtual fields only at row roots`, () => { + const profileLabelIs = (profile: QueryRefFor) => + eq(profile.label, `nested`) + const rowKey = (row: QueryRefFor) => row.$key + + const collection = createLiveQueryCollection((q) => + q.from({ row: rows }).select(({ row }) => { + expectTypeOf(row.$key).toEqualTypeOf>() + expectTypeOf(row.$synced).toEqualTypeOf>() + profileLabelIs(row.profile) + if (row.optionalProfile) profileLabelIs(row.optionalProfile) + if (row.nullableProfile) profileLabelIs(row.nullableProfile) + rowKey(row) + + // @ts-expect-error Nested user objects are not collection rows. + row.profile.$key + // @ts-expect-error Optional nested user objects are not collection rows. + row.optionalProfile?.$synced + // @ts-expect-error Nullable nested user objects are not collection rows. + row.nullableProfile?.$origin + + return { id: row.id, profile: row.profile } + }), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.profile).toEqualTypeOf() + expectTypeOf(result.$key).toEqualTypeOf() + // @ts-expect-error A selected nested user object remains a value, not a row. + result.profile.$key + }) + + test(`single-row refs expose virtual fields only at row roots`, () => { + const profileLabelIs = (profile: IndexRefFor) => + eq(profile.label, `nested`) + const rowKey = (row: IndexRefFor) => row.$key + + rows.createIndex((row) => { + expectTypeOf(row.$collectionId).toEqualTypeOf>() + expectTypeOf(row.$key).toEqualTypeOf>() + profileLabelIs(row.profile) + if (row.optionalProfile) profileLabelIs(row.optionalProfile) + if (row.nullableProfile) profileLabelIs(row.nullableProfile) + rowKey(row) + + // @ts-expect-error Nested user objects are not collection rows. + row.profile.$key + // @ts-expect-error Optional nested user objects are not collection rows. + row.optionalProfile?.$synced + // @ts-expect-error Nullable nested user objects are not collection rows. + row.nullableProfile?.$origin + + return row.id + }) + }) + + test(`opaque scalar child results do not gain virtual row fields`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ row: rows }).select(({ row }) => ({ + id: row.id, + dates: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.createdAt), + ), + firstDate: materialize( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.createdAt) + .findOne(), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.dates[0]!).toEqualTypeOf() + expectTypeOf(result.firstDate).toEqualTypeOf() + + // @ts-expect-error Date values are not rows and have no virtual row fields. + result.dates[0]!.$key + // @ts-expect-error Materialized Date values are not rows either. + result.firstDate?.$synced + }) + + test(`projected child metadata follows row-shaped results`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ row: rows }).select(({ row }) => ({ + id: row.id, + wholeRows: toArray( + q.from({ child: rows }).where(({ child }) => eq(child.id, row.id)), + ), + objects: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ label: child.profile.label })), + ), + nestedObjects: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ + nested: { label: child.profile.label }, + })), + ), + arrays: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.tags), + ), + firstObject: materialize( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ label: child.profile.label })) + .findOne(), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.wholeRows[0]!).toEqualTypeOf< + PublishedValueFor + >() + expectTypeOf(result.objects[0]!).toEqualTypeOf< + PublishedValueFor<{ label: string }, `projected-child`> + >() + expectTypeOf(result.nestedObjects[0]!).toEqualTypeOf< + PublishedValueFor<{ nested: { label: string } }, `projected-child`> + >() + expectTypeOf(result.firstObject).toEqualTypeOf< + PublishedValueFor<{ label: string }, `projected-child`> | undefined + >() + expectTypeOf(result.arrays[0]!).toEqualTypeOf< + PublishedValueFor, `opaque-value`> + >() + + // @ts-expect-error Projected child objects are values, not rows. + result.objects[0]!.$key + // @ts-expect-error Projected child objects remain values when nested. + result.nestedObjects[0]!.$key + // @ts-expect-error Nested projection objects are values, not rows. + result.nestedObjects[0]!.nested.$key + // @ts-expect-error Selected array values are opaque values, not rows. + result.arrays[0]!.$key + // @ts-expect-error Projected findOne results are values, not rows. + result.firstObject?.$key + }) +}) diff --git a/packages/db/tests/query/virtual-row-fields-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-oracle.test.ts new file mode 100644 index 000000000..aae5220ce --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -0,0 +1,229 @@ +/** + * # Which published values carry virtual row fields? + * + * Law and source: `VirtualRowProps` gives Collection and live-query row roots + * `$synced`, `$origin`, `$key`, and `$collectionId`. The output contract + * established by `includes.test.ts` keeps inline `toArray` and `materialize` + * selections in their selected shape instead of publishing each selected value + * as a Collection row. + * + * `expectsVirtualFields` is the independent model: row roots and unprojected + * whole-row children are rows. Projected children, nested values, and opaque + * values are values. The paired type oracle uses the same classification. + * + * The legal query forms exercised here are an unprojected whole-row child and + * object, nested-object, array, `findOne`, and Date child projections. The + * production driver calls `createLiveQueryCollection`, `preload`, `toArray`, + * and `materialize`. The checkpoint is `live.toArray` after `preload` resolves. + * + * `hasVirtualProps` observes all four virtual fields. Exact `$key`, selected + * shapes, and nonempty child results prove the intended paths ran. This oracle + * does not cover joins, ordering, updates, callback boundaries, or sync-state + * transitions. Prior hostile controls made projected-value enrichment and a + * missing whole-row classification fail at these observations. + */ +import { describe, expect, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { + createLiveQueryCollection, + eq, + materialize, + toArray, +} from '../../src/query/index.js' +import { hasVirtualProps } from '../../src/virtual-props.js' +import { mockSyncCollectionOptions } from '../utils.js' + +type Row = { + id: string + profile: { label: string } + createdAt: Date + tags: Array +} + +type VirtualFieldSubject = + | `row-root` + | `whole-row-child` + | `projected-child` + | `nested-ref` + | `nested-value` + | `opaque-value` + +const virtualFieldNames = [ + `$key`, + `$synced`, + `$origin`, + `$collectionId`, +] as const + +// Only row roots and unprojected whole-row children carry virtual row fields. +function expectsVirtualFields(subject: VirtualFieldSubject): boolean { + return subject === `row-root` || subject === `whole-row-child` +} + +describe(`virtual row field runtime boundary`, () => { + test(`matches the row and value classification after publication`, async () => { + const createdAt = new Date(`2026-09-20T12:34:56.000Z`) + const rows = createCollection( + mockSyncCollectionOptions({ + id: `virtual-row-fields-runtime-oracle-source`, + getKey: (row) => row.id, + initialData: [ + { + id: `row-1`, + profile: { label: `nested` }, + createdAt, + tags: [`one`, `two`], + }, + ], + }), + ) + const live = createLiveQueryCollection((q) => + q.from({ row: rows }).select(({ row }) => ({ + id: row.id, + profile: row.profile, + createdAt: row.createdAt, + dates: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.createdAt), + ), + firstDate: materialize( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.createdAt) + .findOne(), + ), + wholeRows: toArray( + q.from({ child: rows }).where(({ child }) => eq(child.id, row.id)), + ), + objects: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ label: child.profile.label })), + ), + nestedObjects: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ + nested: { label: child.profile.label }, + })), + ), + arrays: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.tags), + ), + firstObject: materialize( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ label: child.profile.label })) + .findOne(), + ), + })), + ) + + try { + await live.preload() + + const result = live.toArray[0]! + const observations: Array<{ + name: string + subject: VirtualFieldSubject + value: unknown + }> = [ + { name: `published query row`, subject: `row-root`, value: result }, + { + name: `nested profile`, + subject: `nested-value`, + value: result.profile, + }, + { + name: `selected Date`, + subject: `opaque-value`, + value: result.createdAt, + }, + { + name: `toArray Date`, + subject: `opaque-value`, + value: result.dates[0], + }, + { + name: `materialized Date`, + subject: `opaque-value`, + value: result.firstDate, + }, + { + name: `whole-row child`, + subject: `whole-row-child`, + value: result.wholeRows[0], + }, + { + name: `projected child object`, + subject: `projected-child`, + value: result.objects[0], + }, + { + name: `projected child wrapper`, + subject: `projected-child`, + value: result.nestedObjects[0], + }, + { + name: `nested projected value`, + subject: `nested-value`, + value: result.nestedObjects[0]!.nested, + }, + { + name: `selected array value`, + subject: `opaque-value`, + value: result.arrays[0], + }, + { + name: `materialized projected child`, + subject: `projected-child`, + value: result.firstObject, + }, + ] + + for (const observation of observations) { + const expectsFields = expectsVirtualFields(observation.subject) + expect(hasVirtualProps(observation.value), observation.name).toBe( + expectsFields, + ) + if (!expectsFields) { + for (const field of virtualFieldNames) { + expect( + field in Object(observation.value), + `${observation.name} must not expose ${field}`, + ).toBe(false) + } + } + } + + expect(result.$key).toBe(`row-1`) + + expect(result.profile).toEqual({ label: `nested` }) + expect(`$key` in result.profile).toBe(false) + + expect(result.createdAt).toBeInstanceOf(Date) + expect(result.dates).toHaveLength(1) + expect(result.dates[0]).toBeInstanceOf(Date) + expect(result.firstDate).toBeInstanceOf(Date) + + expect(result.wholeRows).toHaveLength(1) + expect(result.wholeRows[0]!.$key).toBe(`row-1`) + expect(result.objects).toEqual([{ label: `nested` }]) + expect(result.nestedObjects).toEqual([{ nested: { label: `nested` } }]) + expect(result.arrays).toEqual([[`one`, `two`]]) + expect(result.firstObject).toEqual({ label: `nested` }) + } finally { + await live.cleanup() + await rows.cleanup() + } + }) +}) diff --git a/packages/db/tests/single-row-ref-proxy.test-d.ts b/packages/db/tests/single-row-ref-proxy.test-d.ts index ed89b5af5..ff9fe0631 100644 --- a/packages/db/tests/single-row-ref-proxy.test-d.ts +++ b/packages/db/tests/single-row-ref-proxy.test-d.ts @@ -59,19 +59,21 @@ describe(`SingleRowRefProxy type algebra`, () => { test(`optional and nullable objects remain traversable with schema guards`, () => { collection.createIndex((row) => { expectTypeOf(row.optionalTimestamp).toEqualTypeOf< - SingleRowRefProxy | undefined + SingleRowRefProxy | undefined >() expectTypeOf(row.optionalTimestamp?.seconds).toEqualTypeOf< RefLeaf | undefined >() - expectTypeOf( - row.nullableTimestamp, - ).toEqualTypeOf | null>() + expectTypeOf(row.nullableTimestamp).toEqualTypeOf | null>() expectTypeOf(row.nullableTimestamp?.seconds).toEqualTypeOf< RefLeaf | undefined >() expectTypeOf(row.nullishTimestamp).toEqualTypeOf< - SingleRowRefProxy | null | undefined + SingleRowRefProxy | null | undefined >() expectTypeOf(row.nested.timestamp?.nanoseconds).toEqualTypeOf< RefLeaf | undefined