Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/fix-temporal-hash.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 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,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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Comment on lines +112 to +132
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd -t f "hash.ts" --path "*/db-ivm/*"

Repository: TanStack/db

Length of output: 288


🏁 Script executed:

cat -n packages/db-ivm/src/hashing/hash.ts | sed -n '100,150p'

Repository: TanStack/db

Length of output: 1804


🏁 Script executed:

cat -n packages/db-ivm/src/hashing/hash.ts | sed -n '1,50p'

Repository: TanStack/db

Length of output: 2053


🏁 Script executed:

rg "isTemporal|hashTemporal" packages/db-ivm/src/hashing/hash.ts -A 2 -B 2

Repository: TanStack/db

Length of output: 806


🏁 Script executed:

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

Repository: TanStack/db

Length of output: 94


🏁 Script executed:

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

Repository: TanStack/db

Length of output: 7580


🏁 Script executed:

node <<'EOF'
// Test if null-prototype objects have toString
const nullProtoObj = Object.create(null);
nullProtoObj[Symbol.toStringTag] = "Temporal.PlainDate";

console.log("Has toString?", "toString" in nullProtoObj);
console.log("Type of toString:", typeof nullProtoObj.toString);

try {
  const result = nullProtoObj.toString();
  console.log("toString() succeeded:", result);
} catch (e) {
  console.log("toString() threw:", e.message);
}

// Test with Object.prototype
const normalObj = {};
normalObj[Symbol.toStringTag] = "Temporal.PlainDate";
console.log("\n=== Normal object ===");
console.log("Has toString?", "toString" in normalObj);
console.log("Type of toString:", typeof normalObj.toString);
console.log("toString() result:", normalObj.toString());
EOF

Repository: TanStack/db

Length of output: 276


🏁 Script executed:

rg "function hash|hash\(" packages/db-ivm/src/hashing/hash.ts -B 2 -A 5 | head -80

Repository: TanStack/db

Length of output: 1276


🏁 Script executed:

cat -n packages/db-ivm/src/hashing/hash.ts | sed -n '65,112p'

Repository: TanStack/db

Length of output: 1640


🏁 Script executed:

cat -n packages/db-ivm/src/hashing/hash.ts | sed -n '133,200p'

Repository: TanStack/db

Length of output: 2221


🏁 Script executed:

rg "import.*hash|from.*hash" packages/db-ivm/src --type ts --type tsx | head -20

Repository: TanStack/db

Length of output: 82


🏁 Script executed:

rg "\bhash\(" packages/db-ivm/src --type ts --type tsx -B 1 -A 1 | head -40

Repository: TanStack/db

Length of output: 82


🏁 Script executed:

cd packages/db-ivm && grep -r "from.*hash" src --include="*.ts" --include="*.tsx"

Repository: TanStack/db

Length of output: 428


🏁 Script executed:

cd packages/db-ivm && rg "hash\(" src --type ts -B 2 -A 2 | head -80

Repository: TanStack/db

Length of output: 3975


🏁 Script executed:

node <<'EOF'
// Verify the proposed type guard works

type TemporalLike = {
  [Symbol.toStringTag]: string
  toString(): string
}

function isTemporal(input: object): boolean {
  const temporalInput = input as {
    [Symbol.toStringTag]?: unknown
    toString?: unknown
  }
  const tag = temporalInput[Symbol.toStringTag]
  return (
    typeof tag === `string` &&
    ["Temporal.PlainDate", "Temporal.PlainTime"].includes(tag) &&
    typeof temporalInput.toString === `function`
  )
}

// Test 1: null-proto with spoofed tag - should fail
const nullProtoFake = Object.create(null);
nullProtoFake[Symbol.toStringTag] = "Temporal.PlainDate";
console.log("nullProtoFake passes isTemporal?", isTemporal(nullProtoFake));

// Test 2: Normal object with Temporal tag - should pass
const normalTemporal = { [Symbol.toStringTag]: "Temporal.PlainDate" };
console.log("normalTemporal passes isTemporal?", isTemporal(normalTemporal));

// Test 3: null-proto with fake toString
const nullProtoWithFunc = Object.create(null);
nullProtoWithFunc[Symbol.toStringTag] = "Temporal.PlainDate";
nullProtoWithFunc.toString = function() { return "test"; };
console.log("nullProtoWithFunc passes isTemporal?", isTemporal(nullProtoWithFunc));
if (isTemporal(nullProtoWithFunc)) {
  console.log("nullProtoWithFunc.toString():", nullProtoWithFunc.toString());
}
EOF

Repository: TanStack/db

Length of output: 209


🏁 Script executed:

grep -n "as any" packages/db-ivm/src/hashing/hash.ts

Repository: TanStack/db

Length of output: 163


🏁 Script executed:

cat -n packages/db-ivm/src/hashing/hash.ts | sed -n '100,140p'

Repository: TanStack/db

Length of output: 1514


🏁 Script executed:

rg "function hashTemporal|function hash\(" packages/db-ivm/src/hashing/hash.ts -A 8

Repository: TanStack/db

Length of output: 619


Guard the Temporal branch before calling toString().

isTemporal() currently only checks Symbol.toStringTag. A null-prototype object that spoofs Temporal.PlainDate (e.g., via Object.create(null)) will pass the tag check but throw when hashTemporal() calls toString() on line 132, since null-prototype objects do not inherit toString. Replace the any casts with unknown and add a type guard to verify toString is callable before hashing:

Safer type-guarding for the Temporal path
+type TemporalLike = {
+  [Symbol.toStringTag]: string
+  toString(): string
+}
+
 function isTemporal(input: object): boolean {
-  const tag = (input as any)[Symbol.toStringTag]
-  return typeof tag === `string` && temporalTypes.includes(tag)
+  const temporalInput = input as {
+    [Symbol.toStringTag]?: unknown
+    toString?: unknown
+  }
+  const tag = temporalInput[Symbol.toStringTag]
+  return (
+    typeof tag === `string` &&
+    temporalTypes.includes(tag) &&
+    typeof temporalInput.toString === `function`
+  )
 }
 
-function hashTemporal(input: object): number {
+function hashTemporal(input: TemporalLike): number {
   const hasher = new MurmurHashStream()
   hasher.update(TEMPORAL_MARKER)
-  hasher.update((input as any)[Symbol.toStringTag])
+  hasher.update(input[Symbol.toStringTag])
   hasher.update(input.toString())
   return hasher.digest()
 }

Per coding guidelines: "Avoid using any types; use unknown instead when type is truly unknown and provide type guards to narrow safely."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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())
type TemporalLike = {
[Symbol.toStringTag]: string
toString(): string
}
const temporalTypes = [
`Temporal.PlainDate`,
`Temporal.PlainTime`,
`Temporal.PlainDateTime`,
`Temporal.PlainYearMonth`,
`Temporal.PlainMonthDay`,
`Temporal.ZonedDateTime`,
`Temporal.Instant`,
`Temporal.Duration`,
]
function isTemporal(input: object): boolean {
const temporalInput = input as {
[Symbol.toStringTag]?: unknown
toString?: unknown
}
const tag = temporalInput[Symbol.toStringTag]
return (
typeof tag === `string` &&
temporalTypes.includes(tag) &&
typeof temporalInput.toString === `function`
)
}
function hashTemporal(input: TemporalLike): number {
const hasher = new MurmurHashStream()
hasher.update(TEMPORAL_MARKER)
hasher.update(input[Symbol.toStringTag])
hasher.update(input.toString())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db-ivm/src/hashing/hash.ts` around lines 112 - 132, isTemporal
currently only checks Symbol.toStringTag and hashTemporal casts input to any
then calls toString(), which can throw for null-prototype objects; update both
functions to use unknown instead of any, keep temporalTypes and the tag check,
and in hashTemporal (which uses TEMPORAL_MARKER and MurmurHashStream) add a type
guard to verify (input as unknown) has a callable toString method before calling
input.toString(); if toString is not callable, fallback to a safe string
representation (e.g., String(input) or include the tag only) to avoid throwing.

return hasher.digest()
}

function hashPlainObject(input: object, marker: number): number {
const hasher = new MurmurHashStream()

Expand Down
56 changes: 56 additions & 0 deletions packages/db-ivm/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
})
}
Comment on lines +7 to +12
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "utils.test.ts" | grep -E "db-ivm|packages"

Repository: TanStack/db

Length of output: 126


🏁 Script executed:

cd packages/db-ivm && git ls-files | grep -E "test|utils"

Repository: TanStack/db

Length of output: 1090


🏁 Script executed:

# Get the file and check its existence and size
wc -l packages/db-ivm/tests/utils.test.ts 2>/dev/null || echo "File not found, trying alternative paths"
find . -type f -name "utils.test.ts" -path "*db-ivm*"

Repository: TanStack/db

Length of output: 132


🏁 Script executed:

find . -type f -name "*.test.ts" 2>/dev/null | head -20

Repository: TanStack/db

Length of output: 1072


🏁 Script executed:

# Look for db-ivm directory structure
fd -t d db-ivm 2>/dev/null

Repository: TanStack/db

Length of output: 71


🏁 Script executed:

# List top-level packages
ls -la packages/ 2>/dev/null || echo "packages dir not found"

Repository: TanStack/db

Length of output: 1120


🏁 Script executed:

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

Repository: TanStack/db

Length of output: 975


🏁 Script executed:

sed -n '1,20p' packages/db-ivm/tests/utils.test.ts

Repository: TanStack/db

Length of output: 729


🏁 Script executed:

sed -n '190,210p' packages/db-ivm/tests/utils.test.ts

Repository: TanStack/db

Length of output: 984


🏁 Script executed:

# Verify what hashTemporal actually does - search for the hash function implementation
rg -A 10 "hashTemporal|Symbol.toStringTag" packages/db-ivm/src --type ts

Repository: TanStack/db

Length of output: 1887


🏁 Script executed:

# Check if there are any other uses of createTemporalLike to understand the broader context
rg "createTemporalLike" packages/db-ivm/tests/utils.test.ts -B 2 -A 2

Repository: TanStack/db

Length of output: 1619


Add explicit return type to helper and tighten cross-type test.

The createTemporalLike() helper should have an explicit return type since Object.create() returns any by default. Additionally, the PlainDate/PlainDateTime cross-type test uses different string values (2024-01-15 vs 2024-01-15T00:00:00), so it would pass even if hashTemporal() only hashes the string and ignores Symbol.toStringTag. Since both the tag and string are hashed in hashTemporal(), use the same string value for both mocks to ensure the test specifically validates that different tags produce different hashes.

Typed helper and isolated tag-checking test
+type TemporalLike = {
+  readonly [Symbol.toStringTag]: string
+  toString(): string
+}
+
-function createTemporalLike(tag: string, value: string) {
+function createTemporalLike(tag: string, value: string): TemporalLike {
   return Object.create(null, {
     [Symbol.toStringTag]: { value: tag },
     toString: { value: () => value },
-  })
+  }) as TemporalLike
 }
@@
-      const plainDate = createTemporalLike(`Temporal.PlainDate`, `2024-01-15`)
+      const sharedValue = `same-value`
+      const plainDate = createTemporalLike(`Temporal.PlainDate`, sharedValue)
       const plainDateTime = createTemporalLike(
         `Temporal.PlainDateTime`,
-        `2024-01-15T00:00:00`,
+        sharedValue,
       )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db-ivm/tests/utils.test.ts` around lines 7 - 12, Add an explicit
return type to the createTemporalLike helper (so Object.create(...) doesn’t
infer any) and update the PlainDate/PlainDateTime cross-type test that calls
hashTemporal() so both mocks use the identical string value for toString()
(e.g., same date/time string) while only differing in [Symbol.toStringTag]; this
ensures hashTemporal()’s use of both the tag and string is actually validated.
Update createTemporalLike’s signature and the test that compares hashes for
PlainDate vs PlainDateTime (and optionally add a focused tag-only case) to
ensure the test fails if tags are ignored.


describe(`DefaultMap`, () => {
it(`should return default value for missing keys`, () => {
const map = new DefaultMap(() => 0)
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 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,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<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(
(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`, () => {
Expand Down