Skip to content

Commit 962ed2f

Browse files
KyleAMathewsclaude
andcommitted
fix(db, db-ivm): support Temporal objects in join hashing and normalization
Temporal objects have no enumerable properties, so hashPlainObject() produced identical hashes for all Temporal values. This caused join index updates to be silently swallowed when a Temporal field changed. - Add Temporal-aware hashing via Symbol.toStringTag + toString() - Add Temporal normalization in normalizeValue() for join key matching - Add Temporal handling in ascComparator for correct sort ordering - Add null guard to exported isTemporal() - Convert temporalTypes from Array to Set for consistency Fixes #1367 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1270156 commit 962ed2f

File tree

4 files changed

+54
-10
lines changed

4 files changed

+54
-10
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/db': patch
3+
'@tanstack/db-ivm': patch
4+
---
5+
6+
Fix Temporal objects breaking live query updates when used with joins. Temporal objects (e.g. `Temporal.PlainDate`) have no enumerable properties, so the structural hash function produced identical hashes for all Temporal values, causing join index updates to be silently swallowed. Also add Temporal support to value normalization for join key matching and to the comparator for correct sort ordering.

packages/db-ivm/src/hashing/hash.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ const ARRAY_MARKER = randomHash()
1818
const MAP_MARKER = randomHash()
1919
const SET_MARKER = randomHash()
2020
const UINT8ARRAY_MARKER = randomHash()
21+
const TEMPORAL_MARKER = randomHash()
22+
23+
const temporalTypes = new Set([
24+
`Temporal.Duration`,
25+
`Temporal.Instant`,
26+
`Temporal.PlainDate`,
27+
`Temporal.PlainDateTime`,
28+
`Temporal.PlainMonthDay`,
29+
`Temporal.PlainTime`,
30+
`Temporal.PlainYearMonth`,
31+
`Temporal.ZonedDateTime`,
32+
])
33+
34+
function isTemporal(input: object): boolean {
35+
const tag = (input as any)[Symbol.toStringTag]
36+
return typeof tag === `string` && temporalTypes.has(tag)
37+
}
2138

2239
// Maximum byte length for Uint8Arrays to hash by content instead of reference
2340
// Arrays smaller than this will be hashed by content, allowing proper equality comparisons
@@ -59,6 +76,8 @@ function hashObject(input: object): number {
5976
} else if (input instanceof File) {
6077
// Files are always hashed by reference due to their potentially large size
6178
return cachedReferenceHash(input)
79+
} else if (isTemporal(input)) {
80+
valueHash = hashTemporal(input)
6281
} else {
6382
let plainObjectInput = input
6483
let marker = OBJECT_MARKER
@@ -103,6 +122,14 @@ function hashUint8Array(input: Uint8Array): number {
103122
return hasher.digest()
104123
}
105124

125+
function hashTemporal(input: object): number {
126+
const hasher = new MurmurHashStream()
127+
hasher.update(TEMPORAL_MARKER)
128+
hasher.update((input as any)[Symbol.toStringTag])
129+
hasher.update((input as any).toString())
130+
return hasher.digest()
131+
}
132+
106133
function hashPlainObject(input: object, marker: number): number {
107134
const hasher = new MurmurHashStream()
108135

packages/db/src/utils.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ function deepEqualsInternal(
144144
// Handle Temporal objects
145145
// Check if both are Temporal objects of the same type
146146
if (isTemporal(a) && isTemporal(b)) {
147-
const aTag = getStringTag(a)
148-
const bTag = getStringTag(b)
147+
const aTag = a[Symbol.toStringTag]
148+
const bTag = b[Symbol.toStringTag]
149149

150150
// If they're different Temporal types, they're not equal
151151
if (aTag !== bTag) return false
@@ -211,7 +211,7 @@ function deepEqualsInternal(
211211
return false
212212
}
213213

214-
const temporalTypes = [
214+
const temporalTypes = new Set([
215215
`Temporal.Duration`,
216216
`Temporal.Instant`,
217217
`Temporal.PlainDate`,
@@ -220,16 +220,13 @@ const temporalTypes = [
220220
`Temporal.PlainTime`,
221221
`Temporal.PlainYearMonth`,
222222
`Temporal.ZonedDateTime`,
223-
]
224-
225-
function getStringTag(a: any): any {
226-
return a[Symbol.toStringTag]
227-
}
223+
])
228224

229225
/** Checks if the value is a Temporal object by checking for the Temporal brand */
230226
export function isTemporal(a: any): boolean {
231-
const tag = getStringTag(a)
232-
return typeof tag === `string` && temporalTypes.includes(tag)
227+
if (a == null || typeof a !== `object`) return false
228+
const tag = a[Symbol.toStringTag]
229+
return typeof tag === `string` && temporalTypes.has(tag)
233230
}
234231

235232
export const DEFAULT_COMPARE_OPTIONS: CompareOptions = {

packages/db/src/utils/comparison.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isTemporal } from '../utils'
12
import type { CompareOptions } from '../query/builder/types'
23

34
// WeakMap to store stable IDs for objects
@@ -54,6 +55,15 @@ export const ascComparator = (a: any, b: any, opts: CompareOptions): number => {
5455
return a.getTime() - b.getTime()
5556
}
5657

58+
// If both are Temporal objects of the same type, compare by string representation
59+
if (isTemporal(a) && isTemporal(b)) {
60+
const aStr = a.toString()
61+
const bStr = b.toString()
62+
if (aStr < bStr) return -1
63+
if (aStr > bStr) return 1
64+
return 0
65+
}
66+
5767
// If at least one of the values is an object, use stable IDs for comparison
5868
const aIsObject = typeof a === `object`
5969
const bIsObject = typeof b === `object`
@@ -154,6 +164,10 @@ export function normalizeValue(value: any): any {
154164
return value.getTime()
155165
}
156166

167+
if (isTemporal(value)) {
168+
return `__temporal__${value[Symbol.toStringTag]}__${value.toString()}`
169+
}
170+
157171
// Normalize Uint8Arrays/Buffers to a string representation for Map key usage
158172
// This enables content-based equality for binary data like ULIDs
159173
const isUint8Array =

0 commit comments

Comments
 (0)