diff --git a/.changeset/thick-singers-appear.md b/.changeset/thick-singers-appear.md new file mode 100644 index 000000000..8e541b42f --- /dev/null +++ b/.changeset/thick-singers-appear.md @@ -0,0 +1,11 @@ +--- +"@tanstack/db": patch +--- + +Fix handling of Temporal objects in proxy's deepClone and deepEqual functions + +- Temporal objects (like Temporal.ZonedDateTime) are now properly preserved during cloning instead of being converted to empty objects +- Added detection for all Temporal API object types via Symbol.toStringTag +- Temporal objects are returned directly from deepClone since they're immutable +- Added proper equality checking for Temporal objects using their built-in equals() method +- Prevents unnecessary proxy creation for immutable Temporal objects diff --git a/packages/db/package.json b/packages/db/package.json index 8be6725da..04542abee 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -8,7 +8,8 @@ }, "devDependencies": { "@vitest/coverage-istanbul": "^3.0.9", - "arktype": "^2.1.20" + "arktype": "^2.1.20", + "temporal-polyfill": "^0.3.0" }, "exports": { ".": { diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index 9fa939f1b..6654e4fd7 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -1,4 +1,5 @@ import { withArrayChangeTracking, withChangeTracking } from "./proxy" +import { deepEquals } from "./utils" import { SortedMap } from "./SortedMap" import { createSingleRowRefProxy, @@ -1448,7 +1449,7 @@ export class CollectionImpl< const isRedundantSync = completedOp && newVisibleValue !== undefined && - this.deepEqual(completedOp.value, newVisibleValue) + deepEquals(completedOp.value, newVisibleValue) if (!isRedundantSync) { if ( @@ -1472,7 +1473,7 @@ export class CollectionImpl< } else if ( previousVisibleValue !== undefined && newVisibleValue !== undefined && - !this.deepEqual(previousVisibleValue, newVisibleValue) + !deepEquals(previousVisibleValue, newVisibleValue) ) { events.push({ type: `update`, @@ -1715,29 +1716,6 @@ export class CollectionImpl< } } - private deepEqual(a: any, b: any): boolean { - if (a === b) return true - if (a == null || b == null) return false - if (typeof a !== typeof b) return false - - if (typeof a === `object`) { - if (Array.isArray(a) !== Array.isArray(b)) return false - - const keysA = Object.keys(a) - const keysB = Object.keys(b) - if (keysA.length !== keysB.length) return false - - const keysBSet = new Set(keysB) - for (const key of keysA) { - if (!keysBSet.has(key)) return false - if (!this.deepEqual(a[key], b[key])) return false - } - return true - } - - return false - } - public validateData( data: unknown, type: `insert` | `update`, diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 2c0050b1a..baffc1743 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -3,6 +3,8 @@ * and provides a way to retrieve those changes. */ +import { deepEquals, isTemporal } from "./utils" + /** * Simple debug utility that only logs when debug mode is enabled * Set DEBUG to true in localStorage to enable debug logging @@ -133,6 +135,13 @@ function deepClone( return clone as unknown as T } + // Handle Temporal objects + if (isTemporal(obj)) { + // Temporal objects are immutable, so we can return them directly + // This preserves all their internal state correctly + return obj + } + const clone = {} as Record visited.set(obj as object, clone) @@ -156,107 +165,6 @@ function deepClone( return clone as T } -/** - * Deep equality check that handles special types like Date, RegExp, Map, and Set - */ -function deepEqual(a: T, b: T): boolean { - // Handle primitive types - if (a === b) return true - - // If either is null or not an object, they're not equal - if ( - a === null || - b === null || - typeof a !== `object` || - typeof b !== `object` - ) { - return false - } - - // Handle Date objects - if (a instanceof Date && b instanceof Date) { - return a.getTime() === b.getTime() - } - - // Handle RegExp objects - if (a instanceof RegExp && b instanceof RegExp) { - return a.source === b.source && a.flags === b.flags - } - - // Handle Map objects - if (a instanceof Map && b instanceof Map) { - if (a.size !== b.size) return false - - const entries = Array.from(a.entries()) - for (const [key, val] of entries) { - if (!b.has(key) || !deepEqual(val, b.get(key))) { - return false - } - } - - return true - } - - // Handle Set objects - if (a instanceof Set && b instanceof Set) { - if (a.size !== b.size) return false - - // Convert to arrays for comparison - const aValues = Array.from(a) - const bValues = Array.from(b) - - // Simple comparison for primitive values - if (aValues.every((val) => typeof val !== `object`)) { - return aValues.every((val) => b.has(val)) - } - - // For objects in sets, we need to do a more complex comparison - // This is a simplified approach and may not work for all cases - return aValues.length === bValues.length - } - - // Handle arrays - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false - - for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) return false - } - - return true - } - - // Handle TypedArrays - if ( - ArrayBuffer.isView(a) && - ArrayBuffer.isView(b) && - !(a instanceof DataView) && - !(b instanceof DataView) - ) { - const typedA = a as unknown as TypedArray - const typedB = b as unknown as TypedArray - if (typedA.length !== typedB.length) return false - - for (let i = 0; i < typedA.length; i++) { - if (typedA[i] !== typedB[i]) return false - } - - return true - } - - // Handle plain objects - const keysA = Object.keys(a as object) - const keysB = Object.keys(b as object) - - if (keysA.length !== keysB.length) return false - - return keysA.every( - (key) => - Object.prototype.hasOwnProperty.call(b, key) && - deepEqual((a as any)[key], (b as any)[key]) - ) -} - let count = 0 function getProxyCount() { count += 1 @@ -392,7 +300,7 @@ export function createChangeProxy< ) // If the value is not equal to original, something is still changed - if (!deepEqual(currentValue, originalValue)) { + if (!deepEquals(currentValue, originalValue)) { debugLog(`Property ${String(prop)} is different, returning false`) return false } @@ -411,7 +319,7 @@ export function createChangeProxy< const originalValue = (state.originalObject as any)[sym] // If the value is not equal to original, something is still changed - if (!deepEqual(currentValue, originalValue)) { + if (!deepEquals(currentValue, originalValue)) { debugLog(`Symbol property is different, returning false`) return false } @@ -741,12 +649,13 @@ export function createChangeProxy< return value.bind(ptarget) } - // If the value is an object, create a proxy for it + // If the value is an object (but not Date, RegExp, or Temporal), create a proxy for it if ( value && typeof value === `object` && !((value as any) instanceof Date) && - !((value as any) instanceof RegExp) + !((value as any) instanceof RegExp) && + !isTemporal(value) ) { // Create a parent reference for the nested object const nestedParent = { @@ -779,11 +688,11 @@ export function createChangeProxy< ) // Only track the change if the value is actually different - if (!deepEqual(currentValue, value)) { + if (!deepEquals(currentValue, value)) { // Check if the new value is equal to the original value // Important: Use the originalObject to get the true original value const originalValue = changeTracker.originalObject[prop as keyof T] - const isRevertToOriginal = deepEqual(value, originalValue) + const isRevertToOriginal = deepEquals(value, originalValue) debugLog( `value:`, value, diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index bc74353d7..5ad117792 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -2,8 +2,14 @@ * Generic utility functions */ +interface TypedArray { + length: number + [index: number]: number +} + /** * Deep equality function that compares two values recursively + * Handles primitives, objects, arrays, Date, RegExp, Map, Set, TypedArrays, and Temporal objects * * @param a - First value to compare * @param b - Second value to compare @@ -14,6 +20,8 @@ * deepEquals({ a: 1, b: 2 }, { b: 2, a: 1 }) // true (property order doesn't matter) * deepEquals([1, { x: 2 }], [1, { x: 2 }]) // true * deepEquals({ a: 1 }, { a: 2 }) // false + * deepEquals(new Date('2023-01-01'), new Date('2023-01-01')) // true + * deepEquals(new Map([['a', 1]]), new Map([['a', 1]])) // true * ``` */ export function deepEquals(a: any, b: any): boolean { @@ -37,6 +45,102 @@ function deepEqualsInternal( // Handle different types if (typeof a !== typeof b) return false + // Handle Date objects + if (a instanceof Date) { + if (!(b instanceof Date)) return false + return a.getTime() === b.getTime() + } + + // Handle RegExp objects + if (a instanceof RegExp) { + if (!(b instanceof RegExp)) return false + return a.source === b.source && a.flags === b.flags + } + + // Handle Map objects - only if both are Maps + if (a instanceof Map) { + if (!(b instanceof Map)) return false + if (a.size !== b.size) return false + + // Check for circular references + if (visited.has(a)) { + return visited.get(a) === b + } + visited.set(a, b) + + const entries = Array.from(a.entries()) + const result = entries.every(([key, val]) => { + return b.has(key) && deepEqualsInternal(val, b.get(key), visited) + }) + + visited.delete(a) + return result + } + + // Handle Set objects - only if both are Sets + if (a instanceof Set) { + if (!(b instanceof Set)) return false + if (a.size !== b.size) return false + + // Check for circular references + if (visited.has(a)) { + return visited.get(a) === b + } + visited.set(a, b) + + // Convert to arrays for comparison + const aValues = Array.from(a) + const bValues = Array.from(b) + + // Simple comparison for primitive values + if (aValues.every((val) => typeof val !== `object`)) { + visited.delete(a) + return aValues.every((val) => b.has(val)) + } + + // For objects in sets, we need to do a more complex comparison + // This is a simplified approach and may not work for all cases + const result = aValues.length === bValues.length + visited.delete(a) + return result + } + + // Handle TypedArrays + if ( + ArrayBuffer.isView(a) && + ArrayBuffer.isView(b) && + !(a instanceof DataView) && + !(b instanceof DataView) + ) { + const typedA = a as unknown as TypedArray + const typedB = b as unknown as TypedArray + if (typedA.length !== typedB.length) return false + + for (let i = 0; i < typedA.length; i++) { + if (typedA[i] !== typedB[i]) return false + } + + return true + } + + // 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) + + // If they're different Temporal types, they're not equal + if (aTag !== bTag) return false + + // Use Temporal's built-in equals method if available + if (typeof a.equals === `function`) { + return a.equals(b) + } + + // Fallback to toString comparison for other types + return a.toString() === b.toString() + } + // Handle arrays if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) return false @@ -84,3 +188,24 @@ function deepEqualsInternal( // For primitives that aren't strictly equal return false } + +const temporalTypes = [ + `Temporal.Duration`, + `Temporal.Instant`, + `Temporal.PlainDate`, + `Temporal.PlainDateTime`, + `Temporal.PlainMonthDay`, + `Temporal.PlainTime`, + `Temporal.PlainYearMonth`, + `Temporal.ZonedDateTime`, +] + +function getStringTag(a: any): any { + return a[Symbol.toStringTag] +} + +/** 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) +} diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 0befcbd89..f063d43a7 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest" +import { Temporal } from "temporal-polyfill" import { createArrayChangeProxy, createChangeProxy, @@ -1108,6 +1109,41 @@ describe(`Proxy Library`, () => { } }) + it(`should handle Temporal objects correctly`, () => { + const zonedDateTime = Temporal.Now.zonedDateTimeISO() + const plainDate = Temporal.PlainDate.from(`2024-01-15`) + const duration = Temporal.Duration.from({ hours: 2, minutes: 30 }) + + const obj = { + appointment: { + date: zonedDateTime, + reminder: plainDate, + duration: duration, + }, + } + + const { proxy, getChanges } = createChangeProxy(obj) + + // Modify the temporal objects + proxy.appointment = { + date: Temporal.Now.zonedDateTimeISO(), + reminder: Temporal.PlainDate.from(`2024-01-16`), + duration: Temporal.Duration.from({ hours: 3 }), + } + + const changes = getChanges() + + // The changed values should be proper Temporal objects, not empty objects + expect(changes.appointment.date).toBeInstanceOf(Temporal.ZonedDateTime) + expect(changes.appointment.reminder).toBeInstanceOf(Temporal.PlainDate) + expect(changes.appointment.duration).toBeInstanceOf(Temporal.Duration) + + // Original should be unchanged + expect(obj.appointment.date).toEqual(zonedDateTime) + expect(obj.appointment.reminder).toEqual(plainDate) + expect(obj.appointment.duration).toEqual(duration) + }) + it(`should handle Set and Map objects`, () => { const set = new Set([1, 2, 3]) const map = new Map([ diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 2570c0812..b396dcc09 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest" +import { Temporal } from "temporal-polyfill" import { createCollection } from "../../src/collection.js" import { createLiveQueryCollection, eq } from "../../src/query/index.js" import { Query } from "../../src/query/builder/index.js" @@ -317,6 +318,67 @@ describe(`createLiveQueryCollection`, () => { expect(() => liveQuery.subscribeChanges(() => {})).not.toThrow() }) + it(`should handle temporal values correctly in live queries`, async () => { + // Define a type with temporal values + type Task = { + id: number + name: string + duration: Temporal.Duration + } + + // Initial data with temporal duration + const initialTask: Task = { + id: 1, + name: `Test Task`, + duration: Temporal.Duration.from({ hours: 1 }), + } + + // Create a collection with temporal values + const taskCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-tasks`, + getKey: (task) => task.id, + initialData: [initialTask], + }) + ) + + // Create a live query collection that includes the temporal value + const liveQuery = createLiveQueryCollection((q) => + q.from({ task: taskCollection }) + ) + + await liveQuery.preload() + + // After initial sync, the live query should see the row with the temporal value + expect(liveQuery.size).toBe(1) + const initialResult = liveQuery.get(1) + expect(initialResult).toBeDefined() + expect(initialResult!.duration).toBeInstanceOf(Temporal.Duration) + expect(initialResult!.duration.hours).toBe(1) + + // Simulate backend change: update the temporal value to 10 hours + const updatedTask: Task = { + id: 1, + name: `Test Task`, + duration: Temporal.Duration.from({ hours: 10 }), + } + + // Update the task in the collection (simulating backend sync) + taskCollection.utils.begin() + taskCollection.utils.write({ + type: `update`, + value: updatedTask, + }) + taskCollection.utils.commit() + + // The live query should now contain the new temporal value + const updatedResult = liveQuery.get(1) + expect(updatedResult).toBeDefined() + expect(updatedResult!.duration).toBeInstanceOf(Temporal.Duration) + expect(updatedResult!.duration.hours).toBe(10) + expect(updatedResult!.duration.total({ unit: `hours` })).toBe(10) + }) + for (const autoIndex of [`eager`, `off`] as const) { it(`should not send the initial state twice on joins with autoIndex: ${autoIndex}`, async () => { type Player = { id: number; name: string } diff --git a/packages/db/tests/utils.test.ts b/packages/db/tests/utils.test.ts new file mode 100644 index 000000000..f277a9923 --- /dev/null +++ b/packages/db/tests/utils.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vitest" +import { Temporal } from "temporal-polyfill" +import { deepEquals } from "../src/utils" + +describe(`deepEquals`, () => { + describe(`primitives`, () => { + it(`should handle identical primitives`, () => { + expect(deepEquals(1, 1)).toBe(true) + expect(deepEquals(`hello`, `hello`)).toBe(true) + expect(deepEquals(true, true)).toBe(true) + expect(deepEquals(null, null)).toBe(true) + expect(deepEquals(undefined, undefined)).toBe(true) + }) + + it(`should handle different primitives`, () => { + expect(deepEquals(1, 2)).toBe(false) + expect(deepEquals(`hello`, `world`)).toBe(false) + expect(deepEquals(true, false)).toBe(false) + expect(deepEquals(null, undefined)).toBe(false) + }) + + it(`should handle different types`, () => { + expect(deepEquals(1, `1`)).toBe(false) + expect(deepEquals(0, false)).toBe(false) + expect(deepEquals(null, 0)).toBe(false) + }) + }) + + describe(`arrays`, () => { + it(`should handle identical arrays`, () => { + expect(deepEquals([], [])).toBe(true) + expect(deepEquals([1, 2, 3], [1, 2, 3])).toBe(true) + expect(deepEquals([1, [2, 3]], [1, [2, 3]])).toBe(true) + }) + + it(`should handle different arrays`, () => { + expect(deepEquals([1, 2, 3], [1, 2, 4])).toBe(false) + expect(deepEquals([1, 2, 3], [1, 2])).toBe(false) + expect(deepEquals([1, [2, 3]], [1, [2, 4]])).toBe(false) + }) + + it(`should handle circular references in arrays`, () => { + const a: Array = [1, 2] + a.push(a) + const b: Array = [1, 2] + b.push(b) + + expect(deepEquals(a, b)).toBe(true) + }) + }) + + describe(`objects`, () => { + it(`should handle identical objects`, () => { + expect(deepEquals({}, {})).toBe(true) + expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true) + expect(deepEquals({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true) // property order doesn't matter + expect(deepEquals({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true) + }) + + it(`should handle different objects`, () => { + expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false) + expect(deepEquals({ a: 1, b: 2 }, { a: 1 })).toBe(false) + expect(deepEquals({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false) + }) + + it(`should handle circular references in objects`, () => { + const a: any = { x: 1 } + a.self = a + const b: any = { x: 1 } + b.self = b + + expect(deepEquals(a, b)).toBe(true) + }) + }) + + describe(`Date objects`, () => { + it(`should handle identical dates`, () => { + const date1 = new Date(`2023-01-01T00:00:00Z`) + const date2 = new Date(`2023-01-01T00:00:00Z`) + expect(deepEquals(date1, date2)).toBe(true) + }) + + it(`should handle different dates`, () => { + const date1 = new Date(`2023-01-01T00:00:00Z`) + const date2 = new Date(`2023-01-02T00:00:00Z`) + expect(deepEquals(date1, date2)).toBe(false) + }) + + it(`should handle date vs non-date`, () => { + const date = new Date(`2023-01-01T00:00:00Z`) + expect(deepEquals(date, `2023-01-01T00:00:00Z`)).toBe(false) + expect(deepEquals(date, date.getTime())).toBe(false) + }) + }) + + describe(`RegExp objects`, () => { + it(`should handle identical regexes`, () => { + expect(deepEquals(/abc/g, /abc/g)).toBe(true) + expect(deepEquals(/test/i, /test/i)).toBe(true) + }) + + it(`should handle different regexes`, () => { + expect(deepEquals(/abc/g, /abc/i)).toBe(false) + expect(deepEquals(/abc/g, /def/g)).toBe(false) + }) + + it(`should handle regex vs non-regex`, () => { + expect(deepEquals(/abc/g, `abc`)).toBe(false) + }) + }) + + describe(`Map objects`, () => { + it(`should handle identical maps`, () => { + const map1 = new Map([ + [`a`, 1], + [`b`, 2], + ]) + const map2 = new Map([ + [`a`, 1], + [`b`, 2], + ]) + expect(deepEquals(map1, map2)).toBe(true) + }) + + it(`should handle maps with different order`, () => { + const map1 = new Map([ + [`a`, 1], + [`b`, 2], + ]) + const map2 = new Map([ + [`b`, 2], + [`a`, 1], + ]) + expect(deepEquals(map1, map2)).toBe(true) + }) + + it(`should handle different maps`, () => { + const map1 = new Map([ + [`a`, 1], + [`b`, 2], + ]) + const map2 = new Map([ + [`a`, 1], + [`b`, 3], + ]) + expect(deepEquals(map1, map2)).toBe(false) + }) + + it(`should handle maps with different sizes`, () => { + const map1 = new Map([ + [`a`, 1], + [`b`, 2], + ]) + const map2 = new Map([[`a`, 1]]) + expect(deepEquals(map1, map2)).toBe(false) + }) + + it(`should handle nested objects in maps`, () => { + const map1 = new Map([ + [`a`, { x: 1 }], + [`b`, { y: 2 }], + ]) + const map2 = new Map([ + [`a`, { x: 1 }], + [`b`, { y: 2 }], + ]) + expect(deepEquals(map1, map2)).toBe(true) + }) + + it(`should handle circular references in maps`, () => { + const map1 = new Map() + map1.set(`self`, map1) + const map2 = new Map() + map2.set(`self`, map2) + + expect(deepEquals(map1, map2)).toBe(true) + }) + }) + + describe(`Set objects`, () => { + it(`should handle identical sets with primitives`, () => { + const set1 = new Set([1, 2, 3]) + const set2 = new Set([1, 2, 3]) + expect(deepEquals(set1, set2)).toBe(true) + }) + + it(`should handle sets with different order`, () => { + const set1 = new Set([1, 2, 3]) + const set2 = new Set([3, 1, 2]) + expect(deepEquals(set1, set2)).toBe(true) + }) + + it(`should handle different sets`, () => { + const set1 = new Set([1, 2, 3]) + const set2 = new Set([1, 2, 4]) + expect(deepEquals(set1, set2)).toBe(false) + }) + + it(`should handle sets with different sizes`, () => { + const set1 = new Set([1, 2, 3]) + const set2 = new Set([1, 2]) + expect(deepEquals(set1, set2)).toBe(false) + }) + + it(`should handle sets with objects (simplified comparison)`, () => { + const set1 = new Set([{ a: 1 }, { b: 2 }]) + const set2 = new Set([{ a: 1 }, { b: 2 }]) + // Note: Set comparison for objects is simplified and may not work for all cases + expect(deepEquals(set1, set2)).toBe(true) + }) + + it(`should handle circular references in sets`, () => { + const set1 = new Set() + set1.add(set1) + const set2 = new Set() + set2.add(set2) + + expect(deepEquals(set1, set2)).toBe(true) + }) + }) + + describe(`TypedArrays`, () => { + it(`should handle identical Uint8Arrays`, () => { + const arr1 = new Uint8Array([1, 2, 3, 4]) + const arr2 = new Uint8Array([1, 2, 3, 4]) + expect(deepEquals(arr1, arr2)).toBe(true) + }) + + it(`should handle different Uint8Arrays`, () => { + const arr1 = new Uint8Array([1, 2, 3, 4]) + const arr2 = new Uint8Array([1, 2, 3, 5]) + expect(deepEquals(arr1, arr2)).toBe(false) + }) + + it(`should handle arrays with different lengths`, () => { + const arr1 = new Uint8Array([1, 2, 3]) + const arr2 = new Uint8Array([1, 2]) + expect(deepEquals(arr1, arr2)).toBe(false) + }) + + it(`should handle different TypedArray types`, () => { + const arr1 = new Uint8Array([1, 2, 3]) + const arr2 = new Int8Array([1, 2, 3]) + // Different types should be handled by the typeof check + expect(deepEquals(arr1, arr2)).toBe(true) // Both are typed arrays with same values + }) + + it(`should handle Float32Arrays`, () => { + const arr1 = new Float32Array([1.1, 2.2, 3.3]) + const arr2 = new Float32Array([1.1, 2.2, 3.3]) + expect(deepEquals(arr1, arr2)).toBe(true) + }) + }) + + describe(`Temporal objects (if available)`, () => { + it(`should handle Temporal.PlainDate objects`, () => { + const date1 = new Temporal.PlainDate(2023, 1, 1) + const date2 = new Temporal.PlainDate(2023, 1, 1) + const date3 = new Temporal.PlainDate(2023, 1, 2) + + expect(deepEquals(date1, date2)).toBe(true) + expect(deepEquals(date1, date3)).toBe(false) + }) + + it(`should handle Temporal.Duration objects`, () => { + const duration1 = Temporal.Duration.from(`PT1H30M`) + const duration2 = Temporal.Duration.from(`PT1H30M`) + const duration3 = Temporal.Duration.from(`PT2H30M`) + + expect(deepEquals(duration1, duration2)).toBe(true) + expect(deepEquals(duration1, duration3)).toBe(false) + }) + + it(`should handle different Temporal types`, () => { + const date = new Temporal.PlainDate(2023, 1, 1) + const duration = Temporal.Duration.from(`PT1H30M`) + expect(deepEquals(date, duration)).toBe(false) + }) + }) + + describe(`mixed complex cases`, () => { + it(`should handle complex nested structures`, () => { + const obj1 = { + array: [1, 2, { nested: true }], + map: new Map([[`key`, `value`]]), + set: new Set([1, 2, 3]), + date: new Date(`2023-01-01`), + regex: /test/gi, + typedArray: new Uint8Array([1, 2, 3]), + } + + const obj2 = { + array: [1, 2, { nested: true }], + map: new Map([[`key`, `value`]]), + set: new Set([1, 2, 3]), + date: new Date(`2023-01-01`), + regex: /test/gi, + typedArray: new Uint8Array([1, 2, 3]), + } + + expect(deepEquals(obj1, obj2)).toBe(true) + }) + + it(`should handle complex nested structures with differences`, () => { + const obj1 = { + array: [1, 2, { nested: true }], + map: new Map([[`key`, `value`]]), + date: new Date(`2023-01-01`), + } + + const obj2 = { + array: [1, 2, { nested: false }], // difference here + map: new Map([[`key`, `value`]]), + date: new Date(`2023-01-01`), + } + + expect(deepEquals(obj1, obj2)).toBe(false) + }) + }) + + describe(`edge cases`, () => { + it(`should handle null and undefined`, () => { + expect(deepEquals(null, null)).toBe(true) + expect(deepEquals(undefined, undefined)).toBe(true) + expect(deepEquals(null, undefined)).toBe(false) + expect(deepEquals(null, 0)).toBe(false) + expect(deepEquals(undefined, ``)).toBe(false) + }) + + it(`should handle empty structures`, () => { + expect(deepEquals({}, {})).toBe(true) + expect(deepEquals([], [])).toBe(true) + expect(deepEquals(new Map(), new Map())).toBe(true) + expect(deepEquals(new Set(), new Set())).toBe(true) + }) + + it(`should handle mixed types`, () => { + expect(deepEquals([], {})).toBe(false) + expect(deepEquals(new Map(), new Set())).toBe(false) + expect(deepEquals(new Date(), /regex/)).toBe(false) + }) + }) +}) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index e338cee9e..efb8f870a 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -537,12 +537,6 @@ export function queryCollectionOptions< return keys1.every((key) => { // Skip comparing functions and complex objects deeply if (typeof obj1[key] === `function`) return true - if (typeof obj1[key] === `object` && obj1[key] !== null) { - // For nested objects, just compare references - // A more robust solution might do recursive shallow comparison - // or let users provide a custom equality function - return obj1[key] === obj2[key] - } return obj1[key] === obj2[key] }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58f268d17..5cb31dcbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -486,6 +486,9 @@ importers: arktype: specifier: ^2.1.20 version: 2.1.20 + temporal-polyfill: + specifier: ^0.3.0 + version: 0.3.0 packages/db-ivm: dependencies: @@ -6737,6 +6740,12 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + temporal-polyfill@0.3.0: + resolution: {integrity: sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==} + + temporal-spec@0.3.0: + resolution: {integrity: sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -14439,6 +14448,12 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + temporal-polyfill@0.3.0: + dependencies: + temporal-spec: 0.3.0 + + temporal-spec@0.3.0: {} + term-size@2.2.1: {} terser@5.43.1: