From 5dd91ac22753451f42c279bd0bf125eb0489d730 Mon Sep 17 00:00:00 2001 From: Ben Guericke Date: Fri, 13 Mar 2026 11:34:59 -0600 Subject: [PATCH] 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 https://github.com/TanStack/db/issues/1367 Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-temporal-hash.md | 5 ++ packages/db-ivm/src/hashing/hash.ts | 30 +++++++++++ packages/db-ivm/tests/utils.test.ts | 56 ++++++++++++++++++++ packages/db/tests/query/join.test.ts | 77 ++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 .changeset/fix-temporal-hash.md diff --git a/.changeset/fix-temporal-hash.md b/.changeset/fix-temporal-hash.md new file mode 100644 index 000000000..97efec3f3 --- /dev/null +++ b/.changeset/fix-temporal-hash.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db-ivm': patch +--- + +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. diff --git a/packages/db-ivm/src/hashing/hash.ts b/packages/db-ivm/src/hashing/hash.ts index d1b9d0a6a..71dd76de6 100644 --- a/packages/db-ivm/src/hashing/hash.ts +++ b/packages/db-ivm/src/hashing/hash.ts @@ -18,6 +18,7 @@ const ARRAY_MARKER = randomHash() const MAP_MARKER = randomHash() const SET_MARKER = randomHash() const UINT8ARRAY_MARKER = randomHash() +const TEMPORAL_MARKER = randomHash() // Maximum byte length for Uint8Arrays to hash by content instead of reference // Arrays smaller than this will be hashed by content, allowing proper equality comparisons @@ -59,6 +60,11 @@ function hashObject(input: object): number { } else if (input instanceof File) { // Files are always hashed by reference due to their potentially large size return cachedReferenceHash(input) + } else if (isTemporal(input)) { + // Temporal objects (PlainDate, ZonedDateTime, etc.) have no enumerable own + // properties, so Object.keys() returns [] and hashPlainObject would produce + // identical hashes for all instances. Hash by toString() instead. + valueHash = hashTemporal(input) } else { let plainObjectInput = input let marker = OBJECT_MARKER @@ -103,6 +109,30 @@ function hashUint8Array(input: Uint8Array): number { return hasher.digest() } +const temporalTypes = [ + `Temporal.PlainDate`, + `Temporal.PlainTime`, + `Temporal.PlainDateTime`, + `Temporal.PlainYearMonth`, + `Temporal.PlainMonthDay`, + `Temporal.ZonedDateTime`, + `Temporal.Instant`, + `Temporal.Duration`, +] + +function isTemporal(input: object): boolean { + const tag = (input as any)[Symbol.toStringTag] + return typeof tag === `string` && temporalTypes.includes(tag) +} + +function hashTemporal(input: object): number { + const hasher = new MurmurHashStream() + hasher.update(TEMPORAL_MARKER) + hasher.update((input as any)[Symbol.toStringTag]) + hasher.update(input.toString()) + return hasher.digest() +} + function hashPlainObject(input: object, marker: number): number { const hasher = new MurmurHashStream() diff --git a/packages/db-ivm/tests/utils.test.ts b/packages/db-ivm/tests/utils.test.ts index 9f6986577..6ec3b127f 100644 --- a/packages/db-ivm/tests/utils.test.ts +++ b/packages/db-ivm/tests/utils.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it } from 'vitest' import { DefaultMap } from '../src/utils.js' import { hash } from '../src/hashing/index.js' +// Minimal mock that mimics Temporal objects: Symbol.toStringTag + toString() +// without requiring the temporal-polyfill dependency. +function createTemporalLike(tag: string, value: string) { + return Object.create(null, { + [Symbol.toStringTag]: { value: tag }, + toString: { value: () => value }, + }) +} + describe(`DefaultMap`, () => { it(`should return default value for missing keys`, () => { const map = new DefaultMap(() => 0) @@ -170,6 +179,53 @@ describe(`hash`, () => { expect(hash1).not.toBe(hash3) // Different dates should have different hash }) + it(`should hash Temporal objects by value`, () => { + const date1 = createTemporalLike(`Temporal.PlainDate`, `2024-01-15`) + const date2 = createTemporalLike(`Temporal.PlainDate`, `2024-01-15`) + const date3 = createTemporalLike(`Temporal.PlainDate`, `2024-06-15`) + + const hash1 = hash(date1) + const hash2 = hash(date2) + const hash3 = hash(date3) + + expect(typeof hash1).toBe(hashType) + expect(hash1).toBe(hash2) // Same Temporal date should have same hash + expect(hash1).not.toBe(hash3) // Different Temporal dates should have different hash + + // Different Temporal types with overlapping string representations should differ + const plainDate = createTemporalLike(`Temporal.PlainDate`, `2024-01-15`) + const plainDateTime = createTemporalLike( + `Temporal.PlainDateTime`, + `2024-01-15T00:00:00`, + ) + + expect(hash(plainDate)).not.toBe(hash(plainDateTime)) + + // Other Temporal types should also hash correctly + const time1 = createTemporalLike(`Temporal.PlainTime`, `10:30:00`) + const time2 = createTemporalLike(`Temporal.PlainTime`, `10:30:00`) + const time3 = createTemporalLike(`Temporal.PlainTime`, `14:00:00`) + + expect(hash(time1)).toBe(hash(time2)) + expect(hash(time1)).not.toBe(hash(time3)) + + const instant1 = createTemporalLike( + `Temporal.Instant`, + `2024-01-15T00:00:00Z`, + ) + const instant2 = createTemporalLike( + `Temporal.Instant`, + `2024-01-15T00:00:00Z`, + ) + const instant3 = createTemporalLike( + `Temporal.Instant`, + `2024-06-15T00:00:00Z`, + ) + + expect(hash(instant1)).toBe(hash(instant2)) + expect(hash(instant1)).not.toBe(hash(instant3)) + }) + it(`should hash RegExp objects`, () => { const regex1 = /test/g const regex2 = /test/g diff --git a/packages/db/tests/query/join.test.ts b/packages/db/tests/query/join.test.ts index 0219cdaf2..07cc92a76 100644 --- a/packages/db/tests/query/join.test.ts +++ b/packages/db/tests/query/join.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, test } from 'vitest' +import { Temporal } from 'temporal-polyfill' import { concat, createLiveQueryCollection, eq, gt, + inArray, isNull, isUndefined, lt, @@ -12,6 +14,7 @@ import { } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { + flushPromises, mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState, } from '../utils.js' @@ -2022,6 +2025,80 @@ function createJoinTests(autoIndex: `off` | `eager`): void { chainedJoinQuery.toArray.every((r) => r.balance_amount !== undefined), ).toBe(true) }) + + // Regression test for https://github.com/TanStack/db/issues/1367 + // Temporal objects (PlainDate, ZonedDateTime, etc.) have no enumerable own + // properties, so Object.keys() returns []. Without special handling in the + // hash function, all Temporal instances produce identical hashes, causing the + // IVM join Index to treat old and new rows as equal and silently swallow updates. + test(`join should propagate Temporal field updates through live queries`, async () => { + type Task = { + id: number + name: string + project_id: number + dueDate: Temporal.PlainDate + } + + type Project = { + id: number + name: string + } + + const taskCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-temporal-join-${autoIndex}`, + getKey: (task) => task.id, + initialData: [ + { + id: 1, + name: `Task A`, + project_id: 10, + dueDate: Temporal.PlainDate.from(`2024-01-15`), + }, + ], + autoIndex, + }), + ) + + const projectCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-temporal-join-projects-${autoIndex}`, + getKey: (project) => project.id, + initialData: [{ id: 10, name: `Project Alpha` }], + autoIndex, + }), + ) + + const liveQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ task: taskCollection }) + .where(({ task }) => inArray(task.id, [1])) + .innerJoin({ project: projectCollection }, ({ task, project }) => + eq(task.project_id, project.id), + ) + .select(({ task, project }) => ({ + task, + project, + })), + }) + + await liveQuery.preload() + expect(liveQuery.toArray).toHaveLength(1) + expect( + (liveQuery.toArray[0]!.task.dueDate as Temporal.PlainDate).toString(), + ).toBe(`2024-01-15`) + + taskCollection.update(1, (draft) => { + ;(draft as any).dueDate = Temporal.PlainDate.from(`2024-06-15`) + }) + await flushPromises() + + expect( + (liveQuery.toArray[0]!.task.dueDate as Temporal.PlainDate).toString(), + ).toBe(`2024-06-15`) + }) } describe(`Query JOIN Operations`, () => {