Skip to content

Commit 5dd91ac

Browse files
goatrenterguyclaude
andcommitted
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]>
1 parent 1270156 commit 5dd91ac

4 files changed

Lines changed: 168 additions & 0 deletions

File tree

.changeset/fix-temporal-hash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db-ivm': patch
3+
---
4+
5+
Fix Temporal objects (PlainDate, ZonedDateTime, etc.) producing identical hashes in the IVM hash function. Temporal objects have no enumerable own properties, so Object.keys() returns [] and all instances were hashed identically. This caused join live queries to silently swallow updates when only a Temporal field changed. Temporal objects are now hashed by their type tag and string representation.

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const ARRAY_MARKER = randomHash()
1818
const MAP_MARKER = randomHash()
1919
const SET_MARKER = randomHash()
2020
const UINT8ARRAY_MARKER = randomHash()
21+
const TEMPORAL_MARKER = randomHash()
2122

2223
// Maximum byte length for Uint8Arrays to hash by content instead of reference
2324
// Arrays smaller than this will be hashed by content, allowing proper equality comparisons
@@ -59,6 +60,11 @@ function hashObject(input: object): number {
5960
} else if (input instanceof File) {
6061
// Files are always hashed by reference due to their potentially large size
6162
return cachedReferenceHash(input)
63+
} else if (isTemporal(input)) {
64+
// Temporal objects (PlainDate, ZonedDateTime, etc.) have no enumerable own
65+
// properties, so Object.keys() returns [] and hashPlainObject would produce
66+
// identical hashes for all instances. Hash by toString() instead.
67+
valueHash = hashTemporal(input)
6268
} else {
6369
let plainObjectInput = input
6470
let marker = OBJECT_MARKER
@@ -103,6 +109,30 @@ function hashUint8Array(input: Uint8Array): number {
103109
return hasher.digest()
104110
}
105111

112+
const temporalTypes = [
113+
`Temporal.PlainDate`,
114+
`Temporal.PlainTime`,
115+
`Temporal.PlainDateTime`,
116+
`Temporal.PlainYearMonth`,
117+
`Temporal.PlainMonthDay`,
118+
`Temporal.ZonedDateTime`,
119+
`Temporal.Instant`,
120+
`Temporal.Duration`,
121+
]
122+
123+
function isTemporal(input: object): boolean {
124+
const tag = (input as any)[Symbol.toStringTag]
125+
return typeof tag === `string` && temporalTypes.includes(tag)
126+
}
127+
128+
function hashTemporal(input: object): number {
129+
const hasher = new MurmurHashStream()
130+
hasher.update(TEMPORAL_MARKER)
131+
hasher.update((input as any)[Symbol.toStringTag])
132+
hasher.update(input.toString())
133+
return hasher.digest()
134+
}
135+
106136
function hashPlainObject(input: object, marker: number): number {
107137
const hasher = new MurmurHashStream()
108138

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ import { describe, expect, it } from 'vitest'
22
import { DefaultMap } from '../src/utils.js'
33
import { hash } from '../src/hashing/index.js'
44

5+
// Minimal mock that mimics Temporal objects: Symbol.toStringTag + toString()
6+
// without requiring the temporal-polyfill dependency.
7+
function createTemporalLike(tag: string, value: string) {
8+
return Object.create(null, {
9+
[Symbol.toStringTag]: { value: tag },
10+
toString: { value: () => value },
11+
})
12+
}
13+
514
describe(`DefaultMap`, () => {
615
it(`should return default value for missing keys`, () => {
716
const map = new DefaultMap(() => 0)
@@ -170,6 +179,53 @@ describe(`hash`, () => {
170179
expect(hash1).not.toBe(hash3) // Different dates should have different hash
171180
})
172181

182+
it(`should hash Temporal objects by value`, () => {
183+
const date1 = createTemporalLike(`Temporal.PlainDate`, `2024-01-15`)
184+
const date2 = createTemporalLike(`Temporal.PlainDate`, `2024-01-15`)
185+
const date3 = createTemporalLike(`Temporal.PlainDate`, `2024-06-15`)
186+
187+
const hash1 = hash(date1)
188+
const hash2 = hash(date2)
189+
const hash3 = hash(date3)
190+
191+
expect(typeof hash1).toBe(hashType)
192+
expect(hash1).toBe(hash2) // Same Temporal date should have same hash
193+
expect(hash1).not.toBe(hash3) // Different Temporal dates should have different hash
194+
195+
// Different Temporal types with overlapping string representations should differ
196+
const plainDate = createTemporalLike(`Temporal.PlainDate`, `2024-01-15`)
197+
const plainDateTime = createTemporalLike(
198+
`Temporal.PlainDateTime`,
199+
`2024-01-15T00:00:00`,
200+
)
201+
202+
expect(hash(plainDate)).not.toBe(hash(plainDateTime))
203+
204+
// Other Temporal types should also hash correctly
205+
const time1 = createTemporalLike(`Temporal.PlainTime`, `10:30:00`)
206+
const time2 = createTemporalLike(`Temporal.PlainTime`, `10:30:00`)
207+
const time3 = createTemporalLike(`Temporal.PlainTime`, `14:00:00`)
208+
209+
expect(hash(time1)).toBe(hash(time2))
210+
expect(hash(time1)).not.toBe(hash(time3))
211+
212+
const instant1 = createTemporalLike(
213+
`Temporal.Instant`,
214+
`2024-01-15T00:00:00Z`,
215+
)
216+
const instant2 = createTemporalLike(
217+
`Temporal.Instant`,
218+
`2024-01-15T00:00:00Z`,
219+
)
220+
const instant3 = createTemporalLike(
221+
`Temporal.Instant`,
222+
`2024-06-15T00:00:00Z`,
223+
)
224+
225+
expect(hash(instant1)).toBe(hash(instant2))
226+
expect(hash(instant1)).not.toBe(hash(instant3))
227+
})
228+
173229
it(`should hash RegExp objects`, () => {
174230
const regex1 = /test/g
175231
const regex2 = /test/g

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

Lines changed: 77 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,80 @@ 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(
2090+
(liveQuery.toArray[0]!.task.dueDate as Temporal.PlainDate).toString(),
2091+
).toBe(`2024-01-15`)
2092+
2093+
taskCollection.update(1, (draft) => {
2094+
;(draft as any).dueDate = Temporal.PlainDate.from(`2024-06-15`)
2095+
})
2096+
await flushPromises()
2097+
2098+
expect(
2099+
(liveQuery.toArray[0]!.task.dueDate as Temporal.PlainDate).toString(),
2100+
).toBe(`2024-06-15`)
2101+
})
20252102
}
20262103

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

0 commit comments

Comments
 (0)