Skip to content

Commit bb09eb1

Browse files
KyleAMathewsgoatrenterguyclaude
authored
fix(db, db-ivm): support Temporal objects in join hashing and normalization (#1370)
* fix(db-ivm): hash Temporal objects by value instead of identity Temporal objects (PlainDate, ZonedDateTime, etc.) have no enumerable own properties, so Object.keys() returns [] and all instances produce identical hashes. This causes the IVM join Index to treat old and new rows as equal, silently swallowing updates when only a Temporal field changed. Hash Temporal objects by their Symbol.toStringTag type and toString() representation to produce correct, value-based hashes. Fixes #1367 Co-Authored-By: Claude Opus 4.6 <[email protected]> * 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 <[email protected]> * fix: replace as-any casts with proper TemporalLike types Address review feedback from Sam Willis and CodeRabbit: - Add TemporalLike interface in db-ivm for type-safe Temporal detection - Make isTemporal a proper type guard (returns input is TemporalLike) - Remove as-any casts in hashTemporal - Add return type to createTemporalLike test helper - Remove unnecessary casts in join regression test Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use real Temporal objects in hash tests instead of mocks Add temporal-polyfill as devDependency to db-ivm and replace createTemporalLike mocks with real Temporal.PlainDate, PlainTime, PlainDateTime, and Instant objects in hash tests. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Ben Guericke <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 179d666 commit bb09eb1

8 files changed

Lines changed: 184 additions & 11 deletions

File tree

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/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
},
5656
"devDependencies": {
5757
"@types/debug": "^4.1.12",
58-
"@vitest/coverage-istanbul": "^3.2.4"
58+
"@vitest/coverage-istanbul": "^3.2.4",
59+
"temporal-polyfill": "^0.3.0"
5960
}
6061
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,28 @@ 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+
interface TemporalLike {
35+
[Symbol.toStringTag]: string
36+
toString: () => string
37+
}
38+
39+
function isTemporal(input: object): input is TemporalLike {
40+
const tag = (input as Record<symbol, unknown>)[Symbol.toStringTag]
41+
return typeof tag === `string` && temporalTypes.has(tag)
42+
}
2143

2244
// Maximum byte length for Uint8Arrays to hash by content instead of reference
2345
// Arrays smaller than this will be hashed by content, allowing proper equality comparisons
@@ -59,6 +81,8 @@ function hashObject(input: object): number {
5981
} else if (input instanceof File) {
6082
// Files are always hashed by reference due to their potentially large size
6183
return cachedReferenceHash(input)
84+
} else if (isTemporal(input)) {
85+
valueHash = hashTemporal(input)
6286
} else {
6387
let plainObjectInput = input
6488
let marker = OBJECT_MARKER
@@ -103,6 +127,14 @@ function hashUint8Array(input: Uint8Array): number {
103127
return hasher.digest()
104128
}
105129

130+
function hashTemporal(input: TemporalLike): number {
131+
const hasher = new MurmurHashStream()
132+
hasher.update(TEMPORAL_MARKER)
133+
hasher.update(input[Symbol.toStringTag])
134+
hasher.update(input.toString())
135+
return hasher.digest()
136+
}
137+
106138
function hashPlainObject(input: object, marker: number): number {
107139
const hasher = new MurmurHashStream()
108140

packages/db-ivm/tests/utils.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2+
import { Temporal } from 'temporal-polyfill'
23
import { DefaultMap } from '../src/utils.js'
34
import { hash } from '../src/hashing/index.js'
45

@@ -170,6 +171,41 @@ describe(`hash`, () => {
170171
expect(hash1).not.toBe(hash3) // Different dates should have different hash
171172
})
172173

174+
it(`should hash Temporal objects by value`, () => {
175+
const date1 = Temporal.PlainDate.from(`2024-01-15`)
176+
const date2 = Temporal.PlainDate.from(`2024-01-15`)
177+
const date3 = Temporal.PlainDate.from(`2024-06-15`)
178+
179+
const hash1 = hash(date1)
180+
const hash2 = hash(date2)
181+
const hash3 = hash(date3)
182+
183+
expect(typeof hash1).toBe(hashType)
184+
expect(hash1).toBe(hash2) // Same Temporal date should have same hash
185+
expect(hash1).not.toBe(hash3) // Different Temporal dates should have different hash
186+
187+
// Different Temporal types with overlapping string representations should differ
188+
const plainDate = Temporal.PlainDate.from(`2024-01-15`)
189+
const plainDateTime = Temporal.PlainDateTime.from(`2024-01-15T00:00:00`)
190+
191+
expect(hash(plainDate)).not.toBe(hash(plainDateTime))
192+
193+
// Other Temporal types should also hash correctly
194+
const time1 = Temporal.PlainTime.from(`10:30:00`)
195+
const time2 = Temporal.PlainTime.from(`10:30:00`)
196+
const time3 = Temporal.PlainTime.from(`14:00:00`)
197+
198+
expect(hash(time1)).toBe(hash(time2))
199+
expect(hash(time1)).not.toBe(hash(time3))
200+
201+
const instant1 = Temporal.Instant.from(`2024-01-15T00:00:00Z`)
202+
const instant2 = Temporal.Instant.from(`2024-01-15T00:00:00Z`)
203+
const instant3 = Temporal.Instant.from(`2024-06-15T00:00:00Z`)
204+
205+
expect(hash(instant1)).toBe(hash(instant2))
206+
expect(hash(instant1)).not.toBe(hash(instant3))
207+
})
208+
173209
it(`should hash RegExp objects`, () => {
174210
const regex1 = /test/g
175211
const regex2 = /test/g

packages/db/src/utils.ts

Lines changed: 12 additions & 9 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,19 @@ const temporalTypes = [
220220
`Temporal.PlainTime`,
221221
`Temporal.PlainYearMonth`,
222222
`Temporal.ZonedDateTime`,
223-
]
223+
])
224224

225-
function getStringTag(a: any): any {
226-
return a[Symbol.toStringTag]
225+
export interface TemporalLike {
226+
[Symbol.toStringTag]: string
227+
toString: () => string
228+
equals?: (other: unknown) => boolean
227229
}
228230

229231
/** Checks if the value is a Temporal object by checking for the Temporal brand */
230-
export function isTemporal(a: any): boolean {
231-
const tag = getStringTag(a)
232-
return typeof tag === `string` && temporalTypes.includes(tag)
232+
export function isTemporal(a: unknown): a is TemporalLike {
233+
if (a == null || typeof a !== `object`) return false
234+
const tag = (a as Record<symbol, unknown>)[Symbol.toStringTag]
235+
return typeof tag === `string` && temporalTypes.has(tag)
233236
}
234237

235238
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 =

packages/db/tests/query/join.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { beforeEach, describe, expect, test } from 'vitest'
2+
import { Temporal } from 'temporal-polyfill'
23
import {
34
concat,
45
createLiveQueryCollection,
56
eq,
67
gt,
8+
inArray,
79
isNull,
810
isUndefined,
911
lt,
@@ -12,6 +14,7 @@ import {
1214
} from '../../src/query/index.js'
1315
import { createCollection } from '../../src/collection/index.js'
1416
import {
17+
flushPromises,
1518
mockSyncCollectionOptions,
1619
mockSyncCollectionOptionsNoInitialState,
1720
} from '../utils.js'
@@ -2022,6 +2025,76 @@ function createJoinTests(autoIndex: `off` | `eager`): void {
20222025
chainedJoinQuery.toArray.every((r) => r.balance_amount !== undefined),
20232026
).toBe(true)
20242027
})
2028+
2029+
// Regression test for https://github.com/TanStack/db/issues/1367
2030+
// Temporal objects (PlainDate, ZonedDateTime, etc.) have no enumerable own
2031+
// properties, so Object.keys() returns []. Without special handling in the
2032+
// hash function, all Temporal instances produce identical hashes, causing the
2033+
// IVM join Index to treat old and new rows as equal and silently swallow updates.
2034+
test(`join should propagate Temporal field updates through live queries`, async () => {
2035+
type Task = {
2036+
id: number
2037+
name: string
2038+
project_id: number
2039+
dueDate: Temporal.PlainDate
2040+
}
2041+
2042+
type Project = {
2043+
id: number
2044+
name: string
2045+
}
2046+
2047+
const taskCollection = createCollection(
2048+
mockSyncCollectionOptions<Task>({
2049+
id: `test-temporal-join-${autoIndex}`,
2050+
getKey: (task) => task.id,
2051+
initialData: [
2052+
{
2053+
id: 1,
2054+
name: `Task A`,
2055+
project_id: 10,
2056+
dueDate: Temporal.PlainDate.from(`2024-01-15`),
2057+
},
2058+
],
2059+
autoIndex,
2060+
}),
2061+
)
2062+
2063+
const projectCollection = createCollection(
2064+
mockSyncCollectionOptions<Project>({
2065+
id: `test-temporal-join-projects-${autoIndex}`,
2066+
getKey: (project) => project.id,
2067+
initialData: [{ id: 10, name: `Project Alpha` }],
2068+
autoIndex,
2069+
}),
2070+
)
2071+
2072+
const liveQuery = createLiveQueryCollection({
2073+
startSync: true,
2074+
query: (q) =>
2075+
q
2076+
.from({ task: taskCollection })
2077+
.where(({ task }) => inArray(task.id, [1]))
2078+
.innerJoin({ project: projectCollection }, ({ task, project }) =>
2079+
eq(task.project_id, project.id),
2080+
)
2081+
.select(({ task, project }) => ({
2082+
task,
2083+
project,
2084+
})),
2085+
})
2086+
2087+
await liveQuery.preload()
2088+
expect(liveQuery.toArray).toHaveLength(1)
2089+
expect(String(liveQuery.toArray[0]!.task.dueDate)).toBe(`2024-01-15`)
2090+
2091+
taskCollection.update(1, (draft: Task) => {
2092+
draft.dueDate = Temporal.PlainDate.from(`2024-06-15`)
2093+
})
2094+
await flushPromises()
2095+
2096+
expect(String(liveQuery.toArray[0]!.task.dueDate)).toBe(`2024-06-15`)
2097+
})
20252098
}
20262099

20272100
describe(`Query JOIN Operations`, () => {

pnpm-lock.yaml

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)