From d30b1cb78cbef8453d235bb1586fc2baa7f4a44a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 20 Sep 2026 09:56:10 +0100 Subject: [PATCH 1/5] fix(db): tighten aggregate value types --- .changeset/fix-aggregate-value-types.md | 5 + packages/db/src/query/builder/functions.ts | 49 +++++--- .../query/aggregate-value-contracts.test-d.ts | 116 ++++++++++++++++++ 3 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-aggregate-value-types.md create mode 100644 packages/db/tests/query/aggregate-value-contracts.test-d.ts diff --git a/.changeset/fix-aggregate-value-types.md b/.changeset/fix-aggregate-value-types.md new file mode 100644 index 0000000000..24ca57fd59 --- /dev/null +++ b/.changeset/fix-aggregate-value-types.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Restrict built-in aggregate helpers to their supported value domains so numeric aggregates and min/max no longer advertise impossible runtime result types. diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 84c26e5ca4..84d4080f39 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -91,13 +91,24 @@ type ExtractType = ? U : T -// Helper type to determine aggregate return type based on input nullability -type AggregateReturnType = - ExtractType extends infer U - ? U extends number | undefined | null | Date | bigint | string - ? Aggregate - : Aggregate - : Aggregate +type IsAny = 0 extends 1 & T ? true : false + +// Validate the non-nullish value domain while preserving the original +// expression type. `any` remains permissive, while `unknown` must be narrowed. +type AggregateArgument = T & + (IsAny> extends true + ? unknown + : [Exclude, null | undefined>] extends [never] + ? never + : [Exclude, null | undefined>] extends [Domain] + ? unknown + : never) + +type NumericAggregateArgument = AggregateArgument +type OrderableAggregateArgument = AggregateArgument< + T, + number | Date | bigint | string +> // Helper type to determine string function return type based on input nullability type StringFunctionReturnType = @@ -644,20 +655,28 @@ export function count(arg: ExpressionLike): Aggregate { return new Aggregate(`count`, [toExpression(arg)]) } -export function avg(arg: T): AggregateReturnType { - return new Aggregate(`avg`, [toExpression(arg)]) as AggregateReturnType +export function avg( + arg: NumericAggregateArgument, +): Aggregate { + return new Aggregate(`avg`, [toExpression(arg)]) } -export function sum(arg: T): AggregateReturnType { - return new Aggregate(`sum`, [toExpression(arg)]) as AggregateReturnType +export function sum( + arg: NumericAggregateArgument, +): Aggregate { + return new Aggregate(`sum`, [toExpression(arg)]) } -export function min(arg: T): AggregateReturnType { - return new Aggregate(`min`, [toExpression(arg)]) as AggregateReturnType +export function min( + arg: OrderableAggregateArgument, +): Aggregate> { + return new Aggregate(`min`, [toExpression(arg)]) } -export function max(arg: T): AggregateReturnType { - return new Aggregate(`max`, [toExpression(arg)]) as AggregateReturnType +export function max( + arg: OrderableAggregateArgument, +): Aggregate> { + return new Aggregate(`max`, [toExpression(arg)]) } /** diff --git a/packages/db/tests/query/aggregate-value-contracts.test-d.ts b/packages/db/tests/query/aggregate-value-contracts.test-d.ts new file mode 100644 index 0000000000..4bb5833031 --- /dev/null +++ b/packages/db/tests/query/aggregate-value-contracts.test-d.ts @@ -0,0 +1,116 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createLiveQueryCollection } from '../../src/query/index.js' +import { avg, count, max, min, sum } from '../../src/query/builder/functions.js' +import { mockSyncCollectionOptions } from '../utils.js' +import type { Aggregate } from '../../src/query/ir.js' +import type { RefLeaf } from '../../src/query/builder/types.js' +import type { OutputWithVirtual } from '../utils.js' + +type AggregateRow = { + id: number + group: string + amount: number + maybeAmount?: number | null + label: string + createdAt: Date + sequence: bigint + enabled: boolean + temporalLike: { + year: number + month: number + day: number + } +} + +const rows = createCollection( + mockSyncCollectionOptions({ + id: `aggregate-value-contracts`, + getKey: (row) => row.id, + initialData: [], + }), +) + +describe(`aggregate value contracts`, () => { + test(`numeric and orderable aggregates expose their runtime result domains`, () => { + const result = createLiveQueryCollection({ + query: (q) => + q + .from({ row: rows }) + .groupBy(({ row }) => row.group) + .select(({ row }) => { + expectTypeOf(count(row.maybeAmount)).toEqualTypeOf< + Aggregate + >() + expectTypeOf(sum(row.amount)).toEqualTypeOf>() + expectTypeOf(avg(row.amount)).toEqualTypeOf>() + expectTypeOf(sum(row.maybeAmount)).toEqualTypeOf< + Aggregate + >() + expectTypeOf(avg(row.maybeAmount)).toEqualTypeOf< + Aggregate + >() + expectTypeOf(min(row.label)).toEqualTypeOf>() + expectTypeOf(max(row.createdAt)).toEqualTypeOf>() + expectTypeOf(min(row.sequence)).toEqualTypeOf>() + + return { + group: row.group, + count: count(row.maybeAmount), + total: sum(row.amount), + average: avg(row.amount), + maybeTotal: sum(row.maybeAmount), + maybeAverage: avg(row.maybeAmount), + firstLabel: min(row.label), + latest: max(row.createdAt), + firstSequence: min(row.sequence), + } + }), + }) + + expectTypeOf(result.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + group: string + count: number + total: number + average: number + maybeTotal: number + maybeAverage: number + firstLabel: string + latest: Date + firstSequence: bigint + }> + > + >() + }) + + test(`rejects values outside each aggregate's documented domain`, () => { + const loose = undefined as unknown as RefLeaf + const unknownValue = undefined as unknown as RefLeaf + + expectTypeOf(sum(loose)).toEqualTypeOf>() + expectTypeOf(min(loose)).toEqualTypeOf>() + + createLiveQueryCollection({ + query: (q) => + q.from({ row: rows }).select(({ row }) => ({ + // sum() and avg() are numeric aggregates. String coercion would + // return a number while falsely advertising a string result. + // @ts-expect-error string values are not a sum domain + stringSum: sum(row.label), + // @ts-expect-error dates are not an average domain + dateAverage: avg(row.createdAt), + // min()/max() support number, string, bigint, and Date only. + // @ts-expect-error booleans have no supported aggregate ordering + booleanMinimum: min(row.enabled), + // @ts-expect-error Temporal-like objects are not supported yet + temporalMaximum: max(row.temporalLike), + // @ts-expect-error unknown values must be narrowed first + unknownSum: sum(unknownValue), + // @ts-expect-error null alone has no orderable value domain + nullMinimum: min(null), + })), + }) + }) +}) From 9cdd81e01c8888fbc336377b2d6b02f9837ddee0 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 20 Sep 2026 16:12:05 +0100 Subject: [PATCH 2/5] fix(db): preserve supported aggregate generics --- packages/db/src/query/builder/functions.ts | 45 ++++++- .../query/aggregate-value-contracts.test-d.ts | 120 +++++++++++++++++- 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 84d4080f39..f0d14a38f2 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -93,8 +93,6 @@ type ExtractType = type IsAny = 0 extends 1 & T ? true : false -// Validate the non-nullish value domain while preserving the original -// expression type. `any` remains permissive, while `unknown` must be narrowed. type AggregateArgument = T & (IsAny> extends true ? unknown @@ -104,10 +102,23 @@ type AggregateArgument = T & ? unknown : never) +type NumericAggregateWrapper = + | RefProxy + | RefLeaf + | BasicExpression + +type OrderableAggregateValue = number | Date | bigint | string +type OrderableAggregateWrapper = + | RefProxy + | RefLeaf + | BasicExpression + +// Constrained overloads compose through supported generics; these conditional +// fallbacks validate concrete optional/nullish unions and reject unknown. type NumericAggregateArgument = AggregateArgument type OrderableAggregateArgument = AggregateArgument< T, - number | Date | bigint | string + OrderableAggregateValue > // Helper type to determine string function return type based on input nullability @@ -655,27 +666,47 @@ export function count(arg: ExpressionLike): Aggregate { return new Aggregate(`count`, [toExpression(arg)]) } +export function avg(arg: T): Aggregate +export function avg( + arg: T, +): Aggregate export function avg( arg: NumericAggregateArgument, -): Aggregate { +): Aggregate +export function avg(arg: ExpressionLike): Aggregate { return new Aggregate(`avg`, [toExpression(arg)]) } +export function sum(arg: T): Aggregate +export function sum( + arg: T, +): Aggregate export function sum( arg: NumericAggregateArgument, -): Aggregate { +): Aggregate +export function sum(arg: ExpressionLike): Aggregate { return new Aggregate(`sum`, [toExpression(arg)]) } +export function min(arg: T): Aggregate +export function min( + arg: T, +): Aggregate> export function min( arg: OrderableAggregateArgument, -): Aggregate> { +): Aggregate> +export function min(arg: ExpressionLike): Aggregate { return new Aggregate(`min`, [toExpression(arg)]) } +export function max(arg: T): Aggregate +export function max( + arg: T, +): Aggregate> export function max( arg: OrderableAggregateArgument, -): Aggregate> { +): Aggregate> +export function max(arg: ExpressionLike): Aggregate { return new Aggregate(`max`, [toExpression(arg)]) } diff --git a/packages/db/tests/query/aggregate-value-contracts.test-d.ts b/packages/db/tests/query/aggregate-value-contracts.test-d.ts index 4bb5833031..f280d49c8b 100644 --- a/packages/db/tests/query/aggregate-value-contracts.test-d.ts +++ b/packages/db/tests/query/aggregate-value-contracts.test-d.ts @@ -1,12 +1,24 @@ import { describe, expectTypeOf, test } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection } from '../../src/query/index.js' -import { avg, count, max, min, sum } from '../../src/query/builder/functions.js' +import { + add, + avg, + coalesce, + count, + eq, + max, + min, + sum, +} from '../../src/query/builder/functions.js' import { mockSyncCollectionOptions } from '../utils.js' -import type { Aggregate } from '../../src/query/ir.js' +import type { Aggregate, BasicExpression } from '../../src/query/ir.js' +import type { RefProxy } from '../../src/query/builder/ref-proxy.js' import type { RefLeaf } from '../../src/query/builder/types.js' import type { OutputWithVirtual } from '../utils.js' +type BrandedAmount = number & { readonly __brand: `amount` } + type AggregateRow = { id: number group: string @@ -31,6 +43,18 @@ const rows = createCollection( }), ) +const details = createCollection( + mockSyncCollectionOptions<{ + id: number + rowId: number + amount: BrandedAmount + }>({ + id: `aggregate-value-contract-details`, + getKey: (row) => row.id, + initialData: [], + }), +) + describe(`aggregate value contracts`, () => { test(`numeric and orderable aggregates expose their runtime result domains`, () => { const result = createLiveQueryCollection({ @@ -113,4 +137,96 @@ describe(`aggregate value contracts`, () => { })), }) }) + + test(`supported generic wrappers compose without widening their domains`, () => { + const sumNumber = (value: T) => sum(value) + const sumNumericRef = >( + value: T, + ) => sum(value) + const avgNumericExpression = < + T extends BasicExpression, + >( + value: T, + ) => avg(value) + const minOrderableRef = < + T extends RefLeaf, + >( + value: T, + ) => min(value) + const maxOrderableValue = ( + value: T, + ) => max(value) + + const branded = 1 as BrandedAmount + const nullableBrandedRef = undefined as unknown as RefLeaf< + BrandedAmount | null | undefined, + true + > + const mixedOrderableRef = undefined as unknown as RefLeaf + + expectTypeOf(sumNumber(branded)).toEqualTypeOf>() + expectTypeOf(sum(1)).toEqualTypeOf>() + expectTypeOf(avg(1)).toEqualTypeOf>() + expectTypeOf(sumNumericRef(nullableBrandedRef)).toEqualTypeOf< + Aggregate + >() + expectTypeOf(avgNumericExpression(add(1, 2))).toEqualTypeOf< + Aggregate + >() + expectTypeOf(sum(coalesce(nullableBrandedRef, 0))).toEqualTypeOf< + Aggregate + >() + expectTypeOf(minOrderableRef(mixedOrderableRef)).toEqualTypeOf< + Aggregate + >() + expectTypeOf(maxOrderableValue(new Date())).toEqualTypeOf>() + + type BroadExpressionLike = + | Aggregate + | BasicExpression + | RefProxy + | RefLeaf + | string + | number + | boolean + | bigint + | Date + | null + | undefined + | Array + + const unsupportedBroadForwarder = ( + value: T, + ) => { + // @ts-expect-error an unconstrained expression may not be numeric + sum(value) + // @ts-expect-error an unconstrained expression may not be numeric + avg(value) + // @ts-expect-error an unconstrained expression may not be orderable + min(value) + // @ts-expect-error an unconstrained expression may not be orderable + max(value) + } + + expectTypeOf(unsupportedBroadForwarder).toBeFunction() + }) + + test(`left-join nullable branded refs remain valid numeric inputs`, () => { + createLiveQueryCollection({ + query: (q) => + q + .from({ row: rows }) + .leftJoin({ detail: details }, ({ row, detail }) => + eq(row.id, detail.rowId), + ) + .groupBy(({ row }) => row.group) + .select(({ row, detail }) => { + expectTypeOf(sum(detail.amount)).toEqualTypeOf>() + return { + group: row.group, + total: sum(detail.amount), + } + }), + }) + }) }) From 095f423a55bd75094ae98127610a100d23f4555b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 20 Sep 2026 16:22:48 +0100 Subject: [PATCH 3/5] fix(db): reject null-only aggregate refs --- packages/db/src/query/builder/functions.ts | 34 ++++++++----------- .../query/aggregate-value-contracts.test-d.ts | 9 ++++- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index f0d14a38f2..b44b0deb93 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -102,19 +102,19 @@ type AggregateArgument = T & ? unknown : never) -type NumericAggregateWrapper = - | RefProxy - | RefLeaf - | BasicExpression - type OrderableAggregateValue = number | Date | bigint | string -type OrderableAggregateWrapper = - | RefProxy - | RefLeaf - | BasicExpression +type AggregateWrapper = RefProxy | RefLeaf | BasicExpression // Constrained overloads compose through supported generics; these conditional // fallbacks validate concrete optional/nullish unions and reject unknown. +type NumericAggregateWrapperArgument = AggregateArgument< + AggregateWrapper, + number +> +type OrderableAggregateWrapperArgument = AggregateArgument< + AggregateWrapper, + OrderableAggregateValue +> type NumericAggregateArgument = AggregateArgument type OrderableAggregateArgument = AggregateArgument< T, @@ -667,8 +667,8 @@ export function count(arg: ExpressionLike): Aggregate { } export function avg(arg: T): Aggregate -export function avg( - arg: T, +export function avg( + arg: NumericAggregateWrapperArgument, ): Aggregate export function avg( arg: NumericAggregateArgument, @@ -678,8 +678,8 @@ export function avg(arg: ExpressionLike): Aggregate { } export function sum(arg: T): Aggregate -export function sum( - arg: T, +export function sum( + arg: NumericAggregateWrapperArgument, ): Aggregate export function sum( arg: NumericAggregateArgument, @@ -689,9 +689,7 @@ export function sum(arg: ExpressionLike): Aggregate { } export function min(arg: T): Aggregate -export function min( - arg: T, -): Aggregate> +export function min(arg: OrderableAggregateWrapperArgument): Aggregate export function min( arg: OrderableAggregateArgument, ): Aggregate> @@ -700,9 +698,7 @@ export function min(arg: ExpressionLike): Aggregate { } export function max(arg: T): Aggregate -export function max( - arg: T, -): Aggregate> +export function max(arg: OrderableAggregateWrapperArgument): Aggregate export function max( arg: OrderableAggregateArgument, ): Aggregate> diff --git a/packages/db/tests/query/aggregate-value-contracts.test-d.ts b/packages/db/tests/query/aggregate-value-contracts.test-d.ts index f280d49c8b..e110370317 100644 --- a/packages/db/tests/query/aggregate-value-contracts.test-d.ts +++ b/packages/db/tests/query/aggregate-value-contracts.test-d.ts @@ -112,9 +112,15 @@ describe(`aggregate value contracts`, () => { test(`rejects values outside each aggregate's documented domain`, () => { const loose = undefined as unknown as RefLeaf const unknownValue = undefined as unknown as RefLeaf + const nullLeaf = undefined as unknown as RefLeaf + const nullProxy = undefined as unknown as RefProxy expectTypeOf(sum(loose)).toEqualTypeOf>() expectTypeOf(min(loose)).toEqualTypeOf>() + // @ts-expect-error null-only wrappers have no numeric domain + sum(nullLeaf) + // @ts-expect-error null-only wrappers have no orderable domain + min(nullProxy) createLiveQueryCollection({ query: (q) => @@ -176,7 +182,8 @@ describe(`aggregate value contracts`, () => { expectTypeOf(sum(coalesce(nullableBrandedRef, 0))).toEqualTypeOf< Aggregate >() - expectTypeOf(minOrderableRef(mixedOrderableRef)).toEqualTypeOf< + expectTypeOf(minOrderableRef(mixedOrderableRef)).toMatchTypeOf() + expectTypeOf(min(mixedOrderableRef)).toEqualTypeOf< Aggregate >() expectTypeOf(maxOrderableValue(new Date())).toEqualTypeOf>() From 6eebd5ec19e9aca7ef795fc7ab8acd7cc6ac654f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 09:47:42 +0100 Subject: [PATCH 4/5] test(db): document aggregate type oracle --- .../query/aggregate-value-contracts.test-d.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/db/tests/query/aggregate-value-contracts.test-d.ts b/packages/db/tests/query/aggregate-value-contracts.test-d.ts index e110370317..af39ad12f0 100644 --- a/packages/db/tests/query/aggregate-value-contracts.test-d.ts +++ b/packages/db/tests/query/aggregate-value-contracts.test-d.ts @@ -17,6 +17,38 @@ import type { RefProxy } from '../../src/query/builder/ref-proxy.js' import type { RefLeaf } from '../../src/query/builder/types.js' import type { OutputWithVirtual } from '../utils.js' +/** + * Which values may cross the public aggregate-builder boundary, and which + * result type does each accepted value produce? + * + * Contract and laws: + * - `sum` and `avg` accept numeric values, expressions, and query refs. They + * return `Aggregate` because the runtime reduces them to numbers. + * - `min` and `max` accept number, string, bigint, or Date domains. Their + * result preserves the accepted value domain. + * - A nullable wrapper remains valid when its non-nullish domain is valid. + * A null-only wrapper and `unknown` have no aggregate value domain. + * - A generic helper constrained to a supported domain must forward its value + * through the same public overloads without widening or failing inference. + * + * Production path and observation cut: + * Calls go through the exported overloads in `query/builder/functions.ts`, + * both directly and from real select and left-join callbacks. TypeScript + * overload resolution is the boundary. `expectTypeOf` observes accepted calls + * and exact result types; `@ts-expect-error` observes rejected calls. + * + * Reach witnesses and fault controls: + * Positive assertions cover raw values, branded values, expressions, refs, + * nullable refs, generic forwarders, and projected query results. Negative + * controls would fail the type test if unsupported values became accepted. + * The broad generic forwarder proves that a weak constraint cannot bypass the + * domain law. + * + * Known omission: + * This partial oracle does not require `min` or `max` to reject a union of + * individually orderable domains such as `number | string`. Mixed-domain + * ordering remains outside the settled contract. + */ type BrandedAmount = number & { readonly __brand: `amount` } type AggregateRow = { From 2a26a225b62d301056dc3b9dfd227a826b2b11ab Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 12:26:10 +0100 Subject: [PATCH 5/5] test(db): keep mixed aggregate ordering unsettled --- .../tests/query/aggregate-value-contracts.test-d.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/db/tests/query/aggregate-value-contracts.test-d.ts b/packages/db/tests/query/aggregate-value-contracts.test-d.ts index af39ad12f0..88030a3977 100644 --- a/packages/db/tests/query/aggregate-value-contracts.test-d.ts +++ b/packages/db/tests/query/aggregate-value-contracts.test-d.ts @@ -186,11 +186,6 @@ describe(`aggregate value contracts`, () => { >( value: T, ) => avg(value) - const minOrderableRef = < - T extends RefLeaf, - >( - value: T, - ) => min(value) const maxOrderableValue = ( value: T, ) => max(value) @@ -200,8 +195,6 @@ describe(`aggregate value contracts`, () => { BrandedAmount | null | undefined, true > - const mixedOrderableRef = undefined as unknown as RefLeaf - expectTypeOf(sumNumber(branded)).toEqualTypeOf>() expectTypeOf(sum(1)).toEqualTypeOf>() expectTypeOf(avg(1)).toEqualTypeOf>() @@ -214,10 +207,6 @@ describe(`aggregate value contracts`, () => { expectTypeOf(sum(coalesce(nullableBrandedRef, 0))).toEqualTypeOf< Aggregate >() - expectTypeOf(minOrderableRef(mixedOrderableRef)).toMatchTypeOf() - expectTypeOf(min(mixedOrderableRef)).toEqualTypeOf< - Aggregate - >() expectTypeOf(maxOrderableValue(new Date())).toEqualTypeOf>() type BroadExpressionLike =