From a013b9f5c40dce22e0e18ab32451fa6108d657c2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 20 Sep 2026 10:29:41 +0100 Subject: [PATCH 1/4] fix(db): align virtual row field types with runtime --- .changeset/fix-virtual-row-field-types.md | 7 + packages/db/package.json | 2 +- packages/db/src/query/builder/ref-proxy.ts | 15 ++- packages/db/src/query/builder/types.ts | 62 ++++++--- packages/db/src/virtual-props.ts | 4 +- .../query/query-api-type-algebra.test-d.ts | 2 +- .../query/virtual-row-fields-oracle.test-d.ts | 124 ++++++++++++++++++ .../query/virtual-row-fields-oracle.test.ts | 79 +++++++++++ .../db/tests/single-row-ref-proxy.test-d.ts | 12 +- 9 files changed, 273 insertions(+), 34 deletions(-) create mode 100644 .changeset/fix-virtual-row-field-types.md create mode 100644 packages/db/tests/query/virtual-row-fields-oracle.test-d.ts create mode 100644 packages/db/tests/query/virtual-row-fields-oracle.test.ts diff --git a/.changeset/fix-virtual-row-field-types.md b/.changeset/fix-virtual-row-field-types.md new file mode 100644 index 0000000000..a780e65433 --- /dev/null +++ b/.changeset/fix-virtual-row-field-types.md @@ -0,0 +1,7 @@ +--- +'@tanstack/db': patch +--- + +Keep virtual row fields on collection and query row roots without exposing them +on nested user objects or opaque scalar subquery results. Preserve +discriminated unions when removing virtual row fields. diff --git a/packages/db/package.json b/packages/db/package.json index 400431147f..2748eacbd7 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 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 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/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index 3b19330871..1d4deb24ce 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -31,26 +31,31 @@ 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. + * The row root includes virtual properties ($synced, $origin, $key, + * $collectionId) for querying on row metadata. Recursively traversed user + * objects do not, because those values are not independently published rows. */ export type SingleRowRefProxy< T, TKey extends string | number = string | number, + IncludeVirtualProps extends boolean = true, > = 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 diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index ed860c7f19..22ba669ffb 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 ? @@ -524,7 +529,9 @@ type IsTrueRef = T extends RefLeaf ? RefShapeMatches>> extends true ? true - : false + : RefShapeMatches, false>> extends true + ? true + : false : false // Strict structural equivalence between two ref shapes. Unlike plain @@ -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,9 @@ 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. + * Row-root refs include virtual properties ($synced, $origin, $key, + * $collectionId) for querying on row metadata. Recursively traversed user + * objects do not, because those values are not independently published rows. * * Example usage: * ```typescript @@ -873,32 +881,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 = true, +> = 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 +1048,15 @@ 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 extends ReadonlyArray + ? WithVirtualProps + : TResult + : TResult + : never type PrettifyIfPlainObject = IsPlainObject extends true ? Prettify : T type FromSourceNamesForOptionality = @@ -1079,7 +1099,7 @@ type UnionFromResult< ? JoinedOnlyUnionFromResult : never) type ResultValue = TContext[`hasResult`] extends true - ? WithVirtualPropsIfObject + ? WithVirtualPropsIfAttachable : TContext[`hasUnionFrom`] extends true ? UnionFromResult< TContext[`baseSchema`], diff --git a/packages/db/src/virtual-props.ts b/packages/db/src/virtual-props.ts index 3f600a5008..a39c1493f3 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/query-api-type-algebra.test-d.ts b/packages/db/tests/query/query-api-type-algebra.test-d.ts index 7ef833f3f5..6ce6b786e2 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 }) 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 0000000000..e310de1858 --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts @@ -0,0 +1,124 @@ +/** + * Oracle owner: the compile-time boundary for virtual row fields. + * + * `$synced`, `$origin`, `$key`, and `$collectionId` belong to collection and + * query rows. They remain available on root refs, but nested user objects and + * opaque scalar child results are values rather than rows. Discriminated row + * unions must also survive removal of their virtual fields. + * + * TypeScript structural equality and negative `@ts-expect-error` cells are the + * independent judges. The paired runtime oracle verifies the same boundary on + * published values. + */ +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 { + WithVirtualProps, + WithoutVirtualProps, +} from '../../src/virtual-props.js' + +type Variant = + | { kind: `person`; name: string } + | { kind: `company`; legalName: string } +type VirtualVariant = WithVirtualProps + +type Profile = { label: string } +type Row = { + id: string + profile: Profile + optionalProfile?: Profile + nullableProfile: Profile | null + createdAt: Date +} + +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 collection = createLiveQueryCollection((q) => + q.from({ row: rows }).select(({ row }) => { + expectTypeOf(row.$key).toEqualTypeOf>() + expectTypeOf(row.$synced).toEqualTypeOf>() + + // @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`, () => { + rows.createIndex((row) => { + expectTypeOf(row.$collectionId).toEqualTypeOf>() + expectTypeOf(row.$key).toEqualTypeOf>() + + // @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 + }) +}) 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 0000000000..ecc725c3a9 --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -0,0 +1,79 @@ +/** + * Runtime correspondence for the virtual-row-field type oracle. + * + * The published query row owns virtual metadata. Nested user objects and + * opaque values selected through scalar child queries retain their original + * runtime shapes and must not be decorated as rows. + */ +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 +} + +describe(`virtual row field runtime boundary`, () => { + test(`decorates rows but not nested user objects or opaque child values`, 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 }], + }), + ) + 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(), + ), + })), + ) + + try { + await live.preload() + + const result = live.toArray[0]! + expect(hasVirtualProps(result)).toBe(true) + expect(result.$key).toBe(`row-1`) + + expect(result.profile).toEqual({ label: `nested` }) + expect(hasVirtualProps(result.profile)).toBe(false) + expect(`$key` in result.profile).toBe(false) + + expect(result.createdAt).toBeInstanceOf(Date) + expect(hasVirtualProps(result.createdAt)).toBe(false) + expect(result.dates).toHaveLength(1) + expect(result.dates[0]).toBeInstanceOf(Date) + expect(hasVirtualProps(result.dates[0])).toBe(false) + expect(result.firstDate).toBeInstanceOf(Date) + expect(hasVirtualProps(result.firstDate)).toBe(false) + } 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 ed89b5af5c..ff9fe06317 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 From 0613357f4af67a0705220667deb995f2b7379952 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 20 Sep 2026 16:44:54 +0100 Subject: [PATCH 2/4] fix(db): preserve reusable ref compatibility --- .changeset/fix-virtual-row-field-types.md | 11 ++- packages/db/src/collection/changes.ts | 5 +- packages/db/src/collection/index.ts | 2 +- packages/db/src/collection/indexes.ts | 4 +- packages/db/src/query/builder/functions.ts | 6 +- packages/db/src/query/builder/ref-proxy.ts | 22 +++-- packages/db/src/query/builder/types.ts | 32 +++++--- packages/db/src/types.ts | 2 +- packages/db/tests/query/includes.test-d.ts | 41 +++++----- .../query/query-api-type-algebra.test-d.ts | 1 + .../query/virtual-row-fields-oracle.test-d.ts | 80 +++++++++++++++++++ .../query/virtual-row-fields-oracle.test.ts | 48 ++++++++++- 12 files changed, 198 insertions(+), 56 deletions(-) diff --git a/.changeset/fix-virtual-row-field-types.md b/.changeset/fix-virtual-row-field-types.md index a780e65433..58f9a4a38c 100644 --- a/.changeset/fix-virtual-row-field-types.md +++ b/.changeset/fix-virtual-row-field-types.md @@ -1,7 +1,10 @@ --- -'@tanstack/db': patch +'@tanstack/db': minor --- -Keep virtual row fields on collection and query row roots without exposing them -on nested user objects or opaque scalar subquery results. Preserve -discriminated unions when removing virtual row fields. +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/src/collection/changes.ts b/packages/db/src/collection/changes.ts index ad14874245..bed4bf665b 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 354da9941b..a99b5a11d1 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 84e45d6fcd..3fb3aa68f8 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 84c26e5ca4..6b6ed0bee3 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' @@ -751,7 +751,7 @@ export class MaterializeWrapper< export function toArray( query: QueryBuilder, -): ToArrayWrapper> { +): ToArrayWrapper> { return new ToArrayWrapper(query) } @@ -848,7 +848,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 1d4deb24ce..2a0f2cd680 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -40,14 +40,15 @@ type SingleRowField = [ * Type for creating a RefProxy for a single row/type without namespacing * Used in collection indexes and where clauses * - * The row root includes virtual properties ($synced, $origin, $key, - * $collectionId) for querying on row metadata. Recursively traversed user - * objects do not, because those values are not independently published rows. + * 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 = true, + IncludeVirtualProps extends boolean = false, > = T extends Record ? { @@ -63,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 { @@ -106,7 +108,7 @@ export function createSingleRowRefProxy< } // Return the root proxy that starts with an empty path - return createProxy([]) as SingleRowRefProxy + return createProxy([]) as SingleRowRefProxy } /** @@ -220,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' @@ -295,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 22ba669ffb..f1a5757e9b 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -277,7 +277,7 @@ 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 | Ref export type ScalarSelectValue = | BasicExpression @@ -527,7 +527,7 @@ 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 : RefShapeMatches, false>> extends true ? true @@ -680,7 +680,7 @@ type ValueOfUnion = T extends unknown : never type RefForContextValue = T extends unknown ? IsPlainObject extends true - ? Ref + ? Ref : RefLeaf : never type RefsSchemaForContext = @@ -760,7 +760,7 @@ export type RefsForContext = { IsNullableContextKey > } & (TContext[`hasResult`] extends true - ? { $selected: Ref } + ? { $selected: Ref } : {}) & BranchUnionResultRefs & JoinedRefsForContext @@ -862,9 +862,11 @@ type VirtualPropsRef = { * through all nested property accesses, ensuring the result type includes * `| undefined` for all fields accessed through this ref. * - * Row-root refs include virtual properties ($synced, $origin, $key, - * $collectionId) for querying on row metadata. Recursively traversed user - * objects do not, because those values are not independently published rows. + * 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 @@ -872,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 @@ -884,7 +887,7 @@ type VirtualPropsRef = { export type Ref< T = any, Nullable extends boolean = false, - IncludeVirtualProps extends boolean = true, + IncludeVirtualProps extends boolean = false, > = T extends unknown ? RefBranch : never type RefBranch< @@ -1052,9 +1055,7 @@ type WithVirtualPropsIfAttachable = TResult extends unknown ? TResult extends object ? IsPlainObject extends true ? WithVirtualProps - : TResult extends ReadonlyArray - ? WithVirtualProps - : TResult + : TResult : TResult : never @@ -1137,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 44b958aa8a..f6300fb089 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/tests/query/includes.test-d.ts b/packages/db/tests/query/includes.test-d.ts index 5cc7336685..da1f6f591f 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 6ce6b786e2..1eb7fe9212 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 @@ -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 index e310de1858..debc4b1de2 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts @@ -20,6 +20,8 @@ import { } 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, @@ -37,6 +39,7 @@ type Row = { optionalProfile?: Profile nullableProfile: Profile | null createdAt: Date + tags: Array } const rows = createCollection( @@ -53,10 +56,18 @@ describe(`virtual row field type boundary`, () => { }) test(`query refs expose virtual fields only at row roots`, () => { + const profileLabelIs = (profile: Ref) => + eq(profile.label, `nested`) + const rowKey = (row: Ref) => 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 @@ -77,9 +88,18 @@ describe(`virtual row field type boundary`, () => { }) test(`single-row refs expose virtual fields only at row roots`, () => { + const profileLabelIs = (profile: SingleRowRefProxy) => + eq(profile.label, `nested`) + const rowKey = (row: SingleRowRefProxy) => + 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 @@ -121,4 +141,64 @@ describe(`virtual row field type boundary`, () => { // @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]!.$key).toEqualTypeOf() + expectTypeOf(result.objects[0]!).toEqualTypeOf<{ label: string }>() + expectTypeOf(result.nestedObjects[0]!).toEqualTypeOf<{ + nested: { label: string } + }>() + expectTypeOf(result.firstObject).toEqualTypeOf< + { label: string } | undefined + >() + expectTypeOf(result.arrays[0]!).toEqualTypeOf>() + + // @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 index ecc725c3a9..a619ef6b33 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -20,6 +20,7 @@ type Row = { id: string profile: { label: string } createdAt: Date + tags: Array } describe(`virtual row field runtime boundary`, () => { @@ -29,7 +30,14 @@ describe(`virtual row field runtime boundary`, () => { mockSyncCollectionOptions({ id: `virtual-row-fields-runtime-oracle-source`, getKey: (row) => row.id, - initialData: [{ id: `row-1`, profile: { label: `nested` }, createdAt }], + initialData: [ + { + id: `row-1`, + profile: { label: `nested` }, + createdAt, + tags: [`one`, `two`], + }, + ], }), ) const live = createLiveQueryCollection((q) => @@ -50,6 +58,36 @@ describe(`virtual row field runtime boundary`, () => { .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(), + ), })), ) @@ -71,6 +109,14 @@ describe(`virtual row field runtime boundary`, () => { expect(hasVirtualProps(result.dates[0])).toBe(false) expect(result.firstDate).toBeInstanceOf(Date) expect(hasVirtualProps(result.firstDate)).toBe(false) + + expect(hasVirtualProps(result.wholeRows[0])).toBe(true) + expect(result.wholeRows[0]!.$key).toBe(`row-1`) + expect(hasVirtualProps(result.objects[0])).toBe(false) + expect(hasVirtualProps(result.nestedObjects[0])).toBe(false) + expect(hasVirtualProps(result.nestedObjects[0]!.nested)).toBe(false) + expect(hasVirtualProps(result.arrays[0])).toBe(false) + expect(hasVirtualProps(result.firstObject)).toBe(false) } finally { await live.cleanup() await rows.cleanup() From 54e278e7f2394211bf10f836fbc201152b4d6398 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 09:43:38 +0100 Subject: [PATCH 3/4] test(db): document virtual field oracle contract --- .../query/virtual-row-fields-oracle.test-d.ts | 95 +++++++++++--- .../query/virtual-row-fields-oracle.test.ts | 120 +++++++++++++++--- 2 files changed, 178 insertions(+), 37 deletions(-) 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 index debc4b1de2..c7d7897b2a 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts @@ -1,14 +1,33 @@ /** - * Oracle owner: the compile-time boundary for virtual row fields. + * # Which types represent virtual-field-bearing rows? * - * `$synced`, `$origin`, `$key`, and `$collectionId` belong to collection and - * query rows. They remain available on root refs, but nested user objects and - * opaque scalar child results are values rather than rows. Discriminated row - * unions must also survive removal of their virtual fields. + * 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. * - * TypeScript structural equality and negative `@ts-expect-error` cells are the - * independent judges. The paired runtime oracle verifies the same boundary on - * published values. + * 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' @@ -30,7 +49,36 @@ import type { type Variant = | { kind: `person`; name: string } | { kind: `company`; legalName: string } -type VirtualVariant = WithVirtualProps + +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 = { @@ -56,9 +104,9 @@ describe(`virtual row field type boundary`, () => { }) test(`query refs expose virtual fields only at row roots`, () => { - const profileLabelIs = (profile: Ref) => + const profileLabelIs = (profile: QueryRefFor) => eq(profile.label, `nested`) - const rowKey = (row: Ref) => row.$key + const rowKey = (row: QueryRefFor) => row.$key const collection = createLiveQueryCollection((q) => q.from({ row: rows }).select(({ row }) => { @@ -88,10 +136,9 @@ describe(`virtual row field type boundary`, () => { }) test(`single-row refs expose virtual fields only at row roots`, () => { - const profileLabelIs = (profile: SingleRowRefProxy) => + const profileLabelIs = (profile: IndexRefFor) => eq(profile.label, `nested`) - const rowKey = (row: SingleRowRefProxy) => - row.$key + const rowKey = (row: IndexRefFor) => row.$key rows.createIndex((row) => { expectTypeOf(row.$collectionId).toEqualTypeOf>() @@ -180,15 +227,21 @@ describe(`virtual row field type boundary`, () => { ) const result = collection.toArray[0]! - expectTypeOf(result.wholeRows[0]!.$key).toEqualTypeOf() - expectTypeOf(result.objects[0]!).toEqualTypeOf<{ label: string }>() - expectTypeOf(result.nestedObjects[0]!).toEqualTypeOf<{ - nested: { label: string } - }>() + 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< - { label: string } | undefined + PublishedValueFor<{ label: string }, `projected-child`> | undefined + >() + expectTypeOf(result.arrays[0]!).toEqualTypeOf< + PublishedValueFor, `opaque-value`> >() - expectTypeOf(result.arrays[0]!).toEqualTypeOf>() // @ts-expect-error Projected child objects are values, not rows. result.objects[0]!.$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 index a619ef6b33..9952cb08a7 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -1,9 +1,26 @@ /** - * Runtime correspondence for the virtual-row-field type oracle. + * # Which published values carry virtual row fields? * - * The published query row owns virtual metadata. Nested user objects and - * opaque values selected through scalar child queries retain their original - * runtime shapes and must not be decorated as rows. + * 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' @@ -23,8 +40,21 @@ type Row = { tags: Array } +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. +function expectsVirtualFields(subject: VirtualFieldSubject): boolean { + return subject === `row-root` || subject === `whole-row-child` +} + describe(`virtual row field runtime boundary`, () => { - test(`decorates rows but not nested user objects or opaque child values`, async () => { + test(`matches the row and value classification after publication`, async () => { const createdAt = new Date(`2026-09-20T12:34:56.000Z`) const rows = createCollection( mockSyncCollectionOptions({ @@ -95,28 +125,86 @@ describe(`virtual row field runtime boundary`, () => { await live.preload() const result = live.toArray[0]! - expect(hasVirtualProps(result)).toBe(true) + 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) { + expect(hasVirtualProps(observation.value), observation.name).toBe( + expectsVirtualFields(observation.subject), + ) + } + expect(result.$key).toBe(`row-1`) expect(result.profile).toEqual({ label: `nested` }) - expect(hasVirtualProps(result.profile)).toBe(false) expect(`$key` in result.profile).toBe(false) expect(result.createdAt).toBeInstanceOf(Date) - expect(hasVirtualProps(result.createdAt)).toBe(false) expect(result.dates).toHaveLength(1) expect(result.dates[0]).toBeInstanceOf(Date) - expect(hasVirtualProps(result.dates[0])).toBe(false) expect(result.firstDate).toBeInstanceOf(Date) - expect(hasVirtualProps(result.firstDate)).toBe(false) - expect(hasVirtualProps(result.wholeRows[0])).toBe(true) + expect(result.wholeRows).toHaveLength(1) expect(result.wholeRows[0]!.$key).toBe(`row-1`) - expect(hasVirtualProps(result.objects[0])).toBe(false) - expect(hasVirtualProps(result.nestedObjects[0])).toBe(false) - expect(hasVirtualProps(result.nestedObjects[0]!.nested)).toBe(false) - expect(hasVirtualProps(result.arrays[0])).toBe(false) - expect(hasVirtualProps(result.firstObject)).toBe(false) + 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() From df3201af554965718a0e2f46e7d1b859c219ead3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 19:20:48 +0100 Subject: [PATCH 4/4] test(db): assert each virtual row field boundary --- .../query/virtual-row-fields-oracle.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/db/tests/query/virtual-row-fields-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-oracle.test.ts index 9952cb08a7..aae5220ce2 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -48,6 +48,13 @@ type VirtualFieldSubject = | `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` @@ -184,9 +191,18 @@ describe(`virtual row field runtime boundary`, () => { ] for (const observation of observations) { + const expectsFields = expectsVirtualFields(observation.subject) expect(hasVirtualProps(observation.value), observation.name).toBe( - expectsVirtualFields(observation.subject), + 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`)