From c041763cac3e4de5fd982ad5ba1590d0754d7a95 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Thu, 7 May 2026 10:45:32 +0200 Subject: [PATCH 1/9] fix(db): support Temporal values in query comparison operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The query compiler's gt/gte/lt/lte evaluators applied JavaScript's native relational operators directly. That throws TypeError on Temporal values because Temporal types intentionally make valueOf() throw — a spec-level guard against silent miscomparison. Adds a compareValues(a, b) helper that, when both operands share a Temporal type, dispatches through that type's static .compare() (Instant, PlainDate, PlainDateTime, PlainTime, PlainYearMonth, ZonedDateTime, Duration). Mixed Temporal types and types without a defined ordering (PlainMonthDay) throw a descriptive TypeError rather than fall back to a string-lex pseudo-compare, keeping us in line with Temporal's design. Non-Temporal values continue to use native operators (Dates via valueOf, numbers, strings, etc.). The gt/gte/lt/lte cases in evaluators.ts each switch from `a > b` to `compareValues(a, b) > 0` (and equivalents). Equality (eq) is unchanged: it still goes through normalizeValue's tagged toString, which means ZonedDateTime eq treats the zone as part of identity while ordering compares by instant — matching .equals() vs .compare() semantics in the spec. Tests cover all eight Temporal types, the same-instant-different-zone asymmetry for ZonedDateTime, and Duration's equivalent-forms case (PT60M vs PT1H — equal under .compare() but not .equals()). --- packages/db/src/query/compiler/evaluators.ts | 11 +- packages/db/src/utils/comparison.ts | 35 +++ .../tests/query/compiler/evaluators.test.ts | 253 ++++++++++++++++++ 3 files changed, 293 insertions(+), 6 deletions(-) diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index fa2e90725..25e7d5efb 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -5,6 +5,7 @@ import { } from '../../errors.js' import { areValuesEqual, + compareValues, isUnorderable, normalizeValue, } from '../../utils/comparison.js' @@ -265,12 +266,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnknown(a) || isUnknown(b)) { return null } - // NaN/invalid Dates sort greater than every other value, and are equal - // to one another (PostgreSQL semantics) if (isUnorderable(a) || isUnorderable(b)) { return isUnorderable(a) && !isUnorderable(b) } - return a > b + return compareValues(a, b) > 0 } } case `gte`: { @@ -286,7 +285,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnorderable(a) || isUnorderable(b)) { return isUnorderable(a) } - return a >= b + return compareValues(a, b) >= 0 } } case `lt`: { @@ -302,7 +301,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnorderable(a) || isUnorderable(b)) { return isUnorderable(b) && !isUnorderable(a) } - return a < b + return compareValues(a, b) < 0 } } case `lte`: { @@ -318,7 +317,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnorderable(a) || isUnorderable(b)) { return isUnorderable(b) } - return a <= b + return compareValues(a, b) <= 0 } } diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index 992f0098c..cff31954f 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -237,6 +237,41 @@ export function denormalizeUndefined(value: any): any { return value } +/** + * Order two non-null values, returning -1, 0, or 1. + * + * Temporal types intentionally throw from `valueOf` to prevent silent + * miscomparison via the native relational operators. When both operands + * share a Temporal type that exposes a static `compare`, dispatch through + * it. Mixed Temporal types and types without a defined ordering (notably + * `PlainMonthDay`) throw rather than fall back to a string-based pseudo- + * comparison, matching Temporal's design intent. For everything else + * (numbers, strings, Dates via `valueOf`, etc.) the native operators do + * the right thing. + * + * Callers must handle null/undefined themselves — this helper assumes both + * arguments are non-null. + */ +export function compareValues(a: any, b: any): number { + if (isTemporal(a) && isTemporal(b)) { + const aTag = a[Symbol.toStringTag] + const bTag = b[Symbol.toStringTag] + if (aTag !== bTag) { + throw new TypeError( + `Cannot order Temporal values of different types: ${aTag} vs ${bTag}`, + ) + } + const compare = ( + a.constructor as { compare?: (x: unknown, y: unknown) => number } + ).compare + if (typeof compare !== `function`) { + throw new TypeError(`${aTag} has no defined ordering`) + } + return compare(a, b) + } + return a < b ? -1 : a > b ? 1 : 0 +} + /** * Compare two values for equality, with special handling for Uint8Arrays and Buffers */ diff --git a/packages/db/tests/query/compiler/evaluators.test.ts b/packages/db/tests/query/compiler/evaluators.test.ts index 4c5acb78c..dac457867 100644 --- a/packages/db/tests/query/compiler/evaluators.test.ts +++ b/packages/db/tests/query/compiler/evaluators.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { Temporal } from 'temporal-polyfill' import { compileExpression } from '../../../src/query/compiler/evaluators.js' import { Func, PropRef, Value } from '../../../src/query/ir.js' import type { NamespacedRow } from '../../../src/types.js' @@ -811,6 +812,258 @@ describe(`evaluators`, () => { expect(compileExpression(func)({})).toBe(true) }) }) + + describe(`Temporal objects`, () => { + const evalOp = (op: string, left: any, right: any) => + compileExpression( + new Func(op, [new Value(left), new Value(right)]), + )({}) + + describe(`Instant`, () => { + const earlier = Temporal.Instant.from(`2024-01-15T10:00:00Z`) + const later = Temporal.Instant.from(`2024-01-15T11:00:00Z`) + + it(`orders chronologically`, () => { + expect(evalOp(`gt`, later, earlier)).toBe(true) + expect(evalOp(`gt`, earlier, later)).toBe(false) + expect(evalOp(`gt`, earlier, earlier)).toBe(false) + expect(evalOp(`gte`, earlier, earlier)).toBe(true) + expect(evalOp(`lt`, earlier, later)).toBe(true) + expect(evalOp(`lte`, earlier, earlier)).toBe(true) + }) + + it(`eq returns true for distinct instances with the same instant`, () => { + const earlierCopy = Temporal.Instant.from(earlier) + expect(earlier).not.toBe(earlierCopy) + expect(evalOp(`eq`, earlier, earlierCopy)).toBe(true) + }) + }) + + describe(`PlainDate`, () => { + const jan15 = Temporal.PlainDate.from(`2024-01-15`) + const jan16 = Temporal.PlainDate.from(`2024-01-16`) + + it(`orders chronologically`, () => { + expect(evalOp(`gt`, jan16, jan15)).toBe(true) + expect(evalOp(`gt`, jan15, jan16)).toBe(false) + expect(evalOp(`gt`, jan15, jan15)).toBe(false) + expect(evalOp(`gte`, jan15, jan15)).toBe(true) + expect(evalOp(`lt`, jan15, jan16)).toBe(true) + expect(evalOp(`lte`, jan15, jan15)).toBe(true) + }) + + it(`eq returns true for distinct instances with the same date`, () => { + const jan15Copy = Temporal.PlainDate.from(jan15) + expect(jan15).not.toBe(jan15Copy) + expect(evalOp(`eq`, jan15, jan15Copy)).toBe(true) + }) + + it(`eq returns false for different dates`, () => { + expect(evalOp(`eq`, jan15, jan16)).toBe(false) + }) + }) + + describe(`PlainDateTime`, () => { + const a = Temporal.PlainDateTime.from(`2024-01-15T10:30:00`) + const b = Temporal.PlainDateTime.from(`2024-01-15T10:30:01`) + + it(`orders chronologically`, () => { + expect(evalOp(`gt`, b, a)).toBe(true) + expect(evalOp(`gt`, a, b)).toBe(false) + expect(evalOp(`gt`, a, a)).toBe(false) + expect(evalOp(`gte`, a, a)).toBe(true) + expect(evalOp(`lt`, a, b)).toBe(true) + expect(evalOp(`lte`, a, a)).toBe(true) + }) + + it(`eq returns true for distinct instances with the same value`, () => { + const aCopy = Temporal.PlainDateTime.from(a) + expect(a).not.toBe(aCopy) + expect(evalOp(`eq`, a, aCopy)).toBe(true) + }) + }) + + describe(`PlainTime`, () => { + const morning = Temporal.PlainTime.from(`08:00:00`) + const evening = Temporal.PlainTime.from(`20:00:00`) + + it(`orders by time of day`, () => { + expect(evalOp(`gt`, evening, morning)).toBe(true) + expect(evalOp(`gt`, morning, evening)).toBe(false) + expect(evalOp(`gt`, morning, morning)).toBe(false) + expect(evalOp(`gte`, morning, morning)).toBe(true) + expect(evalOp(`lt`, morning, evening)).toBe(true) + expect(evalOp(`lte`, morning, morning)).toBe(true) + }) + + it(`eq returns true for distinct instances with the same time`, () => { + const morningCopy = Temporal.PlainTime.from(morning) + expect(morning).not.toBe(morningCopy) + expect(evalOp(`eq`, morning, morningCopy)).toBe(true) + }) + + it(`eq returns false for different times`, () => { + expect(evalOp(`eq`, morning, evening)).toBe(false) + }) + }) + + describe(`PlainYearMonth`, () => { + const feb = Temporal.PlainYearMonth.from(`2024-02`) + const mar = Temporal.PlainYearMonth.from(`2024-03`) + + it(`orders chronologically`, () => { + expect(evalOp(`gt`, mar, feb)).toBe(true) + expect(evalOp(`gt`, feb, mar)).toBe(false) + expect(evalOp(`gt`, feb, feb)).toBe(false) + expect(evalOp(`gte`, feb, feb)).toBe(true) + expect(evalOp(`lt`, feb, mar)).toBe(true) + expect(evalOp(`lte`, feb, feb)).toBe(true) + }) + + it(`eq returns true for distinct instances with the same year/month`, () => { + const febCopy = Temporal.PlainYearMonth.from(feb) + expect(feb).not.toBe(febCopy) + expect(evalOp(`eq`, feb, febCopy)).toBe(true) + }) + + it(`eq returns false for different year/month pairs`, () => { + expect(evalOp(`eq`, feb, mar)).toBe(false) + }) + }) + + describe(`PlainMonthDay`, () => { + // PlainMonthDay has no static .compare() — there is no canonical ordering + // without a year (e.g. Feb 29 only sometimes follows Feb 28). Equality is + // still well-defined. + const md1 = Temporal.PlainMonthDay.from(`--03-15`) + const md2 = Temporal.PlainMonthDay.from(`--04-15`) + + it(`eq returns true for distinct instances with the same month/day`, () => { + const md1Copy = Temporal.PlainMonthDay.from(md1) + expect(md1).not.toBe(md1Copy) + expect(evalOp(`eq`, md1, md1Copy)).toBe(true) + }) + + it(`eq returns false for different month/day pairs`, () => { + expect(evalOp(`eq`, md1, md2)).toBe(false) + }) + + it(`gt throws since PlainMonthDay has no defined ordering`, () => { + expect(() => evalOp(`gt`, md1, md2)).toThrow( + /no defined ordering/, + ) + }) + }) + + describe(`ZonedDateTime`, () => { + // Same wall-clock noon in two zones is a different instant. + // Tokyo is ahead of New York, so noon Tokyo precedes noon NY by ~14h. + const tokyoNoon = Temporal.ZonedDateTime.from( + `2024-01-15T12:00:00[Asia/Tokyo]`, + ) + const nyNoon = Temporal.ZonedDateTime.from( + `2024-01-15T12:00:00[America/New_York]`, + ) + + it(`orders by underlying instant across time zones`, () => { + expect(evalOp(`gt`, nyNoon, tokyoNoon)).toBe(true) + expect(evalOp(`gt`, tokyoNoon, nyNoon)).toBe(false) + expect(evalOp(`gt`, tokyoNoon, tokyoNoon)).toBe(false) + expect(evalOp(`gte`, tokyoNoon, tokyoNoon)).toBe(true) + expect(evalOp(`lt`, tokyoNoon, nyNoon)).toBe(true) + expect(evalOp(`lte`, tokyoNoon, tokyoNoon)).toBe(true) + }) + + it(`eq returns true for distinct instances with same instant AND zone`, () => { + const tokyoNoonCopy = Temporal.ZonedDateTime.from(tokyoNoon) + expect(tokyoNoon).not.toBe(tokyoNoonCopy) + expect(evalOp(`eq`, tokyoNoon, tokyoNoonCopy)).toBe(true) + }) + + it(`eq returns false for same instant in different zones`, () => { + // ZonedDateTime.equals treats the time zone as part of identity, so two + // ZonedDateTimes for the same instant in different zones are not equal — + // even though .compare() returns 0 for them. + const inTokyo = nyNoon.withTimeZone(`Asia/Tokyo`) + expect(evalOp(`eq`, nyNoon, inTokyo)).toBe(false) + }) + + it(`ranks same-instant-different-zone as positionally equal`, () => { + // .compare() ranks by underlying instant, so two ZonedDateTimes for + // the same instant in different zones are positionally equal — even + // though .equals() returns false (see the eq test above). This + // verifies we dispatch to .compare() rather than lex-comparing + // toString output, which would order by zone name. + const inTokyo = nyNoon.withTimeZone(`Asia/Tokyo`) + expect(evalOp(`gt`, nyNoon, inTokyo)).toBe(false) + expect(evalOp(`lt`, nyNoon, inTokyo)).toBe(false) + expect(evalOp(`gte`, nyNoon, inTokyo)).toBe(true) + expect(evalOp(`lte`, nyNoon, inTokyo)).toBe(true) + }) + }) + + describe(`Duration`, () => { + // Without a relativeTo, Duration ordering is only well-defined when both + // values are time/day-only. Calendar-affected fields (years/months/weeks) + // are not exercised here. + const oneHour = Temporal.Duration.from(`PT1H`) + const twoHours = Temporal.Duration.from(`PT2H`) + + it(`orders time-only durations by length`, () => { + expect(evalOp(`gt`, twoHours, oneHour)).toBe(true) + expect(evalOp(`gt`, oneHour, twoHours)).toBe(false) + expect(evalOp(`gt`, oneHour, oneHour)).toBe(false) + expect(evalOp(`gte`, oneHour, oneHour)).toBe(true) + expect(evalOp(`lt`, oneHour, twoHours)).toBe(true) + expect(evalOp(`lte`, oneHour, oneHour)).toBe(true) + }) + + it(`eq returns true for distinct instances with the same value`, () => { + const oneHourCopy = Temporal.Duration.from(oneHour) + expect(oneHour).not.toBe(oneHourCopy) + expect(evalOp(`eq`, oneHour, oneHourCopy)).toBe(true) + }) + + it(`eq returns false for different durations`, () => { + expect(evalOp(`eq`, oneHour, twoHours)).toBe(false) + }) + + it(`equivalent forms compare equal but eq returns false`, () => { + // PT60M and PT1H represent the same duration. .compare() ranks them + // as equal, but .equals() (structural, field-by-field) does not, so + // eq returns false. This pins the asymmetry — and verifies we + // dispatch to .compare() rather than lex-comparing toString output, + // which would say "PT1H" < "PT60M". + const sixtyMinutes = Temporal.Duration.from(`PT60M`) + const oneHourLit = Temporal.Duration.from(`PT1H`) + expect(evalOp(`gt`, sixtyMinutes, oneHourLit)).toBe(false) + expect(evalOp(`lt`, sixtyMinutes, oneHourLit)).toBe(false) + expect(evalOp(`gte`, sixtyMinutes, oneHourLit)).toBe(true) + expect(evalOp(`lte`, sixtyMinutes, oneHourLit)).toBe(true) + expect(evalOp(`eq`, sixtyMinutes, oneHourLit)).toBe(false) + }) + }) + + it(`eq returns false for different Temporal types with overlapping string forms`, () => { + // PlainDate and PlainDateTime can both stringify to "2024-01-15..." but + // should compare unequal because the types differ. + const date = Temporal.PlainDate.from(`2024-01-15`) + const dateTime = Temporal.PlainDateTime.from(`2024-01-15`) + // sanity-check the strings share a prefix + expect(dateTime.toString().startsWith(date.toString())).toBe(true) + expect(evalOp(`eq`, date, dateTime)).toBe(false) + }) + + it(`gt throws when comparing different Temporal types`, () => { + // Mixed-type ordering is undefined; surfacing a TypeError beats the + // string-lex pseudo-comparison Temporal explicitly designs against. + const date = Temporal.PlainDate.from(`2024-01-15`) + const dateTime = Temporal.PlainDateTime.from(`2024-01-15T00:00:00`) + expect(() => evalOp(`gt`, date, dateTime)).toThrow( + /different types/, + ) + }) + }) }) describe(`boolean operators`, () => { From bc9971998aa98b90ecd4bcd992668808d5c9cde7 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Wed, 1 Jul 2026 13:50:14 +0200 Subject: [PATCH 2/9] refactor(db): extract compareTemporalValues with tag-based dispatch --- packages/db/src/utils/comparison.ts | 66 ++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index cff31954f..4f9758da5 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -237,37 +237,61 @@ export function denormalizeUndefined(value: any): any { return value } +// Cached map from Symbol.toStringTag → static compare function (null = none defined). +// Populated lazily on first encounter of each Temporal type so we never access +// `.constructor` more than once per type, and dispatch is keyed on the already- +// computed brand tag rather than on the constructor itself. +const temporalCompareByTag = new Map< + string, + ((a: unknown, b: unknown) => number) | null +>() + +/** + * Compare two Temporal values of the same type, returning -1, 0, or 1. + * + * Dispatch is keyed on `Symbol.toStringTag` (the brand already checked by + * `isTemporal`) rather than `a.constructor`, making it robust across realms + * and resistant to a shadowed `constructor` property. Types without a static + * `.compare` (e.g. `PlainMonthDay`) throw rather than fall back to string + * comparison, matching Temporal's design intent. + * + * Callers must ensure both arguments are Temporal objects; mixed types throw. + */ +export function compareTemporalValues(a: any, b: any): number { + const aTag = a[Symbol.toStringTag] as string + const bTag = b[Symbol.toStringTag] as string + if (aTag !== bTag) { + throw new TypeError( + `Cannot order Temporal values of different types: ${aTag} vs ${bTag}`, + ) + } + let compare = temporalCompareByTag.get(aTag) + if (compare === undefined) { + const fn = (a.constructor as { compare?: (x: unknown, y: unknown) => number }) + .compare + compare = typeof fn === `function` ? fn : null + temporalCompareByTag.set(aTag, compare) + } + if (compare === null) { + throw new TypeError(`${aTag} has no defined ordering`) + } + return compare(a, b) +} + /** * Order two non-null values, returning -1, 0, or 1. * * Temporal types intentionally throw from `valueOf` to prevent silent - * miscomparison via the native relational operators. When both operands - * share a Temporal type that exposes a static `compare`, dispatch through - * it. Mixed Temporal types and types without a defined ordering (notably - * `PlainMonthDay`) throw rather than fall back to a string-based pseudo- - * comparison, matching Temporal's design intent. For everything else - * (numbers, strings, Dates via `valueOf`, etc.) the native operators do - * the right thing. + * miscomparison via the native relational operators — delegate to + * `compareTemporalValues` for them. For everything else (numbers, strings, + * Dates via `valueOf`, etc.) the native operators do the right thing. * * Callers must handle null/undefined themselves — this helper assumes both * arguments are non-null. */ export function compareValues(a: any, b: any): number { if (isTemporal(a) && isTemporal(b)) { - const aTag = a[Symbol.toStringTag] - const bTag = b[Symbol.toStringTag] - if (aTag !== bTag) { - throw new TypeError( - `Cannot order Temporal values of different types: ${aTag} vs ${bTag}`, - ) - } - const compare = ( - a.constructor as { compare?: (x: unknown, y: unknown) => number } - ).compare - if (typeof compare !== `function`) { - throw new TypeError(`${aTag} has no defined ordering`) - } - return compare(a, b) + return compareTemporalValues(a, b) } return a < b ? -1 : a > b ? 1 : 0 } From 381bd47488ade74aeaa813f1243585c9cb37bc39 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Wed, 1 Jul 2026 14:31:15 +0200 Subject: [PATCH 3/9] fix(db): use compareTemporalValues in ascComparator for consistent orderBy semantics --- packages/db/src/utils/comparison.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index 4f9758da5..9cd11d2a7 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -80,13 +80,9 @@ export const ascComparator = (a: any, b: any, opts: CompareOptions): number => { return a.getTime() - b.getTime() } - // If both are Temporal objects of the same type, compare by string representation + // If both are Temporal objects, use compareTemporalValues for correct semantic ordering if (isTemporal(a) && isTemporal(b)) { - const aStr = a.toString() - const bStr = b.toString() - if (aStr < bStr) return -1 - if (aStr > bStr) return 1 - return 0 + return compareTemporalValues(a, b) } // If at least one of the values is an object, use stable IDs for comparison From 1e904413c70df9cb501f950c68e397d10a82bb21 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Wed, 1 Jul 2026 14:44:18 +0200 Subject: [PATCH 4/9] test(db): pin compareValues NaN fallback behavior --- packages/db/tests/comparison.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/db/tests/comparison.test.ts b/packages/db/tests/comparison.test.ts index ae40488f4..85bd1b3b8 100644 --- a/packages/db/tests/comparison.test.ts +++ b/packages/db/tests/comparison.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { ascComparator, defaultComparator } from '../src/utils/comparison' +import { + ascComparator, + compareValues, + defaultComparator, +} from '../src/utils/comparison' import { DEFAULT_COMPARE_OPTIONS } from '../src/utils' describe(`ascComparator - PostgreSQL float semantics for NaN`, () => { @@ -39,3 +43,16 @@ describe(`ascComparator - PostgreSQL float semantics for NaN`, () => { expect(ascComparator(valid, invalid, opts)).toBeLessThan(0) }) }) + +describe(`compareValues - NaN behavior`, () => { + // NaN satisfies neither < nor >, so the fallback returns 0. In practice + // gt/gte/lt/lte catch NaN via isUnorderable before reaching compareValues. + it(`treats NaN as equal to NaN`, () => { + expect(compareValues(NaN, NaN)).toBe(0) + }) + + it(`returns 0 for NaN vs a finite number — neither < nor > holds for NaN`, () => { + expect(compareValues(NaN, 5)).toBe(0) + expect(compareValues(5, NaN)).toBe(0) + }) +}) From c049f08f56eeef0a35175d45a381de4d568d771a Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Wed, 1 Jul 2026 14:45:34 +0200 Subject: [PATCH 5/9] chore: add changeset for Temporal comparator support --- .changeset/temporal-comparators.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/temporal-comparators.md diff --git a/.changeset/temporal-comparators.md b/.changeset/temporal-comparators.md new file mode 100644 index 000000000..5a9e44cd4 --- /dev/null +++ b/.changeset/temporal-comparators.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Support Temporal values in `gt`/`gte`/`lt`/`lte` query operators, using `Temporal.*.compare()` for correct semantic ordering. `orderBy` now uses the same logic, fixing inconsistencies with `Duration` and `ZonedDateTime` values. From 7f77373f3da672cec2703496924892d86e3ebd64 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Wed, 1 Jul 2026 15:01:03 +0200 Subject: [PATCH 6/9] refactor(db): tighten compareValues/compareTemporalValues params to unknown --- packages/db/src/utils/comparison.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index 9cd11d2a7..f5d19b2bb 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -253,9 +253,9 @@ const temporalCompareByTag = new Map< * * Callers must ensure both arguments are Temporal objects; mixed types throw. */ -export function compareTemporalValues(a: any, b: any): number { - const aTag = a[Symbol.toStringTag] as string - const bTag = b[Symbol.toStringTag] as string +export function compareTemporalValues(a: unknown, b: unknown): number { + const aTag = (a as Record)[Symbol.toStringTag] as string + const bTag = (b as Record)[Symbol.toStringTag] as string if (aTag !== bTag) { throw new TypeError( `Cannot order Temporal values of different types: ${aTag} vs ${bTag}`, @@ -263,7 +263,7 @@ export function compareTemporalValues(a: any, b: any): number { } let compare = temporalCompareByTag.get(aTag) if (compare === undefined) { - const fn = (a.constructor as { compare?: (x: unknown, y: unknown) => number }) + const fn = ((a as { constructor: unknown }).constructor as { compare?: (x: unknown, y: unknown) => number }) .compare compare = typeof fn === `function` ? fn : null temporalCompareByTag.set(aTag, compare) @@ -285,11 +285,11 @@ export function compareTemporalValues(a: any, b: any): number { * Callers must handle null/undefined themselves — this helper assumes both * arguments are non-null. */ -export function compareValues(a: any, b: any): number { +export function compareValues(a: unknown, b: unknown): number { if (isTemporal(a) && isTemporal(b)) { return compareTemporalValues(a, b) } - return a < b ? -1 : a > b ? 1 : 0 + return (a as any) < (b as any) ? -1 : (a as any) > (b as any) ? 1 : 0 } /** From 45d86407834da0b2c74e266117971e914dcbbe33 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Wed, 1 Jul 2026 15:04:08 +0200 Subject: [PATCH 7/9] test(db): add ascComparator regression tests for Temporal ordering --- packages/db/tests/comparison.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/db/tests/comparison.test.ts b/packages/db/tests/comparison.test.ts index 85bd1b3b8..e89debf80 100644 --- a/packages/db/tests/comparison.test.ts +++ b/packages/db/tests/comparison.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { Temporal } from 'temporal-polyfill' import { ascComparator, compareValues, @@ -44,6 +45,26 @@ describe(`ascComparator - PostgreSQL float semantics for NaN`, () => { }) }) +describe(`ascComparator - Temporal values`, () => { + const opts = DEFAULT_COMPARE_OPTIONS + + it(`orders PlainDate values by calendar date`, () => { + const earlier = new Temporal.PlainDate(2024, 1, 1) + const later = new Temporal.PlainDate(2024, 6, 1) + expect(ascComparator(earlier, later, opts)).toBeLessThan(0) + expect(ascComparator(later, earlier, opts)).toBeGreaterThan(0) + expect(ascComparator(earlier, new Temporal.PlainDate(2024, 1, 1), opts)).toBe(0) + }) + + it(`treats ZonedDateTime values at the same instant as equal regardless of zone`, () => { + // 2024-01-15T06:30Z and 2024-01-15T12:00+05:30 are the same instant; + // lexicographic toString comparison would order them differently. + const utc = Temporal.ZonedDateTime.from(`2024-01-15T06:30:00+00:00[+00:00]`) + const ist = Temporal.ZonedDateTime.from(`2024-01-15T12:00:00+05:30[+05:30]`) + expect(ascComparator(utc, ist, opts)).toBe(0) + }) +}) + describe(`compareValues - NaN behavior`, () => { // NaN satisfies neither < nor >, so the fallback returns 0. In practice // gt/gte/lt/lte catch NaN via isUnorderable before reaching compareValues. From acba8123a31c29d3bb0d3724e9d2c7468d4bace0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:43:26 +0000 Subject: [PATCH 8/9] ci: apply automated fixes --- packages/db/src/utils/comparison.ts | 7 +++++-- packages/db/tests/comparison.test.ts | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index f5d19b2bb..265db441b 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -263,8 +263,11 @@ export function compareTemporalValues(a: unknown, b: unknown): number { } let compare = temporalCompareByTag.get(aTag) if (compare === undefined) { - const fn = ((a as { constructor: unknown }).constructor as { compare?: (x: unknown, y: unknown) => number }) - .compare + const fn = ( + (a as { constructor: unknown }).constructor as { + compare?: (x: unknown, y: unknown) => number + } + ).compare compare = typeof fn === `function` ? fn : null temporalCompareByTag.set(aTag, compare) } diff --git a/packages/db/tests/comparison.test.ts b/packages/db/tests/comparison.test.ts index e89debf80..ded56421c 100644 --- a/packages/db/tests/comparison.test.ts +++ b/packages/db/tests/comparison.test.ts @@ -53,7 +53,9 @@ describe(`ascComparator - Temporal values`, () => { const later = new Temporal.PlainDate(2024, 6, 1) expect(ascComparator(earlier, later, opts)).toBeLessThan(0) expect(ascComparator(later, earlier, opts)).toBeGreaterThan(0) - expect(ascComparator(earlier, new Temporal.PlainDate(2024, 1, 1), opts)).toBe(0) + expect( + ascComparator(earlier, new Temporal.PlainDate(2024, 1, 1), opts), + ).toBe(0) }) it(`treats ZonedDateTime values at the same instant as equal regardless of zone`, () => { From 14a8923c05c884f855703f8dd996b3121c279f61 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Mon, 20 Jul 2026 12:02:21 +0200 Subject: [PATCH 9/9] Update changeset for Temporal comparators in line with review comment --- .changeset/temporal-comparators.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/temporal-comparators.md b/.changeset/temporal-comparators.md index 5a9e44cd4..9e6c8aaa7 100644 --- a/.changeset/temporal-comparators.md +++ b/.changeset/temporal-comparators.md @@ -2,4 +2,11 @@ '@tanstack/db': patch --- -Support Temporal values in `gt`/`gte`/`lt`/`lte` query operators, using `Temporal.*.compare()` for correct semantic ordering. `orderBy` now uses the same logic, fixing inconsistencies with `Duration` and `ZonedDateTime` values. +Support Temporal values in the `gt`/`gte`/`lt`/`lte` query operators. Comparisons now dispatch to the Temporal types' static `compare()` instead of the native relational operators, which throw on Temporal objects (`valueOf()` is designed to throw). `orderBy` uses the same logic, so filtering and ordering now agree — previously `orderBy` compared Temporal values lexicographically by their `toString()`, which mis-ordered equivalent `Duration` forms (`PT60M` vs `PT1H`) and same-instant `ZonedDateTime` values in different zones. + +Note two intentional behavior changes for `orderBy` over Temporal columns: + +- Ordering `Temporal.PlainMonthDay` values now throws a `TypeError`, since the type has no defined ordering (previously they were silently ordered by string). +- Ordering mixed Temporal types (e.g. `PlainDate` vs `PlainDateTime`) now throws a `TypeError` instead of comparing their string forms. + +Equality (`eq`) is unchanged: `ZonedDateTime` equality still treats the zone as part of identity, and equivalent `Duration` forms remain unequal, mirroring Temporal's `.equals()` vs `.compare()` semantics.