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-aggregate-value-types.md
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 67 additions & 21 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,35 @@ type ExtractType<T> =
? U
: T

// Helper type to determine aggregate return type based on input nullability
type AggregateReturnType<T> =
ExtractType<T> extends infer U
? U extends number | undefined | null | Date | bigint | string
? Aggregate<U>
: Aggregate<number | undefined | null | Date | bigint | string>
: Aggregate<number | undefined | null | Date | bigint | string>
type IsAny<T> = 0 extends 1 & T ? true : false

type AggregateArgument<T, Domain> = T &
(IsAny<ExtractType<T>> extends true
? unknown
: [Exclude<ExtractType<T>, null | undefined>] extends [never]
? never
: [Exclude<ExtractType<T>, null | undefined>] extends [Domain]
? unknown
: never)

type OrderableAggregateValue = number | Date | bigint | string
type AggregateWrapper<T> = RefProxy<T> | RefLeaf<T> | BasicExpression<T>

// Constrained overloads compose through supported generics; these conditional
// fallbacks validate concrete optional/nullish unions and reject unknown.
type NumericAggregateWrapperArgument<T> = AggregateArgument<
AggregateWrapper<T>,
number
>
type OrderableAggregateWrapperArgument<T> = AggregateArgument<
AggregateWrapper<T>,
OrderableAggregateValue
>
type NumericAggregateArgument<T> = AggregateArgument<T, number>
type OrderableAggregateArgument<T> = AggregateArgument<
T,
OrderableAggregateValue
>

// Helper type to determine string function return type based on input nullability
type StringFunctionReturnType<T> =
Expand Down Expand Up @@ -644,20 +666,44 @@ export function count(arg: ExpressionLike): Aggregate<number> {
return new Aggregate(`count`, [toExpression(arg)])
}

export function avg<T extends ExpressionLike>(arg: T): AggregateReturnType<T> {
return new Aggregate(`avg`, [toExpression(arg)]) as AggregateReturnType<T>
}

export function sum<T extends ExpressionLike>(arg: T): AggregateReturnType<T> {
return new Aggregate(`sum`, [toExpression(arg)]) as AggregateReturnType<T>
}

export function min<T extends ExpressionLike>(arg: T): AggregateReturnType<T> {
return new Aggregate(`min`, [toExpression(arg)]) as AggregateReturnType<T>
}

export function max<T extends ExpressionLike>(arg: T): AggregateReturnType<T> {
return new Aggregate(`max`, [toExpression(arg)]) as AggregateReturnType<T>
export function avg<T extends number>(arg: T): Aggregate<number>
export function avg<T>(
arg: NumericAggregateWrapperArgument<T>,
): Aggregate<number>
export function avg<T extends ExpressionLike>(
arg: NumericAggregateArgument<T>,
): Aggregate<number>
export function avg(arg: ExpressionLike): Aggregate<number> {
return new Aggregate(`avg`, [toExpression(arg)])
}

export function sum<T extends number>(arg: T): Aggregate<number>
export function sum<T>(
arg: NumericAggregateWrapperArgument<T>,
): Aggregate<number>
export function sum<T extends ExpressionLike>(
arg: NumericAggregateArgument<T>,
): Aggregate<number>
export function sum(arg: ExpressionLike): Aggregate<number> {
return new Aggregate(`sum`, [toExpression(arg)])
}

export function min<T extends OrderableAggregateValue>(arg: T): Aggregate<T>
export function min<T>(arg: OrderableAggregateWrapperArgument<T>): Aggregate<T>
export function min<T extends ExpressionLike>(
arg: OrderableAggregateArgument<T>,
): Aggregate<ExtractType<T>>
export function min(arg: ExpressionLike): Aggregate {
return new Aggregate(`min`, [toExpression(arg)])
}

export function max<T extends OrderableAggregateValue>(arg: T): Aggregate<T>
export function max<T>(arg: OrderableAggregateWrapperArgument<T>): Aggregate<T>
export function max<T extends ExpressionLike>(
arg: OrderableAggregateArgument<T>,
): Aggregate<ExtractType<T>>
export function max(arg: ExpressionLike): Aggregate {
return new Aggregate(`max`, [toExpression(arg)])
}

/**
Expand Down
260 changes: 260 additions & 0 deletions packages/db/tests/query/aggregate-value-contracts.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { describe, expectTypeOf, test } from 'vitest'
import { createCollection } from '../../src/collection/index.js'
import { createLiveQueryCollection } from '../../src/query/index.js'
import {
add,
avg,
coalesce,
count,
eq,
max,
min,
sum,
} from '../../src/query/builder/functions.js'
import { mockSyncCollectionOptions } from '../utils.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'

/**
* 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<number>` 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 = {
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<AggregateRow>({
id: `aggregate-value-contracts`,
getKey: (row) => row.id,
initialData: [],
}),
)

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({
query: (q) =>
q
.from({ row: rows })
.groupBy(({ row }) => row.group)
.select(({ row }) => {
expectTypeOf(count(row.maybeAmount)).toEqualTypeOf<
Aggregate<number>
>()
expectTypeOf(sum(row.amount)).toEqualTypeOf<Aggregate<number>>()
expectTypeOf(avg(row.amount)).toEqualTypeOf<Aggregate<number>>()
expectTypeOf(sum(row.maybeAmount)).toEqualTypeOf<
Aggregate<number>
>()
expectTypeOf(avg(row.maybeAmount)).toEqualTypeOf<
Aggregate<number>
>()
expectTypeOf(min(row.label)).toEqualTypeOf<Aggregate<string>>()
expectTypeOf(max(row.createdAt)).toEqualTypeOf<Aggregate<Date>>()
expectTypeOf(min(row.sequence)).toEqualTypeOf<Aggregate<bigint>>()

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<any>
const unknownValue = undefined as unknown as RefLeaf<unknown>
const nullLeaf = undefined as unknown as RefLeaf<null>
const nullProxy = undefined as unknown as RefProxy<null>

expectTypeOf(sum(loose)).toEqualTypeOf<Aggregate<number>>()
expectTypeOf(min(loose)).toEqualTypeOf<Aggregate<any>>()
// @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) =>
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),
})),
})
})

test(`supported generic wrappers compose without widening their domains`, () => {
const sumNumber = <T extends number>(value: T) => sum(value)
const sumNumericRef = <T extends RefLeaf<number | null | undefined>>(
value: T,
) => sum(value)
const avgNumericExpression = <
T extends BasicExpression<number | null | undefined>,
>(
value: T,
) => avg(value)
const maxOrderableValue = <T extends number | string | bigint | Date>(
value: T,
) => max(value)

const branded = 1 as BrandedAmount
const nullableBrandedRef = undefined as unknown as RefLeaf<
BrandedAmount | null | undefined,
true
>
expectTypeOf(sumNumber(branded)).toEqualTypeOf<Aggregate<number>>()
expectTypeOf(sum(1)).toEqualTypeOf<Aggregate<number>>()
expectTypeOf(avg(1)).toEqualTypeOf<Aggregate<number>>()
expectTypeOf(sumNumericRef(nullableBrandedRef)).toEqualTypeOf<
Aggregate<number>
>()
expectTypeOf(avgNumericExpression(add(1, 2))).toEqualTypeOf<
Aggregate<number>
>()
expectTypeOf(sum(coalesce(nullableBrandedRef, 0))).toEqualTypeOf<
Aggregate<number>
>()
expectTypeOf(maxOrderableValue(new Date())).toEqualTypeOf<Aggregate<Date>>()

type BroadExpressionLike =
| Aggregate
| BasicExpression
| RefProxy<any>
| RefLeaf<any>
| string
| number
| boolean
| bigint
| Date
| null
| undefined
| Array<unknown>

const unsupportedBroadForwarder = <T extends BroadExpressionLike>(
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<Aggregate<number>>()
return {
group: row.group,
total: sum(detail.amount),
}
}),
})
})
})
Loading