UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

406 lines (361 loc) • 13.6 kB
/** * Generic utility functions */ import { InvalidKeyError, UndefinedKeyError } from './errors' import type { CompareOptions } from './query/builder/types' interface TypedArray { length: number [index: number]: number every: (predicate: (value: number, index: number) => boolean) => boolean } export const durationFields = [ `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`, `milliseconds`, `microseconds`, `nanoseconds`, ] as const export type DurationFields = Record<(typeof durationFields)[number], number> /** * Deep equality function that compares two values recursively * Handles primitives, objects, arrays, Date, RegExp, Map, Set, TypedArrays, and Temporal objects * Plain records can compare by keys with keyed class instances and records from other realms. * * @param a - First value to compare * @param b - Second value to compare * @returns True if the values are deeply equal, false otherwise * * @example * ```typescript * 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 { return deepEqualsInternal(a, b) } /** * @internal Whether a synced row changed observably: deep equality that * tells -0 from 0 and preserves record prototypes, as public row values do. * Maintainer decisions: FID-1, 2026-10-29; record shape, 2026-11-03. */ export function sameRowValue(a: unknown, b: unknown): boolean { return deepEqualsInternal(a, b, ROW) } /** * @internal Whether an ordered Effect's update repeats its previous * publication: row-value equality, except that a class instance equals only * itself, since it can hold readable state outside its enumerable fields * (maintainer decision 2026-11-02). */ export function samePublishedValue(a: unknown, b: unknown): boolean { return deepEqualsInternal(a, b, ROW | PUBLICATION) } /** @internal Compare persisted public and durable value shapes before skipping a reload. */ export function equalPersistedSnapshotValues(a: unknown, b: unknown): boolean { return deepEqualsInternal(a, b, DRAFT | PERSISTED) } function isPlainPrototype(prototype: object | null): boolean { return prototype === null || Object.getPrototypeOf(prototype) === null } function enumerableOwnKeys(value: object): Array<string | symbol> { const keys: Array<string | symbol> = Object.keys(value) for (const key of Object.getOwnPropertySymbols(value)) { if (Object.prototype.propertyIsEnumerable.call(value, key)) keys.push(key) } return keys } // The comparison modes of `deepEqualsInternal`, combined as bits. /** * The stricter equality used by change-tracking drafts: Map and Set contents * must match in order, RegExp match position must match, and arrays compare * as keyed objects (holes and extra enumerable keys count). */ export const DRAFT = 1 /** Persisted snapshots also distinguish prototypes after serialization. */ const PERSISTED = 2 /** Row change detection tells -0 from 0; general equality does not. */ export const ROW = 4 /** An ordered Effect's publication also compares a class instance by identity. */ const PUBLICATION = 8 /** * Internal implementation with cycle detection to prevent infinite recursion. * Internal callers can seed already-paired roots when comparing their children. * * `mode` combines the bits above; one recursive call forwards all of them. */ export function deepEqualsInternal( a: any, b: any, mode = 0, // Created on the first container that descends into a child. visited?: Map<object, object>, ): boolean { const draft = mode & DRAFT // Handle strict equality (primitives, same reference); a row value tells // -0 from 0. if ((!(mode & ROW) && a === b) || Object.is(a, b)) return true // Handle null/undefined if (a == null || b == null) return false // Handle different types if (typeof a !== typeof b) return false // SQLite can turn an authored nested instance into an equal-keyed plain // record. A skipped persisted reload must not retain the old public shape. if ( mode & PERSISTED && typeof a === `object` && Object.getPrototypeOf(a) !== Object.getPrototypeOf(b) ) { return false } // A container already paired in this comparison closes a cycle: it equals // only its partner. if (visited?.has(a)) return visited.get(a) === b // Handle Date objects if (a instanceof Date) { if (!(b instanceof Date)) return false return Object.is(a.getTime(), b.getTime()) } // Symmetric check: if b is Date but a is not, they're not equal if (b instanceof Date) return false // Handle RegExp objects if (a instanceof RegExp) { if (!(b instanceof RegExp)) return false return ( a.source === b.source && a.flags === b.flags && (!draft || a.lastIndex === b.lastIndex) ) } // Symmetric check: if b is RegExp but a is not, they're not equal if (b instanceof RegExp) return false // 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 ;(visited ??= new Map()).set(a, b) // A draft compares entries in order; general equality looks keys up. const bEntries = draft && Array.from(b.entries()) const result = Array.from(a.entries()).every(([key, val], index) => bEntries ? Object.is(key, bEntries[index]![0]) && deepEqualsInternal(val, bEntries[index]![1], mode, visited) : b.has(key) && deepEqualsInternal(val, b.get(key), mode, visited), ) visited.delete(a) return result } // Symmetric check: if b is Map but a is not, they're not equal if (b instanceof Map) return false // 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 ;(visited ??= new Map()).set(a, b) // Convert to arrays for comparison const aValues = Array.from(a) const bValues = Array.from(b) if (draft) { // A draft compares contents in order, as it compares arrays. const result = deepEqualsInternal(aValues, bValues, mode, visited) visited.delete(a) return result } // Simple comparison for primitive values if (aValues.every((val) => typeof val !== `object`)) { visited.delete(a) return aValues.every((val) => b.has(val)) } // A paired equality is already a bijection. Keep common equal inputs linear. if ( aValues.every((value, index) => deepEqualsInternal(value, bValues[index], mode, visited), ) ) { visited.delete(a) return true } // Recursive comparisons remove their own pairs before returning. Reuse the // ancestor map; only failed assignments need augmenting-path search. const candidates = aValues.map((value) => bValues.flatMap((candidate, index) => deepEqualsInternal(value, candidate, mode, visited) ? [index] : [], ), ) const assigned = new Map<number, number>() const match = (index: number, seen: Set<number>): boolean => { const options = candidates[index]! const free = options.find((candidate) => !assigned.has(candidate)) if (free !== undefined) { assigned.set(free, index) return true } return options.some((candidate) => { if (seen.has(candidate)) return false seen.add(candidate) const previous = assigned.get(candidate) if (previous !== undefined && !match(previous, seen)) return false assigned.set(candidate, index) return true }) } const result = aValues.every((_, index) => match(index, new Set())) visited.delete(a) return result } // Symmetric check: if b is Set but a is not, they're not equal if (b instanceof Set) return false // Handle TypedArrays. A typed array is its own value class: it equals only // another typed array, in both directions. const typed = ArrayBuffer.isView(a) && !(a instanceof DataView) if (typed !== (ArrayBuffer.isView(b) && !(b instanceof DataView))) { return false } if (typed) { const typedA = a as unknown as TypedArray const typedB = b as unknown as TypedArray // Elements compare as the numbers they are: NaN equals NaN, and -0 // equals 0 except as a row value. Only a draft treats a change of // typed-array class as a change. return ( (!draft || Object.getPrototypeOf(a) === Object.getPrototypeOf(b)) && typedA.length === typedB.length && typedA.every((x, i) => deepEqualsInternal(x, typedB[i], mode)) ) } // Handle Temporal objects // Check if both are Temporal objects of the same type if (isTemporal(a) && isTemporal(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 // Duration identity preserves its fields, even when toString normalizes // different units to the same elapsed time. if (aTag === `Temporal.Duration`) { const durationA = a as unknown as DurationFields const durationB = b as unknown as DurationFields return durationFields.every( (field) => durationA[field] === durationB[field], ) } // 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() } // Symmetric check: if b is Temporal but a is not, they're not equal if (isTemporal(b)) return false // Handle arrays if (Array.isArray(a) !== Array.isArray(b)) return false if (Array.isArray(a) && a.length !== b.length) return false if (Array.isArray(a) && !draft) { ;(visited ??= new Map()).set(a, b) let result = true for (let index = 0; index < a.length; index++) { if (!deepEqualsInternal(a[index], b[index], mode, visited)) { result = false break } } visited.delete(a) return result } // Handle objects if (typeof a === `object`) { // Row publication preserves record prototypes. Generic equality and draft // reconstruction keep their established plain-record/class bridge. const prototype = Object.getPrototypeOf(a) const prototypeB = Object.getPrototypeOf(b) const plain = isPlainPrototype(prototype) const plainB = isPlainPrototype(prototypeB) if ( prototype !== prototypeB && ((mode & ROW && !draft) || (!plain && !plainB)) ) return false if (mode & PUBLICATION && !(plain && plainB)) return false // Compare enumerable symbol keys as well as string keys. Query results may // use user-owned symbols, and a symbol-only update is still a value change. const keysA = enumerableOwnKeys(a) const keysB = enumerableOwnKeys(b) // Check if they have the same number of keys if (keysA.length !== keysB.length) return false // A class instance without enumerable keys (a File, an object with // private fields) keeps its state elsewhere, so it equals only itself. // A draft copies a URL by its href, so URLs compare by href. if (keysA.length === 0 && !Array.isArray(a) && !(plain && plainB)) { return a instanceof URL && a.href === b.href } // Check if all keys exist in both objects and their values are equal. // Register for cycles only before descending, so a flat object // allocates no cycle map. let registered = false let result = true for (const key of keysA) { const value = a[key] if (!registered && value !== null && typeof value === `object`) { ;(visited ??= new Map()).set(a, b) registered = true } if ( !Object.prototype.propertyIsEnumerable.call(b, key) || !deepEqualsInternal(value, b[key], mode, visited) ) { result = false break } } if (registered) visited!.delete(a) return result } // For primitives that aren't strictly equal return false } const temporalTypes = new Set([ `Temporal.Duration`, `Temporal.Instant`, `Temporal.PlainDate`, `Temporal.PlainDateTime`, `Temporal.PlainMonthDay`, `Temporal.PlainTime`, `Temporal.PlainYearMonth`, `Temporal.ZonedDateTime`, ]) 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: 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: Exclude< CompareOptions, { stringSort: `custom` } > = { direction: `asc`, nulls: `first`, stringSort: `locale`, } /** Throws unless a key has the Collection key type: a string or a number. */ export function assertCollectionKey(key: unknown, item: unknown): void { if (typeof key !== `string` && typeof key !== `number`) { throw key === undefined ? new UndefinedKeyError(item) : new InvalidKeyError(key, item) } }