Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-single-row-optional-ref-proxy.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 14 additions & 4 deletions packages/db/src/query/builder/ref-proxy.ts
Original file line number Diff line number Diff line change
@@ -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<T = any> {
Expand All @@ -22,6 +22,18 @@ export type VirtualPropsRefProxy<
readonly [K in keyof VirtualRowProps<TKey>]: RefLeaf<VirtualRowProps<TKey>[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<V, TKey extends string | number> = [
NonNullable<V>,
] extends [never]
? RefLeaf<V>
: IsPlainObject<NonNullable<V>> extends true
? SingleRowRefProxy<NonNullable<V>, TKey> | Extract<V, null | undefined>
: RefLeaf<V>

/**
* Type for creating a RefProxy for a single row/type without namespacing
* Used in collection indexes and where clauses
Expand All @@ -35,9 +47,7 @@ export type SingleRowRefProxy<
> =
T extends Record<string, any>
? {
[K in keyof T]: T[K] extends Record<string, any>
? SingleRowRefProxy<T[K], TKey> & RefProxy<T[K]>
: RefLeaf<T[K]>
[K in keyof T]: SingleRowField<T[K], TKey>
} & RefProxy<T> &
VirtualPropsRefProxy<TKey>
: RefProxy<T> & VirtualPropsRefProxy<TKey>
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1320,7 +1320,7 @@ export type Prettify<T> = {
* - Objects with `Symbol.toStringTag` (class instances like Temporal types,
* TypedArrays not already in JsBuiltIns, etc.) — these are not plain data objects
*/
type IsPlainObject<T> = T extends unknown
export type IsPlainObject<T> = T extends unknown
? T extends object
? T extends ReadonlyArray<any>
? false
Expand Down
23 changes: 23 additions & 0 deletions packages/db/tests/query/builder/ref-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
createRefProxy,
createSingleRowRefProxy,
isRefProxy,
toExpression,
val,
} from '../../../src/query/builder/ref-proxy.js'
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<Row>()

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<Row>()

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 } }>([
Expand Down
256 changes: 256 additions & 0 deletions packages/db/tests/single-row-ref-proxy.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<string>
optionalTags?: Array<string>
nullableTags: Array<string> | null
requiredMap: Map<string, number>
optionalMap?: Map<string, number>
nullableMap: Map<string, number> | null
requiredCallback: () => string
optionalCallback?: () => string
nullableCallback: (() => string) | null
mixedObjects: Timestamp | Date | null
}

const collection = createCollection<Row, string>({
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<Timestamp> | undefined
>()
expectTypeOf(row.optionalTimestamp?.seconds).toEqualTypeOf<
RefLeaf<number> | undefined
>()
expectTypeOf(
row.nullableTimestamp,
).toEqualTypeOf<SingleRowRefProxy<Timestamp> | null>()
expectTypeOf(row.nullableTimestamp?.seconds).toEqualTypeOf<
RefLeaf<number> | undefined
>()
expectTypeOf(row.nullishTimestamp).toEqualTypeOf<
SingleRowRefProxy<Timestamp> | null | undefined
>()
expectTypeOf(row.nested.timestamp?.nanoseconds).toEqualTypeOf<
RefLeaf<number> | 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<RefLeaf<string>>()
expectTypeOf(row.nickname).toEqualTypeOf<
RefLeaf<string | undefined> | undefined
>()
expectTypeOf(row.deletedBy).toEqualTypeOf<RefLeaf<string | null>>()
expectTypeOf(row.exactNull).toEqualTypeOf<RefLeaf<null>>()
expectTypeOf(row.exactUndefined).toEqualTypeOf<RefLeaf<undefined>>()

// @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<RefLeaf<Date>>()
expectTypeOf(row.optionalDate).toEqualTypeOf<
RefLeaf<Date | undefined> | undefined
>()
expectTypeOf(row.nullableDate).toEqualTypeOf<RefLeaf<Date | null>>()

expectTypeOf(row.requiredTags).toEqualTypeOf<RefLeaf<Array<string>>>()
expectTypeOf(row.optionalTags).toEqualTypeOf<
RefLeaf<Array<string> | undefined> | undefined
>()
expectTypeOf(row.nullableTags).toEqualTypeOf<
RefLeaf<Array<string> | null>
>()

expectTypeOf(row.requiredMap).toEqualTypeOf<
RefLeaf<Map<string, number>>
>()
expectTypeOf(row.optionalMap).toEqualTypeOf<
RefLeaf<Map<string, number> | undefined> | undefined
>()
expectTypeOf(row.nullableMap).toEqualTypeOf<
RefLeaf<Map<string, number> | null>
>()

expectTypeOf(row.requiredCallback).toEqualTypeOf<RefLeaf<() => 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<Date> = row.requiredDate
const nullableDate: RefLeaf<Date | null> = row.nullableDate
const optionalDate: RefLeaf<Date | undefined> | undefined =
row.optionalDate
const requiredTags: RefLeaf<Array<string>> = row.requiredTags
const optionalTags: RefLeaf<Array<string> | undefined> | undefined =
row.optionalTags
const nullableMap: RefLeaf<Map<string, number> | 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<Timestamp | Date | null>
>()
// @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<T extends { id: string; timestamp?: Timestamp }>(
rows: Collection<T, string>,
) {
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<string> | null
},
>(rows: Collection<T, string>) {
rows.createIndex((row) => {
const date: RefLeaf<Date | undefined> | undefined = row.date
const tags: RefLeaf<Array<string> | 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),
})
})
})
Loading