From fbfbc7c84b5c520c8c7aeecbf6b69de65fd7c846 Mon Sep 17 00:00:00 2001 From: balanced Date: Tue, 18 Aug 2026 18:16:54 +0300 Subject: [PATCH] fix: support nested optional property access in SingleRowRefProxy --- .../single-row-ref-proxy-optional-access.md | 5 ++ packages/db/src/query/builder/ref-proxy.ts | 22 ++++- .../db/tests/single-row-ref-proxy.test-d.ts | 80 +++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 .changeset/single-row-ref-proxy-optional-access.md create mode 100644 packages/db/tests/single-row-ref-proxy.test-d.ts diff --git a/.changeset/single-row-ref-proxy-optional-access.md b/.changeset/single-row-ref-proxy-optional-access.md new file mode 100644 index 0000000000..8d02d84f69 --- /dev/null +++ b/.changeset/single-row-ref-proxy-optional-access.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Fix `SingleRowRefProxy` collapsing optional and nullable nested objects to opaque leaves. `createIndex()` and single-row `where` callbacks can now traverse them with optional chaining (`row.updatedAt?.seconds`), matching the query builder's `Ref` behavior; runtime behavior is unchanged. diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index 121c0da40e..39377c11cb 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -22,6 +22,24 @@ export type VirtualPropsRefProxy< readonly [K in keyof VirtualRowProps]: RefLeaf[K]> } +/** + * Resolves the ref type for a single field of a row. Optionality and + * nullability are hoisted out of the field type before the object check, so + * an optional nested object (`Foo | undefined`) still produces a traversable + * branch proxy instead of collapsing to an opaque leaf. The nullish part is + * re-added to the union so optional chaining (`row.a?.b`) type-checks the + * same way it does on the query builder's `Ref` type. + */ +type SingleRowField = [ + NonNullable, +] extends [never] + ? RefLeaf + : NonNullable extends Record + ? + | (SingleRowRefProxy, TKey> & RefProxy>) + | Extract + : RefLeaf + /** * Type for creating a RefProxy for a single row/type without namespacing * Used in collection indexes and where clauses @@ -35,9 +53,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/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..1247806d60 --- /dev/null +++ b/packages/db/tests/single-row-ref-proxy.test-d.ts @@ -0,0 +1,80 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { eq } from '../src/query/builder/functions.js' +import type { RefLeaf } from '../src/query/builder/types.js' + +describe(`SingleRowRefProxy nested optional property access`, () => { + type Doc = { + id: string + name: string + updatedAt?: { seconds: number; nanoseconds: number } + author: { name: string; contact?: { email: string } } + deletedAt: { seconds: number } | null + } + + const collection = createCollection({ + getKey: (doc) => doc.id, + sync: { sync: () => {} }, + }) + + it(`allows optional chaining into an optional nested object`, () => { + collection.createIndex((row) => { + expectTypeOf(row.updatedAt?.seconds).toEqualTypeOf< + RefLeaf | undefined + >() + return row.updatedAt?.seconds + }) + }) + + it(`requires optional chaining for an optional nested object`, () => { + collection.createIndex((row) => { + // @ts-expect-error - updatedAt may be undefined, plain access must error + return row.updatedAt.seconds + }) + }) + + it(`allows optional chaining into a nullable nested object`, () => { + collection.createIndex((row) => { + expectTypeOf(row.deletedAt?.seconds).toEqualTypeOf< + RefLeaf | undefined + >() + return row.deletedAt?.seconds + }) + }) + + it(`keeps required nested objects traversable without optional chaining`, () => { + collection.createIndex((row) => { + expectTypeOf(row.author.name).toEqualTypeOf>() + return row.author.name + }) + }) + + it(`supports optional objects nested below a required object`, () => { + collection.createIndex((row) => { + expectTypeOf(row.author.contact?.email).toEqualTypeOf< + RefLeaf | undefined + >() + return row.author.contact?.email + }) + }) + + it(`keeps scalar fields as plain leaves`, () => { + collection.createIndex((row) => { + expectTypeOf(row.name).toEqualTypeOf>() + return row.name + }) + }) + + it(`rejects properties that do not exist on the nested object`, () => { + collection.createIndex((row) => { + // @ts-expect-error - millis is not a property of updatedAt + return row.updatedAt?.millis + }) + }) + + it(`accepts nested optional refs in the subscribeChanges where callback`, () => { + collection.subscribeChanges(() => {}, { + where: (row) => eq(row.updatedAt?.seconds, 5), + }) + }) +})