Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-temporal-join-hashing.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/db-ivm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
32 changes: 32 additions & 0 deletions packages/db-ivm/src/hashing/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>)[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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
36 changes: 36 additions & 0 deletions packages/db-ivm/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions packages/db/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,7 +211,7 @@ function deepEqualsInternal(
return false
}

const temporalTypes = [
const temporalTypes = new Set([
`Temporal.Duration`,
`Temporal.Instant`,
`Temporal.PlainDate`,
Expand All @@ -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, unknown>)[Symbol.toStringTag]
return typeof tag === `string` && temporalTypes.has(tag)
}

export const DEFAULT_COMPARE_OPTIONS: CompareOptions = {
Expand Down
14 changes: 14 additions & 0 deletions packages/db/src/utils/comparison.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isTemporal } from '../utils'
import type { CompareOptions } from '../query/builder/types'

// WeakMap to store stable IDs for objects
Expand Down Expand Up @@ -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
}
Comment on lines +58 to +65
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use Temporal compare semantics instead of raw toString() ordering.
Line 58-Line 65 can misorder chronological values (e.g., Temporal.ZonedDateTime across different zones/offsets). It also doesn’t enforce same-type comparison despite the comment.

💡 Proposed fix
   // 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
+    const aTag = a[Symbol.toStringTag]
+    const bTag = b[Symbol.toStringTag]
+
+    // Prefer Temporal's native compare when both values share the same constructor/type.
+    if (aTag === bTag && a.constructor === b.constructor) {
+      const compare = (a.constructor as { compare?: (x: unknown, y: unknown) => number }).compare
+      if (typeof compare === `function`) {
+        return compare(a, b)
+      }
+    }
+
+    // Deterministic fallback for mixed Temporal types or missing compare().
+    if (aTag < bTag) return -1
+    if (aTag > bTag) return 1
+    const aStr = a.toString()
+    const bStr = b.toString()
+    if (aStr < bStr) return -1
+    if (aStr > bStr) return 1
+    return 0
   }
For JavaScript Temporal, can lexicographic ordering of `toString()` differ from `Temporal.ZonedDateTime.compare(a, b)` for values in different time zones/offsets? Provide a concrete example.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/utils/comparison.ts` around lines 58 - 65, The Temporal
branch currently compares by toString(), which can misorder values (especially
ZonedDateTime across zones) and does not enforce same-type comparison; change it
so when isTemporal(a) && isTemporal(b) and they are the same Temporal type
(e.g., a.constructor === b.constructor or compare constructor names), call the
appropriate Temporal compare method for that type (e.g.,
Temporal.ZonedDateTime.compare(a,b), Temporal.PlainDateTime.compare(a,b),
Temporal.PlainDate.compare(a,b), Temporal.PlainTime.compare(a,b), etc.) and
return the compare result normalized to -1/0/1; if the types differ fall back to
the existing non-Temporal logic (or a stable tie-breaker) rather than comparing
toString().


// 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`
Expand Down Expand Up @@ -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 =
Expand Down
73 changes: 73 additions & 0 deletions packages/db/tests/query/join.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,6 +14,7 @@ import {
} from '../../src/query/index.js'
import { createCollection } from '../../src/collection/index.js'
import {
flushPromises,
mockSyncCollectionOptions,
mockSyncCollectionOptionsNoInitialState,
} from '../utils.js'
Expand Down Expand Up @@ -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<Task>({
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<Project>({
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`, () => {
Expand Down
10 changes: 9 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading