From 2910622cd1e03f68f84eb0f5ff0f3260b01f01a3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 16 Sep 2026 17:42:00 -0600 Subject: [PATCH 1/7] fix(db): preserve nullable and generic query refs --- packages/db/src/query/builder/types.ts | 182 +++---- packages/db/tests/query/join-subquery.test.ts | 2 +- .../query/query-api-type-algebra.test-d.ts | 481 ++++++++++++++++++ .../query/query-api-type-algebra.test.ts | 35 ++ 4 files changed, 608 insertions(+), 92 deletions(-) create mode 100644 packages/db/tests/query/query-api-type-algebra.test-d.ts create mode 100644 packages/db/tests/query/query-api-type-algebra.test.ts diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 5adb14bc5b..1778c17295 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -173,6 +173,8 @@ type ResultFromBranch = type UnionBranchResult>> = ResultFromBranch +declare const BranchUnionRefs: unique symbol + type UnionBranchSchema>> = UnionBranchResult extends infer TResult ? { @@ -183,13 +185,14 @@ type UnionBranchSchema>> = export type ContextFromUnionBranches< TBranches extends readonly [QueryBuilder, ...Array>], > = { - baseSchema: UnionBranchSchema & ContextSchema - schema: UnionBranchSchema & ContextSchema + baseSchema: UnionBranchSchema + schema: UnionBranchSchema refsSchema: UnionBranchSchema fromSourceName: keyof UnionBranchSchema & string hasJoins: false result: PrettifyIfPlainObject> hasResult: true + [BranchUnionRefs]: UnionBranchResult } /** @@ -492,7 +495,7 @@ type ExtractRef = T extends unknown ? IsTrueRef extends true ? T extends RefLeaf ? IsNullableRef extends true - ? DeepNullable + ? U | undefined : U : never : Prettify>> @@ -533,14 +536,6 @@ type RefShapeMatches = ? true : false -// Propagate nullable-join semantics into the user-data shape. -type DeepNullable = - T extends Record - ? IsPlainObject extends true - ? { [K in keyof T]: DeepNullable } - : T | undefined - : T | undefined - // Helper type to extract the underlying type from various expression types type ExtractExpressionType = T extends PropRef @@ -677,46 +672,75 @@ type RefForContextValue = T extends unknown : RefLeaf : never type RefsSchemaForContext = - IsExactlyUndefined extends true - ? TContext[`schema`] - : NonUndefined extends ContextSchema - ? NonUndefined - : TContext[`schema`] + `refsSchema` extends keyof TContext + ? IsExactlyUndefined extends true + ? TContext[`schema`] + : NonUndefined + : TContext[`schema`] -export type RefsForContext = { - [K in KeysOfUnion>]: IsNonExactOptional< - ValueOfUnion, K> - > extends true - ? IsNonExactNullable< - ValueOfUnion, K> - > extends true - ? // T is both non-exact optional and non-exact nullable (e.g., string | null | undefined) - // Extract the non-undefined and non-null part, mark as nullable ref - RefForContextValue< - NonNullable, K>>, - true - > - : // T is optional (T | undefined) but not exactly undefined, and not nullable - // Extract the non-undefined part, mark as nullable ref - RefForContextValue< - NonUndefined, K>>, - true - > - : IsNonExactNullable< - ValueOfUnion, K> - > extends true - ? // T is nullable (T | null) but not exactly null, and not optional - // Extract the non-null part, mark as nullable ref - RefForContextValue< - NonNull, K>>, - true +type IsNullableContextKey = + TContext[`joinTypes`] extends Record + ? K extends keyof TContext[`joinTypes`] + ? Extract extends never + ? false + : true + : K extends FromSourceNamesForOptionality + ? TContext[`hasUnionFrom`] extends true + ? true + : HasRightOrFullJoin + : false + : K extends FromSourceNamesForOptionality + ? TContext[`hasUnionFrom`] extends true + ? true + : HasRightOrFullJoin + : false + +type RefForContextSchemaValue< + T, + ForceNullable extends boolean, +> = ForceNullable extends true + ? RefForContextValue, true> + : IsNonExactOptional extends true + ? IsNonExactNullable extends true + ? RefForContextValue, true> + : RefForContextValue, true> + : IsNonExactNullable extends true + ? RefForContextValue, true> + : RefForContextValue + +type BranchUnionResultRefs = + typeof BranchUnionRefs extends keyof TContext + ? Ref> + : object + +type JoinedRefsForContext = + TContext[`joinTypes`] extends Record + ? { + [K in keyof TContext[`joinTypes`] & keyof TContext[`schema`]]: RefForContextSchemaValue< + TContext[`schema`][K], + IsNullableContextKey > - : // T is exactly undefined, exactly null, or neither optional nor nullable - // Wrap in Ref as-is (includes exact undefined, exact null, and normal types) - RefForContextValue, K>> + } + : object + +type JoinedRefKey = + TContext[`joinTypes`] extends Record + ? keyof TContext[`joinTypes`] + : never + +export type RefsForContext = { + [K in Exclude< + KeysOfUnion>, + JoinedRefKey + >]: RefForContextSchemaValue< + ValueOfUnion, K>, + IsNullableContextKey + > } & (TContext[`hasResult`] extends true ? { $selected: Ref } - : {}) + : {}) & + BranchUnionResultRefs & + JoinedRefsForContext /** * Type Detection Helpers @@ -886,32 +910,6 @@ type WithoutRefBrand = ? Omit : T -/** - * PreserveSingleResultFlag - Conditionally includes the singleResult flag - * - * This helper type ensures the singleResult flag is only added to the context when it's - * explicitly true. It uses a non-distributive conditional (tuple wrapper) to prevent - * unexpected behavior when TFlag is a union type. - * - * @template TFlag - The singleResult flag value to check - * @returns { singleResult: true } if TFlag is true, otherwise {} - */ -type PreserveSingleResultFlag = [TFlag] extends [true] - ? { singleResult: true } - : {} - -type PreserveHasResultFlag = [TFlag] extends [true] - ? { hasResult: true } - : {} - -type PreserveUnionFromFlag = [TFlag] extends [true] - ? { hasUnionFrom: true } - : {} - -type PreserveFromSourceNames = [TNames] extends [ReadonlyArray] - ? { fromSourceNames: TNames } - : {} - /** * MergeContextWithJoinType - Creates a new context after a join operation * @@ -933,13 +931,16 @@ type PreserveFromSourceNames = [TNames] extends [ReadonlyArray] * - `hasJoins`: Set to true * - `joinTypes`: Updated to track this join type * - `result`: Preserved from previous operations - * - `singleResult`: Preserved only if already true (via PreserveSingleResultFlag) + * - All other context state is preserved */ export type MergeContextWithJoinType< TContext extends Context, TNewSchema extends ContextSchema, TJoinType extends `inner` | `left` | `right` | `full` | `outer` | `cross`, -> = { +> = Omit< + TContext, + `schema` | `refsSchema` | `hasJoins` | `joinTypes` +> & { baseSchema: TContext[`baseSchema`] // Apply optionality immediately to the schema schema: ApplyJoinOptionalityToMergedSchema< @@ -962,11 +963,7 @@ export type MergeContextWithJoinType< : {}) & { [K in keyof TNewSchema & string]: TJoinType } - result: TContext[`result`] -} & PreserveSingleResultFlag & - PreserveHasResultFlag & - PreserveUnionFromFlag & - PreserveFromSourceNames +} /** * ApplyJoinOptionalityToMergedSchema - Applies optionality rules when merging schemas @@ -1267,20 +1264,22 @@ export type HasJoinType< export type MergeContextForJoinCallback< TContext extends Context, TNewSchema extends ContextSchema, -> = { +> = Omit< + TContext, + `schema` | `refsSchema` | `hasJoins` | `joinTypes` +> & { baseSchema: TContext[`baseSchema`] // Merge schemas without applying join optionality - both are non-optional in join condition schema: TContext[`schema`] & TNewSchema refsSchema: RefsSchemaForContext & TNewSchema fromSourceName: TContext[`fromSourceName`] hasJoins: true - joinTypes: TContext[`joinTypes`] extends Record + joinTypes: (TContext[`joinTypes`] extends Record ? TContext[`joinTypes`] - : {} - result: TContext[`result`] -} & PreserveHasResultFlag & - PreserveUnionFromFlag & - PreserveFromSourceNames + : {}) & { + [K in keyof TNewSchema & string]: `inner` + } +} /** * WithResult - Updates a context with a new result type after select() @@ -1297,10 +1296,11 @@ export type MergeContextForJoinCallback< * result type display cleanly in IDEs. */ export type WithResult = Prettify< - Omit & { - result: PrettifyIfPlainObject - hasResult: true - } + Omit & + Pick & { + result: PrettifyIfPlainObject + hasResult: true + } > /** diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index c14e66b113..dfbc2adf11 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -469,7 +469,7 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { expect(results).toHaveLength(1) expect(results[0]!.product.id).toBe(1) expect(results[0]!.tried).toBeDefined() - expect(results[0]!.tried.userId).toBe(1) + expect(results[0]!.tried!.userId).toBe(1) expect(results[0]).toEqual({ product: { id: 1, a: `8` }, tried: sampleTrials[0], 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 new file mode 100644 index 0000000000..f8506c04d1 --- /dev/null +++ b/packages/db/tests/query/query-api-type-algebra.test-d.ts @@ -0,0 +1,481 @@ +/** + * Oracle owner: the query-builder compile-time suites. + * + * Laws and sources: nullable join refs stay nullable when selected whole, and + * unresolved generic constraints survive joins and both union forms. These + * laws preserve the reports and prior art from issues 1467 and 1679. + * + * Reference and observation: TypeScript structural assignability and + * `@ts-expect-error` are the independent judges. Product-contract cells cross + * the public source -> query builder/Collection -> consumer type path. The + * paired runtime test owns the unmatched-row value witness. Existing generic, + * heterogeneous-union, join, and nullable-leaf suites remain the broader + * compatibility owners. + */ +import { describe, expectTypeOf, test } from 'vitest' +import { Query, createLiveQueryCollection, eq } from '../../src/query/index.js' +import type { Collection } from '../../src/collection/index.js' +import type { + Context, + QueryBuilder, + QueryResult, + RefsForContext, + WithResult, +} from '../../src/query/index.js' +import type { WithVirtualProps } from '../../src/virtual-props.js' + +type Row = { id: string; departmentId: string } +type Department = { id: string; name: string } +type DeepNullable = T extends object + ? { [K in keyof T]: DeepNullable } + : T | undefined +type IsAny = 0 extends 1 & T ? true : false +type Selected = WithResult< + TContext, + { selectedId: string } +> +type SelectedSourceContext = Pick< + Selected, + `baseSchema` | `schema` | `fromSourceName` +> + +describe(`query API type algebra`, () => { + test(`select preserves required source fields in generic contexts`, () => { + function preserveSourceContext( + selected: Selected, + ) { + const baseSchema: TContext[`baseSchema`] = selected.baseSchema + const schema: TContext[`schema`] = selected.schema + const fromSourceName: TContext[`fromSourceName`] = selected.fromSourceName + const result: { selectedId: string } = selected.result + const sourceContext: SelectedSourceContext = selected + return { baseSchema, schema, fromSourceName, result, sourceContext } + } + + void preserveSourceContext + }) + + test(`whole-object selections preserve nullable join refs`, () => { + function projectNullableDepartment( + rows: Collection, + departments: Collection, + ) { + const query = createLiveQueryCollection((q) => + q + .from({ row: rows }) + .leftJoin({ department: departments }, ({ row, department }) => + eq(row.departmentId, department.id), + ) + .select(({ department }) => ({ + department, + departmentName: department.name, + nested: { department }, + })), + ) + + const result = query.toArray[0]! + type ActualDepartment = typeof result.department + type ExpectedDepartment = WithVirtualProps | undefined + + expectTypeOf().toEqualTypeOf() + expectTypeOf< + unknown extends ActualDepartment ? true : false + >().toEqualTypeOf() + expectTypeOf< + null extends ActualDepartment ? true : false + >().toEqualTypeOf() + expectTypeOf< + DeepNullable< + WithVirtualProps + > extends ActualDepartment + ? true + : false + >().toEqualTypeOf() + expectTypeOf< + NonNullable[`name`] + >().toEqualTypeOf() + expectTypeOf(result.nested.department).toEqualTypeOf() + + const absentLeaf: typeof result.departmentName = undefined + + // @ts-expect-error An unmatched whole-object ref requires a guard. + result.department.name + // @ts-expect-error Nested whole-object refs retain the same guard. + result.nested.department.name + + void absentLeaf + return query + } + + void projectNullableDepartment + }) + + test(`spreading a nullable join ref widens its leaves`, () => { + function spreadNullableDepartment( + rows: Collection, + departments: Collection, + ) { + const query = createLiveQueryCollection((q) => + q + .from({ row: rows }) + .leftJoin({ department: departments }, ({ row, department }) => + eq(row.departmentId, department.id), + ) + .select(({ department }) => ({ ...department })), + ) + + const result = query.toArray[0]! + expectTypeOf(result.id).toEqualTypeOf() + expectTypeOf(result.name).toEqualTypeOf() + return query + } + + void spreadNullableDepartment + }) + + test(`unresolved generic constraints survive joins and union sources`, () => { + function composeGenericSources( + rows: Collection, + a: Collection, + b: Collection, + id: string, + ) { + const direct = new Query().from({ item: a }).where(({ item }) => { + // @ts-expect-error The generic constraint guarantees no other field. + void item.notGuaranteed + return eq(item.id, id) + }) + + const joined = new Query() + .from({ row: rows }) + .leftJoin({ item: a }, ({ row, item }) => { + // @ts-expect-error Only the generic constraint is available. + void item.notGuaranteed + return eq(row.id, item.id) + }) + .where(({ item }) => { + // @ts-expect-error Nullable generic refs expose only guaranteed fields. + void item.notGuaranteed + return eq(item.id, id) + }) + .select(({ item }) => { + // @ts-expect-error Only the generic constraint is available. + void item.notGuaranteed + return { id: item.id } + }) + + const sourceUnion = new Query() + .unionAll({ a, b }) + .where(({ a: aRef, b: bRef }) => { + // @ts-expect-error Union refs expose only guaranteed fields. + void aRef.notGuaranteed + // @ts-expect-error Union refs expose only guaranteed fields. + void bRef.notGuaranteed + return eq(aRef.id, bRef.id) + }) + + const aRows = new Query().from({ a }) + const bRows = new Query().from({ b }) + const branchUnion = new Query() + .unionAll(aRows, bRows) + .where(({ id: itemId }) => eq(itemId, id)) + .where((refs) => { + // @ts-expect-error Branch unions expose no unselected field. + void refs.notGuaranteed + return eq(refs.id, id) + }) + + const selectedBranchUnion = new Query() + .unionAll(aRows, bRows) + .select(({ id: itemId }) => ({ renamed: itemId })) + .where((refs) => { + void refs.id + void refs.$selected.renamed + expectTypeOf>().toEqualTypeOf() + expectTypeOf< + IsAny + >().toEqualTypeOf() + // @ts-expect-error Selected aliases require the $selected namespace. + void refs.renamed + // @ts-expect-error Only the generic constraint is available. + void refs.notGuaranteed + return eq(refs.id, refs.$selected.renamed) + }) + + const joinedBranchUnion = new Query() + .unionAll(aRows, bRows) + .leftJoin({ row: rows }, ({ id: itemId, row }) => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + // @ts-expect-error The joined source declares no other field. + void row.notGuaranteed + return eq(itemId, row.id) + }) + .where((refs) => { + void refs.id + void refs.row.id + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + // @ts-expect-error Only the generic constraint is available. + void refs.notGuaranteed + return eq(refs.id, refs.row.id) + }) + .select(({ id: itemId, row }) => ({ id: itemId, rowId: row.id })) + + return { + direct, + joined, + sourceUnion, + branchUnion, + selectedBranchUnion, + joinedBranchUnion, + } + } + + type Concrete = ReturnType< + typeof composeGenericSources<{ id: string; concrete: number }> + > + type JoinedId = QueryResult[`id`] + type DirectConcrete = QueryResult[`concrete`] + type SourceAId = NonNullable< + QueryResult[`a`] + >[`id`] + type SourceAConcrete = NonNullable< + QueryResult[`a`] + >[`concrete`] + type SourceBId = NonNullable< + QueryResult[`b`] + >[`id`] + type BranchId = QueryResult[`id`] + type BranchConcrete = QueryResult[`concrete`] + type SelectedBranchId = QueryResult< + Concrete[`selectedBranchUnion`] + >[`renamed`] + type JoinedBranchId = QueryResult[`id`] + type JoinedBranchRowId = QueryResult< + Concrete[`joinedBranchUnion`] + >[`rowId`] + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + + void composeGenericSources + }) + + test(`branch unions keep heterogeneous public result members`, () => { + type Alpha = { kind: `alpha`; id: string; alphaValue: number } + type Beta = { kind: `beta`; id: string; betaValue: boolean } + + function composeHeterogeneousBranches( + alpha: Collection, + beta: Collection, + departments: Collection, + ) { + const selectedOnly = new Query() + .unionAll( + new Query().from({ alpha }).select(({ alpha: row }) => ({ + id: row.id, + })), + new Query().from({ beta }).select(({ beta: row }) => ({ + id: row.id, + })), + ) + .select(({ id }) => ({ renamed: id })) + .where((refs) => { + void refs.$selected.renamed + void refs.id + expectTypeOf< + IsAny + >().toEqualTypeOf() + // @ts-expect-error Selected aliases require the $selected namespace. + void refs.renamed + return eq(refs.$selected.renamed, refs.id) + }) + + const query = createLiveQueryCollection((q) => { + const alphaRows = q.from({ alpha }).select(({ alpha: row }) => ({ + kind: row.kind, + id: row.id, + alphaValue: row.alphaValue, + })) + const betaRows = q.from({ beta }).select(({ beta: row }) => ({ + kind: row.kind, + id: row.id, + betaValue: row.betaValue, + })) + const union = q.unionAll(alphaRows, betaRows) + const callbackProjection = union + .leftJoin( + { department: departments }, + ({ kind, id, alphaValue, betaValue, department }) => { + void kind + void alphaValue + void betaValue + return eq(id, department.id) + }, + ) + .where(({ kind, alphaValue, betaValue }) => { + void alphaValue + void betaValue + return eq(kind, `beta`) + }) + .select(({ kind, alphaValue, betaValue }) => ({ + kind, + alphaValue, + betaValue, + })) + + type CallbackProjection = QueryResult + expectTypeOf().toEqualTypeOf< + `alpha` | `beta` + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + + void callbackProjection + return union + }) + + type Result = (typeof query.toArray)[number] + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<`alpha` | `beta`>() + + function assertNarrowing(result: Result) { + if (result.kind === `alpha`) { + expectTypeOf(result.alphaValue).toEqualTypeOf() + // @ts-expect-error The beta-only field must not leak across branches. + void result.betaValue + } else { + expectTypeOf(result.betaValue).toEqualTypeOf() + // @ts-expect-error The alpha-only field must not leak across branches. + void result.alphaValue + } + } + + void assertNarrowing + void selectedOnly + return query + } + + void composeHeterogeneousBranches + }) + + test(`mixed branch context kinds retain their callback keys`, () => { + type Alpha = { id: string; alphaValue: number } + type Beta = { id: string; betaValue: boolean } + type Gamma = { id: string; betaId: string; gammaValue: Date } + + function composeMixedBranches( + alpha: Collection, + beta: Collection, + gamma: Collection, + ) { + const selected = new Query().from({ alpha }).select(({ alpha: row }) => ({ + id: row.id, + alphaValue: row.alphaValue, + })) + const plain = new Query().from({ beta }) + const joined = new Query() + .from({ gamma }) + .leftJoin({ beta }, ({ gamma: row, beta: joinedBeta }) => + eq(row.betaId, joinedBeta.id), + ) + + const query = new Query() + .unionAll(selected, plain, joined) + .where(({ alphaValue, betaValue, gamma: gammaRow, beta: betaRow }) => { + void betaValue + void gammaRow.id + void betaRow.id + return eq(alphaValue, alphaValue) + }) + .select( + ({ alphaValue, betaValue, gamma: gammaRow, beta: betaRow }) => ({ + alphaValue, + betaValue, + gammaId: gammaRow.id, + betaId: betaRow.id, + }), + ) + + type Result = QueryResult + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + return query + } + + void composeMixedBranches + }) + + test(`specific query builders remain assignable to erased query builders`, () => { + function eraseBuilder( + source: Collection, + ) { + const specific = new Query().from({ source }) + const erased: QueryBuilder = specific + return erased + } + + void eraseBuilder + }) + + test(`exact nullish schema leaves stay exact`, () => { + type NullishRow = { id: string; nullValue: null; undefinedValue: undefined } + type ExactNullishContext = { + baseSchema: { nullValue: null; undefinedValue: undefined } + schema: { nullValue: null; undefinedValue: undefined } + fromSourceName: `nullValue` + hasJoins: false + } + type ExactNullishRefs = RefsForContext + + function projectNullishLeaves(source: Collection) { + const query = createLiveQueryCollection((q) => + q.from({ row: source }).select(({ row }) => ({ + nullValue: row.nullValue, + undefinedValue: row.undefinedValue, + })), + ) + const result = query.toArray[0]! + expectTypeOf(result.nullValue).toEqualTypeOf() + expectTypeOf(result.undefinedValue).toEqualTypeOf() + return query + } + + expectTypeOf().not.toBeNever() + expectTypeOf().not.toBeNever() + void projectNullishLeaves + }) + + test(`an explicit undefined refs schema falls back to the query schema`, () => { + type ExplicitUndefinedRefsContext = { + baseSchema: { row: Row } + schema: { row: Row } + refsSchema: undefined + fromSourceName: `row` + hasJoins: false + } + + function useSchemaFallback( + refs: RefsForContext, + ) { + void refs.row.id + } + + void useSchemaFallback + }) + +}) diff --git a/packages/db/tests/query/query-api-type-algebra.test.ts b/packages/db/tests/query/query-api-type-algebra.test.ts new file mode 100644 index 0000000000..42227e2454 --- /dev/null +++ b/packages/db/tests/query/query-api-type-algebra.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { mockSyncCollectionOptions } from '../utils.js' + +test(`an unmatched whole-object projection publishes undefined`, () => { + const rows = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-rows`, + getKey: (row: { id: string; departmentId: string }) => row.id, + initialData: [{ id: `row-1`, departmentId: `missing` }], + }), + ) + const departments = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-departments`, + getKey: (row: { id: string; name: string }) => row.id, + initialData: [{ id: `present`, name: `Present` }], + }), + ) + + const query = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ row: rows }) + .leftJoin({ department: departments }, ({ row, department }) => + eq(row.departmentId, department.id), + ) + .select(({ department }) => ({ department })), + }) + + expect(query.toArray).toHaveLength(1) + expect(query.toArray[0]!.department).toBeUndefined() +}) From 2b0a60eb677fcaa7c30b3e898f66f7226ffbb903 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 16 Sep 2026 17:42:41 -0600 Subject: [PATCH 2/7] chore: add query ref type fix changeset --- .changeset/fix-query-ref-algebra.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-query-ref-algebra.md diff --git a/.changeset/fix-query-ref-algebra.md b/.changeset/fix-query-ref-algebra.md new file mode 100644 index 0000000000..c7e3183849 --- /dev/null +++ b/.changeset/fix-query-ref-algebra.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Preserve whole-object nullability for left-join projections and constrained generic fields through join and `unionAll` query chains. From 6ef198f213de34be50e110607114919ad8bda14d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:46:18 +0000 Subject: [PATCH 3/7] ci: apply automated fixes --- packages/db/src/query/builder/types.ts | 19 +++++++------------ .../query/query-api-type-algebra.test-d.ts | 5 +---- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 1778c17295..7a273b76bf 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -716,7 +716,8 @@ type BranchUnionResultRefs = type JoinedRefsForContext = TContext[`joinTypes`] extends Record ? { - [K in keyof TContext[`joinTypes`] & keyof TContext[`schema`]]: RefForContextSchemaValue< + [K in keyof TContext[`joinTypes`] & + keyof TContext[`schema`]]: RefForContextSchemaValue< TContext[`schema`][K], IsNullableContextKey > @@ -733,9 +734,9 @@ export type RefsForContext = { KeysOfUnion>, JoinedRefKey >]: RefForContextSchemaValue< - ValueOfUnion, K>, - IsNullableContextKey - > + ValueOfUnion, K>, + IsNullableContextKey + > } & (TContext[`hasResult`] extends true ? { $selected: Ref } : {}) & @@ -937,10 +938,7 @@ export type MergeContextWithJoinType< TContext extends Context, TNewSchema extends ContextSchema, TJoinType extends `inner` | `left` | `right` | `full` | `outer` | `cross`, -> = Omit< - TContext, - `schema` | `refsSchema` | `hasJoins` | `joinTypes` -> & { +> = Omit & { baseSchema: TContext[`baseSchema`] // Apply optionality immediately to the schema schema: ApplyJoinOptionalityToMergedSchema< @@ -1264,10 +1262,7 @@ export type HasJoinType< export type MergeContextForJoinCallback< TContext extends Context, TNewSchema extends ContextSchema, -> = Omit< - TContext, - `schema` | `refsSchema` | `hasJoins` | `joinTypes` -> & { +> = Omit & { baseSchema: TContext[`baseSchema`] // Merge schemas without applying join optionality - both are non-optional in join condition schema: TContext[`schema`] & TNewSchema 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 f8506c04d1..dd115cc3db 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 @@ -252,9 +252,7 @@ describe(`query API type algebra`, () => { Concrete[`selectedBranchUnion`] >[`renamed`] type JoinedBranchId = QueryResult[`id`] - type JoinedBranchRowId = QueryResult< - Concrete[`joinedBranchUnion`] - >[`rowId`] + type JoinedBranchRowId = QueryResult[`rowId`] expectTypeOf().toEqualTypeOf() expectTypeOf>().toEqualTypeOf() expectTypeOf().toEqualTypeOf() @@ -477,5 +475,4 @@ describe(`query API type algebra`, () => { void useSchemaFallback }) - }) From 02e6a853a54141b579fd7b52f12aa43d5c4dcc9b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 17 Sep 2026 07:26:27 -0600 Subject: [PATCH 4/7] test(db): exercise erased branch union builders --- packages/db/tests/query/query-api-type-algebra.test-d.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 dd115cc3db..2bef49c2c0 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,10 @@ describe(`query API type algebra`, () => { function eraseBuilder( source: Collection, ) { - const specific = new Query().from({ source }) + const specific = new Query().unionAll( + new Query().from({ source }), + new Query().from({ source }), + ) const erased: QueryBuilder = specific return erased } From 60bd0cb9ac14f93fc583ba5fe933aeed3861804b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 17 Sep 2026 07:57:26 -0600 Subject: [PATCH 5/7] test(db): drop excluded heterogeneous union claims --- .../query/query-api-type-algebra.test-d.ts | 150 +----------------- 1 file changed, 1 insertion(+), 149 deletions(-) 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 2bef49c2c0..aa464a8b93 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 @@ -9,8 +9,7 @@ * `@ts-expect-error` are the independent judges. Product-contract cells cross * the public source -> query builder/Collection -> consumer type path. The * paired runtime test owns the unmatched-row value witness. Existing generic, - * heterogeneous-union, join, and nullable-leaf suites remain the broader - * compatibility owners. + * join, and nullable-leaf suites remain the broader compatibility owners. */ import { describe, expectTypeOf, test } from 'vitest' import { Query, createLiveQueryCollection, eq } from '../../src/query/index.js' @@ -271,153 +270,6 @@ describe(`query API type algebra`, () => { void composeGenericSources }) - test(`branch unions keep heterogeneous public result members`, () => { - type Alpha = { kind: `alpha`; id: string; alphaValue: number } - type Beta = { kind: `beta`; id: string; betaValue: boolean } - - function composeHeterogeneousBranches( - alpha: Collection, - beta: Collection, - departments: Collection, - ) { - const selectedOnly = new Query() - .unionAll( - new Query().from({ alpha }).select(({ alpha: row }) => ({ - id: row.id, - })), - new Query().from({ beta }).select(({ beta: row }) => ({ - id: row.id, - })), - ) - .select(({ id }) => ({ renamed: id })) - .where((refs) => { - void refs.$selected.renamed - void refs.id - expectTypeOf< - IsAny - >().toEqualTypeOf() - // @ts-expect-error Selected aliases require the $selected namespace. - void refs.renamed - return eq(refs.$selected.renamed, refs.id) - }) - - const query = createLiveQueryCollection((q) => { - const alphaRows = q.from({ alpha }).select(({ alpha: row }) => ({ - kind: row.kind, - id: row.id, - alphaValue: row.alphaValue, - })) - const betaRows = q.from({ beta }).select(({ beta: row }) => ({ - kind: row.kind, - id: row.id, - betaValue: row.betaValue, - })) - const union = q.unionAll(alphaRows, betaRows) - const callbackProjection = union - .leftJoin( - { department: departments }, - ({ kind, id, alphaValue, betaValue, department }) => { - void kind - void alphaValue - void betaValue - return eq(id, department.id) - }, - ) - .where(({ kind, alphaValue, betaValue }) => { - void alphaValue - void betaValue - return eq(kind, `beta`) - }) - .select(({ kind, alphaValue, betaValue }) => ({ - kind, - alphaValue, - betaValue, - })) - - type CallbackProjection = QueryResult - expectTypeOf().toEqualTypeOf< - `alpha` | `beta` - >() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - - void callbackProjection - return union - }) - - type Result = (typeof query.toArray)[number] - expectTypeOf>().toEqualTypeOf() - expectTypeOf().toEqualTypeOf<`alpha` | `beta`>() - - function assertNarrowing(result: Result) { - if (result.kind === `alpha`) { - expectTypeOf(result.alphaValue).toEqualTypeOf() - // @ts-expect-error The beta-only field must not leak across branches. - void result.betaValue - } else { - expectTypeOf(result.betaValue).toEqualTypeOf() - // @ts-expect-error The alpha-only field must not leak across branches. - void result.alphaValue - } - } - - void assertNarrowing - void selectedOnly - return query - } - - void composeHeterogeneousBranches - }) - - test(`mixed branch context kinds retain their callback keys`, () => { - type Alpha = { id: string; alphaValue: number } - type Beta = { id: string; betaValue: boolean } - type Gamma = { id: string; betaId: string; gammaValue: Date } - - function composeMixedBranches( - alpha: Collection, - beta: Collection, - gamma: Collection, - ) { - const selected = new Query().from({ alpha }).select(({ alpha: row }) => ({ - id: row.id, - alphaValue: row.alphaValue, - })) - const plain = new Query().from({ beta }) - const joined = new Query() - .from({ gamma }) - .leftJoin({ beta }, ({ gamma: row, beta: joinedBeta }) => - eq(row.betaId, joinedBeta.id), - ) - - const query = new Query() - .unionAll(selected, plain, joined) - .where(({ alphaValue, betaValue, gamma: gammaRow, beta: betaRow }) => { - void betaValue - void gammaRow.id - void betaRow.id - return eq(alphaValue, alphaValue) - }) - .select( - ({ alphaValue, betaValue, gamma: gammaRow, beta: betaRow }) => ({ - alphaValue, - betaValue, - gammaId: gammaRow.id, - betaId: betaRow.id, - }), - ) - - type Result = QueryResult - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - return query - } - - void composeMixedBranches - }) - test(`specific query builders remain assignable to erased query builders`, () => { function eraseBuilder( source: Collection, From 736bd1867408ea83021793a69428c12ae68b7a93 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 17 Sep 2026 11:13:49 -0600 Subject: [PATCH 6/7] fix(db): preserve nullable branch union refs --- .changeset/fix-query-ref-algebra.md | 2 +- packages/db/src/query/builder/types.ts | 9 +- .../query/query-api-type-algebra.test-d.ts | 84 ++++++++++++++++++- .../query/query-api-type-algebra.test.ts | 56 +++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) diff --git a/.changeset/fix-query-ref-algebra.md b/.changeset/fix-query-ref-algebra.md index c7e3183849..5ec3d8f8c2 100644 --- a/.changeset/fix-query-ref-algebra.md +++ b/.changeset/fix-query-ref-algebra.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -Preserve whole-object nullability for left-join projections and constrained generic fields through join and `unionAll` query chains. +Preserve whole-object nullability through supported left-join and `unionAll` projections, and preserve constrained generic fields through supported join and `unionAll` query chains. diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 7a273b76bf..4d2b34c821 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -708,9 +708,16 @@ type RefForContextSchemaValue< ? RefForContextValue, true> : RefForContextValue +type RefsForBranchResult = T extends unknown + ? { [K in keyof T]: RefForContextSchemaValue } + : never + type BranchUnionResultRefs = typeof BranchUnionRefs extends keyof TContext - ? Ref> + ? RefsForBranchResult< + TContext[typeof BranchUnionRefs], + HasRightOrFullJoin + > : object type JoinedRefsForContext = 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 aa464a8b93..aa235f97d2 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 @@ -1,9 +1,10 @@ /** * Oracle owner: the query-builder compile-time suites. * - * Laws and sources: nullable join refs stay nullable when selected whole, and - * unresolved generic constraints survive joins and both union forms. These - * laws preserve the reports and prior art from issues 1467 and 1679. + * Laws and sources: nullable join refs stay nullable when selected whole. In + * the separately enumerated generic callback cells, unresolved constraints + * survive direct queries, joins, and both union forms. These laws preserve the + * reports and prior art from issues 1467 and 1679. * * Reference and observation: TypeScript structural assignability and * `@ts-expect-error` are the independent judges. Product-contract cells cross @@ -109,6 +110,73 @@ describe(`query API type algebra`, () => { void projectNullableDepartment }) + test(`branch unions preserve nullable whole-object fields`, () => { + type Address = { city: string } + type Person = Row & { address: Address; optionalAddress?: Address } + + function projectNullableBranchFields( + rowsA: Collection, + rowsB: Collection, + othersA: Collection, + othersB: Collection, + ) { + const joinedBranchA = new Query() + .from({ rowA: rowsA }) + .leftJoin({ otherA: othersA }, ({ rowA, otherA }) => + eq(rowA.id, otherA.id), + ) + .select(({ otherA }) => ({ other: otherA })) + const joinedBranchB = new Query() + .from({ rowB: rowsB }) + .leftJoin({ otherB: othersB }, ({ rowB, otherB }) => + eq(rowB.id, otherB.id), + ) + .select(({ otherB }) => ({ other: otherB })) + const joinedUnion = new Query() + .unionAll(joinedBranchA, joinedBranchB) + .select(({ other }) => ({ + other, + otherAddress: other.address, + })) + + type JoinedResult = QueryResult + const joinedResult = null as unknown as JoinedResult + expectTypeOf(joinedResult.other).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(joinedResult.otherAddress).toEqualTypeOf< + Address | undefined + >() + // @ts-expect-error An unmatched branch whole object requires a guard. + joinedResult.other.id + if (joinedResult.other) { + expectTypeOf(joinedResult.other.id).toEqualTypeOf() + // @ts-expect-error Nested user objects do not gain row virtual props. + joinedResult.other.address.$key + } + + const optionalBranchA = new Query() + .from({ rowA: rowsA }) + .select(({ rowA }) => ({ address: rowA.optionalAddress })) + const optionalBranchB = new Query() + .from({ rowB: rowsB }) + .select(({ rowB }) => ({ address: rowB.optionalAddress })) + const optionalUnion = new Query() + .unionAll(optionalBranchA, optionalBranchB) + .select(({ address }) => ({ address })) + + type OptionalResult = QueryResult + const optionalResult = null as unknown as OptionalResult + expectTypeOf(optionalResult.address).toEqualTypeOf
() + // @ts-expect-error An absent selected object requires a guard. + optionalResult.address.city + + return { joinedUnion, optionalUnion } + } + + void projectNullableBranchFields + }) + test(`spreading a nullable join ref widens its leaves`, () => { function spreadNullableDepartment( rows: Collection, @@ -221,6 +289,11 @@ describe(`query API type algebra`, () => { }) .select(({ id: itemId, row }) => ({ id: itemId, rowId: row.id })) + const rightJoinedBranchUnion = new Query() + .unionAll(aRows, bRows) + .rightJoin({ row: rows }, ({ id: itemId, row }) => eq(itemId, row.id)) + .select(({ id: itemId }) => ({ id: itemId })) + return { direct, joined, @@ -228,6 +301,7 @@ describe(`query API type algebra`, () => { branchUnion, selectedBranchUnion, joinedBranchUnion, + rightJoinedBranchUnion, } } @@ -252,6 +326,9 @@ describe(`query API type algebra`, () => { >[`renamed`] type JoinedBranchId = QueryResult[`id`] type JoinedBranchRowId = QueryResult[`rowId`] + type RightJoinedBranchId = QueryResult< + Concrete[`rightJoinedBranchUnion`] + >[`id`] expectTypeOf().toEqualTypeOf() expectTypeOf>().toEqualTypeOf() expectTypeOf().toEqualTypeOf() @@ -266,6 +343,7 @@ describe(`query API type algebra`, () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() void composeGenericSources }) diff --git a/packages/db/tests/query/query-api-type-algebra.test.ts b/packages/db/tests/query/query-api-type-algebra.test.ts index 42227e2454..6f276cf395 100644 --- a/packages/db/tests/query/query-api-type-algebra.test.ts +++ b/packages/db/tests/query/query-api-type-algebra.test.ts @@ -33,3 +33,59 @@ test(`an unmatched whole-object projection publishes undefined`, () => { expect(query.toArray).toHaveLength(1) expect(query.toArray[0]!.department).toBeUndefined() }) + +test(`branch unions publish unmatched whole-object projections as undefined`, async () => { + type Person = { id: string } + + const rowsA = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-rows-a`, + getKey: (row: Person) => row.id, + initialData: [{ id: `row-1` }], + }), + ) + const rowsB = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-rows-b`, + getKey: (row: Person) => row.id, + initialData: [{ id: `row-2` }], + }), + ) + const othersA = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-others-a`, + getKey: (row: Person) => row.id, + initialData: [{ id: `row-1` }], + }), + ) + const othersB = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-others-b`, + getKey: (row: Person) => row.id, + initialData: [{ id: `other-2` }], + }), + ) + + const query = createLiveQueryCollection((q) => { + const branchA = q + .from({ rowA: rowsA }) + .leftJoin({ otherA: othersA }, ({ rowA, otherA }) => + eq(rowA.id, otherA.id), + ) + .select(({ otherA }) => ({ other: otherA })) + const branchB = q + .from({ rowB: rowsB }) + .leftJoin({ otherB: othersB }, ({ rowB, otherB }) => + eq(rowB.id, otherB.id), + ) + .select(({ otherB }) => ({ other: otherB })) + + return q.unionAll(branchA, branchB).select(({ other }) => ({ other })) + }) + + await query.preload() + + expect(query.toArray).toHaveLength(2) + expect(query.toArray[0]!.other).toMatchObject({ id: `row-1` }) + expect(query.toArray[1]!.other).toBeUndefined() +}) From 50acc0a8e7c0e9d71e18da4bf27c4eacea120bbd Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 17 Sep 2026 14:51:52 -0600 Subject: [PATCH 7/7] fix(db): preserve nullish branch refs through outer joins --- .changeset/fix-query-ref-algebra.md | 2 +- packages/db/src/query/builder/types.ts | 13 ++-- .../query/query-api-type-algebra.test-d.ts | 76 +++++++++++++++++++ 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/.changeset/fix-query-ref-algebra.md b/.changeset/fix-query-ref-algebra.md index 5ec3d8f8c2..c89a76c3aa 100644 --- a/.changeset/fix-query-ref-algebra.md +++ b/.changeset/fix-query-ref-algebra.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -Preserve whole-object nullability through supported left-join and `unionAll` projections, and preserve constrained generic fields through supported join and `unionAll` query chains. +Preserve whole-object nullability through supported join and `unionAll` projections, retain intrinsic nullish fields when right/full joins follow branch unions, and preserve constrained generic fields through supported join and `unionAll` query chains. diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 4d2b34c821..2651f1fe3f 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -709,7 +709,11 @@ type RefForContextSchemaValue< : RefForContextValue type RefsForBranchResult = T extends unknown - ? { [K in keyof T]: RefForContextSchemaValue } + ? { + [K in keyof T]: ForceNullable extends true + ? RefForContextValue + : RefForContextSchemaValue + } : never type BranchUnionResultRefs = @@ -731,15 +735,10 @@ type JoinedRefsForContext = } : object -type JoinedRefKey = - TContext[`joinTypes`] extends Record - ? keyof TContext[`joinTypes`] - : never - export type RefsForContext = { [K in Exclude< KeysOfUnion>, - JoinedRefKey + keyof JoinedRefsForContext | keyof BranchUnionResultRefs >]: RefForContextSchemaValue< ValueOfUnion, K>, IsNullableContextKey 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 aa235f97d2..fc4a028439 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 @@ -391,6 +391,82 @@ describe(`query API type algebra`, () => { void projectNullishLeaves }) + test(`branch unions preserve intrinsic nullish fields through nullable joins`, () => { + type BranchRow = { + id: string + exactNull: null + exactUndefined: undefined + nullableText: string | null + nullableObject: { label: string } | null + } + + function projectNullableBranchUnion( + a: Collection, + b: Collection, + rows: Collection<{ id: string }, string>, + ) { + const branch = (source: Collection) => + new Query().from({ source }).select(({ source: value }) => ({ + id: value.id, + exactNull: value.exactNull, + exactUndefined: value.exactUndefined, + nullableText: value.nullableText, + nullableObject: value.nullableObject, + })) + const union = new Query().unionAll(branch(a), branch(b)) + const rightJoined = union + .rightJoin({ row: rows }, ({ id, row }) => eq(id, row.id)) + .select( + ({ exactNull, exactUndefined, nullableText, nullableObject }) => ({ + exactNull, + exactUndefined, + nullableText, + nullableObject, + }), + ) + const fullJoined = union + .fullJoin({ row: rows }, ({ id, row }) => eq(id, row.id)) + .select( + ({ exactNull, exactUndefined, nullableText, nullableObject }) => ({ + exactNull, + exactUndefined, + nullableText, + nullableObject, + }), + ) + + type RightResult = QueryResult + type FullResult = QueryResult + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + string | null | undefined + >() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + string | null | undefined + >() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() + + const result = null as unknown as RightResult + if (result.nullableObject) { + expectTypeOf(result.nullableObject.label).toEqualTypeOf() + // @ts-expect-error Nested user objects do not gain row virtual props. + result.nullableObject.$key + } + + return { rightJoined, fullJoined } + } + + void projectNullableBranchUnion + }) + test(`an explicit undefined refs schema falls back to the query schema`, () => { type ExplicitUndefinedRefsContext = { baseSchema: { row: Row }