diff --git a/.changeset/fix-single-row-optional-ref-proxy.md b/.changeset/fix-single-row-optional-ref-proxy.md new file mode 100644 index 0000000000..e8911f6375 --- /dev/null +++ b/.changeset/fix-single-row-optional-ref-proxy.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Allow collection index and change-filter callbacks to traverse optional or nullable nested plain objects with optional chaining while preserving built-in values and functions as query leaves. diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index 121c0da40e..3b19330871 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -1,6 +1,6 @@ import { PropRef, Value } from '../ir.js' import type { BasicExpression } from '../ir.js' -import type { RefLeaf } from './types.js' +import type { IsPlainObject, RefLeaf } from './types.js' import type { VirtualRowProps } from '../../virtual-props.js' export interface RefProxy { @@ -22,6 +22,18 @@ export type VirtualPropsRefProxy< readonly [K in keyof VirtualRowProps]: RefLeaf[K]> } +// Strip nullish members before deciding whether a schema field is traversable, +// then restore them outside the proxy so the schema still requires a guard. +// The tuple guard keeps exact null/undefined fields as leaves: `never` would +// otherwise satisfy the plain-object check. +type SingleRowField = [ + NonNullable, +] extends [never] + ? RefLeaf + : IsPlainObject> extends true + ? SingleRowRefProxy, TKey> | Extract + : RefLeaf + /** * Type for creating a RefProxy for a single row/type without namespacing * Used in collection indexes and where clauses @@ -35,9 +47,7 @@ export type SingleRowRefProxy< > = T extends Record ? { - [K in keyof T]: T[K] extends Record - ? SingleRowRefProxy & RefProxy - : RefLeaf + [K in keyof T]: SingleRowField } & RefProxy & VirtualPropsRefProxy : RefProxy & VirtualPropsRefProxy diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 2651f1fe3f..00af2e97e3 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -1320,7 +1320,7 @@ export type Prettify = { * - Objects with `Symbol.toStringTag` (class instances like Temporal types, * TypedArrays not already in JsBuiltIns, etc.) — these are not plain data objects */ -type IsPlainObject = T extends unknown +export type IsPlainObject = T extends unknown ? T extends object ? T extends ReadonlyArray ? false diff --git a/packages/db/tests/query/builder/ref-proxy.test.ts b/packages/db/tests/query/builder/ref-proxy.test.ts index ec41351daf..e003d34d07 100644 --- a/packages/db/tests/query/builder/ref-proxy.test.ts +++ b/packages/db/tests/query/builder/ref-proxy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createRefProxy, + createSingleRowRefProxy, isRefProxy, toExpression, val, @@ -8,6 +9,28 @@ import { import { PropRef, Value } from '../../../src/query/ir.js' describe(`ref-proxy`, () => { + describe(`createSingleRowRefProxy`, () => { + it(`records a nested path through an optional schema field`, () => { + type Row = { timestamp?: { seconds: number } } + const proxy = createSingleRowRefProxy() + + const expression = toExpression(proxy.timestamp?.seconds) + + expect(expression).toBeInstanceOf(PropRef) + expect((expression as PropRef).path).toEqual([`timestamp`, `seconds`]) + }) + + it(`records built-in method paths only when the type boundary is bypassed`, () => { + type Row = { updatedAt?: Date } + const proxy = createSingleRowRefProxy() + + const expression = toExpression((proxy as any).updatedAt?.getTime) + + expect(expression).toBeInstanceOf(PropRef) + expect((expression as PropRef).path).toEqual([`updatedAt`, `getTime`]) + }) + }) + describe(`createRefProxy`, () => { it(`creates a proxy with correct basic properties`, () => { const proxy = createRefProxy<{ users: { id: number; name: string } }>([ diff --git a/packages/db/tests/single-row-ref-proxy.test-d.ts b/packages/db/tests/single-row-ref-proxy.test-d.ts new file mode 100644 index 0000000000..ed89b5af5c --- /dev/null +++ b/packages/db/tests/single-row-ref-proxy.test-d.ts @@ -0,0 +1,256 @@ +/** + * Oracle owner: SingleRowRefProxy compile-time projection. + * + * Runtime callbacks receive one truthy path-recording proxy. Schema-level + * nullishness nevertheless owns whether nested object access requires optional + * chaining. TypeScript's structural assignability and `@ts-expect-error` are + * the independent judges; the paired runtime test owns the recorded path. + */ +import { describe, expectTypeOf, test } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { eq, isNull } from '../src/query/builder/functions.js' +import type { Collection } from '../src/collection/index.js' +import type { SingleRowRefProxy } from '../src/query/builder/ref-proxy.js' +import type { RefLeaf } from '../src/query/builder/types.js' + +interface Timestamp { + seconds: number + nanoseconds: number +} + +type Variant = + | { kind: `created`; payload: { author: string } } + | { kind: `deleted`; payload: { reason: string } } + +type Row = { + id: string + name: string + nickname?: string + deletedBy: string | null + optionalTimestamp?: Timestamp + nullableTimestamp: Timestamp | null + nullishTimestamp?: Timestamp | null + nested: { timestamp?: Timestamp } + exactNull: null + exactUndefined: undefined + variant?: Variant + mixed: Timestamp | string | undefined + requiredDate: Date + optionalDate?: Date + nullableDate: Date | null + requiredTags: Array + optionalTags?: Array + nullableTags: Array | null + requiredMap: Map + optionalMap?: Map + nullableMap: Map | null + requiredCallback: () => string + optionalCallback?: () => string + nullableCallback: (() => string) | null + mixedObjects: Timestamp | Date | null +} + +const collection = createCollection({ + getKey: (row) => row.id, + sync: { sync: () => {} }, +}) + +describe(`SingleRowRefProxy type algebra`, () => { + test(`optional and nullable objects remain traversable with schema guards`, () => { + collection.createIndex((row) => { + expectTypeOf(row.optionalTimestamp).toEqualTypeOf< + SingleRowRefProxy | undefined + >() + expectTypeOf(row.optionalTimestamp?.seconds).toEqualTypeOf< + RefLeaf | undefined + >() + expectTypeOf( + row.nullableTimestamp, + ).toEqualTypeOf | null>() + expectTypeOf(row.nullableTimestamp?.seconds).toEqualTypeOf< + RefLeaf | undefined + >() + expectTypeOf(row.nullishTimestamp).toEqualTypeOf< + SingleRowRefProxy | null | undefined + >() + expectTypeOf(row.nested.timestamp?.nanoseconds).toEqualTypeOf< + RefLeaf | undefined + >() + + // @ts-expect-error Optional objects require a guard before traversal. + row.optionalTimestamp.seconds + // @ts-expect-error Nullable objects require a guard before traversal. + row.nullableTimestamp.seconds + // @ts-expect-error Unknown nested keys must not be fabricated. + row.optionalTimestamp?.milliseconds + + return row.optionalTimestamp?.seconds + }) + }) + + test(`scalar and exact-nullish leaves retain their declared domains`, () => { + collection.createIndex((row) => { + expectTypeOf(row.name).toEqualTypeOf>() + expectTypeOf(row.nickname).toEqualTypeOf< + RefLeaf | undefined + >() + expectTypeOf(row.deletedBy).toEqualTypeOf>() + expectTypeOf(row.exactNull).toEqualTypeOf>() + expectTypeOf(row.exactUndefined).toEqualTypeOf>() + + // @ts-expect-error Scalar leaves are not traversable objects. + row.nickname?.length + // @ts-expect-error Exact null does not acquire object fields. + row.exactNull.value + + return row.name + }) + }) + + test(`JavaScript built-ins and functions remain scalar leaves`, () => { + collection.createIndex((row) => { + expectTypeOf(row.requiredDate).toEqualTypeOf>() + expectTypeOf(row.optionalDate).toEqualTypeOf< + RefLeaf | undefined + >() + expectTypeOf(row.nullableDate).toEqualTypeOf>() + + expectTypeOf(row.requiredTags).toEqualTypeOf>>() + expectTypeOf(row.optionalTags).toEqualTypeOf< + RefLeaf | undefined> | undefined + >() + expectTypeOf(row.nullableTags).toEqualTypeOf< + RefLeaf | null> + >() + + expectTypeOf(row.requiredMap).toEqualTypeOf< + RefLeaf> + >() + expectTypeOf(row.optionalMap).toEqualTypeOf< + RefLeaf | undefined> | undefined + >() + expectTypeOf(row.nullableMap).toEqualTypeOf< + RefLeaf | null> + >() + + expectTypeOf(row.requiredCallback).toEqualTypeOf string>>() + expectTypeOf(row.optionalCallback).toEqualTypeOf< + RefLeaf<(() => string) | undefined> | undefined + >() + expectTypeOf(row.nullableCallback).toEqualTypeOf< + RefLeaf<(() => string) | null> + >() + + // @ts-expect-error Date methods are value behavior, not query paths. + row.requiredDate.getTime + // @ts-expect-error Optional Date methods are not traversable query paths. + row.optionalDate?.toISOString + // @ts-expect-error Array members are not traversable query paths. + row.optionalTags?.length + // @ts-expect-error Map methods are not traversable query paths. + row.nullableMap.get + // @ts-expect-error Function values remain leaves, not callable proxies. + row.requiredCallback() + + return row.requiredDate + }) + }) + + test(`leaf-compatible helpers retain required and nullish built-ins`, () => { + collection.createIndex((row) => { + const requiredDate: RefLeaf = row.requiredDate + const nullableDate: RefLeaf = row.nullableDate + const optionalDate: RefLeaf | undefined = + row.optionalDate + const requiredTags: RefLeaf> = row.requiredTags + const optionalTags: RefLeaf | undefined> | undefined = + row.optionalTags + const nullableMap: RefLeaf | null> = row.nullableMap + const optionalCallback: RefLeaf<(() => string) | undefined> | undefined = + row.optionalCallback + + void [ + requiredDate, + nullableDate, + optionalDate, + requiredTags, + optionalTags, + nullableMap, + optionalCallback, + ] + return row.id + }) + }) + + test(`direct expression consumers continue accepting built-in leaves`, () => { + collection.createIndex((row) => { + const equality = eq(row.nullableDate, new Date(0)) + const nullCheck = isNull(row.optionalDate) + void [equality, nullCheck] + return row.requiredDate + }) + + collection.subscribeChanges(() => {}, { + where: (row) => isNull(row.nullableMap), + }) + }) + + test(`object unions expose shared structure without inventing variant keys`, () => { + collection.createIndex((row) => { + expectTypeOf(row.variant?.kind).toEqualTypeOf< + RefLeaf<`created`> | RefLeaf<`deleted`> | undefined + >() + + // Ref leaves record expressions; they do not provide value-level + // discriminant narrowing for sibling proxy fields. + // @ts-expect-error Variant-only nested fields remain unavailable. + row.variant?.payload.author + // @ts-expect-error Mixed object/scalar unions are opaque leaves. + row.mixed.seconds + expectTypeOf(row.mixedObjects).toEqualTypeOf< + RefLeaf + >() + // @ts-expect-error Mixed plain/built-in object unions stay opaque. + row.mixedObjects.seconds + + return row.variant?.kind + }) + }) + + test(`constrained generic optional objects retain guaranteed fields`, () => { + function addTimestampIndex( + rows: Collection, + ) { + rows.createIndex((row) => row.timestamp?.seconds) + rows.subscribeChanges(() => {}, { + where: (row) => eq(row.timestamp?.nanoseconds, 0), + }) + } + + void addTimestampIndex + + function keepBuiltInLeaves< + T extends { + id: string + date?: Date + tags: Array | null + }, + >(rows: Collection) { + rows.createIndex((row) => { + const date: RefLeaf | undefined = row.date + const tags: RefLeaf | null> = row.tags + void [date, tags] + return row.id + }) + } + + void keepBuiltInLeaves + }) + + test(`all public SingleRowRefProxy callback paths share the same projection`, () => { + collection.createIndex((row) => row.optionalTimestamp?.seconds) + collection.subscribeChanges(() => {}, { + where: (row) => eq(row.nullishTimestamp?.nanoseconds, 0), + }) + }) +})