diff --git a/.changeset/fix-temporal-join-hashing.md b/.changeset/fix-temporal-join-hashing.md new file mode 100644 index 000000000..3b8e13b45 --- /dev/null +++ b/.changeset/fix-temporal-join-hashing.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +'@tanstack/db-ivm': patch +--- + +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. diff --git a/packages/db-ivm/package.json b/packages/db-ivm/package.json index b5e5d6833..ac529434e 100644 --- a/packages/db-ivm/package.json +++ b/packages/db-ivm/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@types/debug": "^4.1.12", - "@vitest/coverage-istanbul": "^3.2.4" + "@vitest/coverage-istanbul": "^3.2.4", + "temporal-polyfill": "^0.3.0" } } diff --git a/packages/db-ivm/src/hashing/hash.ts b/packages/db-ivm/src/hashing/hash.ts index d1b9d0a6a..813e4ed35 100644 --- a/packages/db-ivm/src/hashing/hash.ts +++ b/packages/db-ivm/src/hashing/hash.ts @@ -18,6 +18,28 @@ const ARRAY_MARKER = randomHash() const MAP_MARKER = randomHash() const SET_MARKER = randomHash() const UINT8ARRAY_MARKER = randomHash() +const TEMPORAL_MARKER = randomHash() + +const temporalTypes = new Set([ + `Temporal.Duration`, + `Temporal.Instant`, + `Temporal.PlainDate`, + `Temporal.PlainDateTime`, + `Temporal.PlainMonthDay`, + `Temporal.PlainTime`, + `Temporal.PlainYearMonth`, + `Temporal.ZonedDateTime`, +]) + +interface TemporalLike { + [Symbol.toStringTag]: string + toString: () => string +} + +function isTemporal(input: object): input is TemporalLike { + const tag = (input as Record)[Symbol.toStringTag] + return typeof tag === `string` && temporalTypes.has(tag) +} // 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 +81,8 @@ 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)) { + valueHash = hashTemporal(input) } else { let plainObjectInput = input let marker = OBJECT_MARKER @@ -103,6 +127,14 @@ function hashUint8Array(input: Uint8Array): number { return hasher.digest() } +function hashTemporal(input: TemporalLike): number { + const hasher = new MurmurHashStream() + hasher.update(TEMPORAL_MARKER) + hasher.update(input[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..a3eb0685b 100644 --- a/packages/db-ivm/tests/utils.test.ts +++ b/packages/db-ivm/tests/utils.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { Temporal } from 'temporal-polyfill' import { DefaultMap } from '../src/utils.js' import { hash } from '../src/hashing/index.js' @@ -170,6 +171,41 @@ describe(`hash`, () => { expect(hash1).not.toBe(hash3) // Different dates should have different hash }) + it(`should hash Temporal objects by value`, () => { + const date1 = Temporal.PlainDate.from(`2024-01-15`) + const date2 = Temporal.PlainDate.from(`2024-01-15`) + const date3 = Temporal.PlainDate.from(`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 = Temporal.PlainDate.from(`2024-01-15`) + const plainDateTime = Temporal.PlainDateTime.from(`2024-01-15T00:00:00`) + + expect(hash(plainDate)).not.toBe(hash(plainDateTime)) + + // Other Temporal types should also hash correctly + const time1 = Temporal.PlainTime.from(`10:30:00`) + const time2 = Temporal.PlainTime.from(`10:30:00`) + const time3 = Temporal.PlainTime.from(`14:00:00`) + + expect(hash(time1)).toBe(hash(time2)) + expect(hash(time1)).not.toBe(hash(time3)) + + const instant1 = Temporal.Instant.from(`2024-01-15T00:00:00Z`) + const instant2 = Temporal.Instant.from(`2024-01-15T00:00:00Z`) + const instant3 = Temporal.Instant.from(`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/src/utils.ts b/packages/db/src/utils.ts index 00292e37a..e65208741 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -144,8 +144,8 @@ function deepEqualsInternal( // Handle Temporal objects // Check if both are Temporal objects of the same type if (isTemporal(a) && isTemporal(b)) { - const aTag = getStringTag(a) - const bTag = getStringTag(b) + const aTag = a[Symbol.toStringTag] + const bTag = b[Symbol.toStringTag] // If they're different Temporal types, they're not equal if (aTag !== bTag) return false @@ -211,7 +211,7 @@ function deepEqualsInternal( return false } -const temporalTypes = [ +const temporalTypes = new Set([ `Temporal.Duration`, `Temporal.Instant`, `Temporal.PlainDate`, @@ -220,16 +220,19 @@ const temporalTypes = [ `Temporal.PlainTime`, `Temporal.PlainYearMonth`, `Temporal.ZonedDateTime`, -] +]) -function getStringTag(a: any): any { - return a[Symbol.toStringTag] +export interface TemporalLike { + [Symbol.toStringTag]: string + toString: () => string + equals?: (other: unknown) => boolean } /** Checks if the value is a Temporal object by checking for the Temporal brand */ -export function isTemporal(a: any): boolean { - const tag = getStringTag(a) - return typeof tag === `string` && temporalTypes.includes(tag) +export function isTemporal(a: unknown): a is TemporalLike { + if (a == null || typeof a !== `object`) return false + const tag = (a as Record)[Symbol.toStringTag] + return typeof tag === `string` && temporalTypes.has(tag) } export const DEFAULT_COMPARE_OPTIONS: CompareOptions = { diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index 650642b55..bf5ac1a91 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -1,3 +1,4 @@ +import { isTemporal } from '../utils' import type { CompareOptions } from '../query/builder/types' // WeakMap to store stable IDs for objects @@ -54,6 +55,15 @@ 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 (isTemporal(a) && isTemporal(b)) { + const aStr = a.toString() + const bStr = b.toString() + if (aStr < bStr) return -1 + if (aStr > bStr) return 1 + return 0 + } + // If at least one of the values is an object, use stable IDs for comparison const aIsObject = typeof a === `object` const bIsObject = typeof b === `object` @@ -154,6 +164,10 @@ export function normalizeValue(value: any): any { return value.getTime() } + if (isTemporal(value)) { + return `__temporal__${value[Symbol.toStringTag]}__${value.toString()}` + } + // Normalize Uint8Arrays/Buffers to a string representation for Map key usage // This enables content-based equality for binary data like ULIDs const isUint8Array = diff --git a/packages/db/tests/query/join.test.ts b/packages/db/tests/query/join.test.ts index 0219cdaf2..41be9605f 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,76 @@ 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(String(liveQuery.toArray[0]!.task.dueDate)).toBe(`2024-01-15`) + + taskCollection.update(1, (draft: Task) => { + draft.dueDate = Temporal.PlainDate.from(`2024-06-15`) + }) + await flushPromises() + + expect(String(liveQuery.toArray[0]!.task.dueDate)).toBe(`2024-06-15`) + }) } describe(`Query JOIN Operations`, () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e09a11881..64962845c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -667,7 +667,7 @@ importers: version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(gel@2.1.1)(kysely@0.28.11)(pg@8.19.0)(postgres@3.4.8) drizzle-zod: specifier: ^0.8.3 - version: 0.8.3(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(gel@2.1.1)(kysely@0.28.11)(pg@8.19.0)(postgres@3.4.8))(zod@4.3.6) + version: 0.8.3(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(gel@2.1.1)(kysely@0.28.11)(pg@8.19.0)(postgres@3.4.8))(zod@3.25.76) express: specifier: ^5.2.1 version: 5.2.1 @@ -858,6 +858,9 @@ importers: '@vitest/coverage-istanbul': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) + temporal-polyfill: + specifier: ^0.3.0 + version: 0.3.0 packages/electric-db-collection: dependencies: @@ -16132,6 +16135,11 @@ snapshots: pg: 8.19.0 postgres: 3.4.8 + drizzle-zod@0.8.3(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(gel@2.1.1)(kysely@0.28.11)(pg@8.19.0)(postgres@3.4.8))(zod@3.25.76): + dependencies: + drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(gel@2.1.1)(kysely@0.28.11)(pg@8.19.0)(postgres@3.4.8) + zod: 3.25.76 + drizzle-zod@0.8.3(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(gel@2.1.1)(kysely@0.28.11)(pg@8.19.0)(postgres@3.4.8))(zod@4.3.6): dependencies: drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(gel@2.1.1)(kysely@0.28.11)(pg@8.19.0)(postgres@3.4.8)