UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

371 lines (334 loc) • 12.1 kB
import { durationFields, isTemporal } from '../utils' import { getRuntimeReferenceIdentity } from '../query/runtime-reference-identity' import { codedMessage, devBuild } from '../error-message' import type { DurationFields } from '../utils' import type { CompareOptions } from '../query/builder/types' /** * Whether a value has no IEEE-754 natural order: `NaN`, or an invalid Date * (whose timestamp is `NaN`). The query engine follows PostgreSQL float * semantics for these values — they are all equal to one another and greater * than every other (non-null) value — so the comparator and the WHERE * evaluator treat them explicitly instead of letting `NaN` compare unequal to * everything (which has no consistent order and cannot be indexed or sorted). */ export function isUnorderable(value: any): boolean { return ( (typeof value === `number` && Number.isNaN(value)) || (value instanceof Date && Number.isNaN(value.getTime())) ) } /** * Universal comparison function for all data types * Handles null/undefined, strings, arrays, dates, objects, and primitives * Always sorts null/undefined values first */ export const ascComparator = (a: any, b: any, opts: CompareOptions): number => { const { nulls } = opts // Handle null/undefined if (a == null && b == null) return 0 if (a == null) return nulls === `first` ? -1 : 1 if (b == null) return nulls === `first` ? 1 : -1 // Handle NaN / invalid Dates. Following PostgreSQL float semantics, they are // all equal and sort greater than every other non-null value. This keeps the // order total (NaN would otherwise compare equal to everything), so such // values can be sorted and stored in tree-based indexes. const aUnordered = isUnorderable(a) const bUnordered = isUnorderable(b) if (aUnordered && bUnordered) return 0 if (aUnordered) return 1 if (bUnordered) return -1 // If both values are strings, apply the configured string collation. if (typeof a === `string` && typeof b === `string`) { if (opts.stringSort === `custom`) { return opts.compare(a, b) } if (opts.stringSort === `locale`) { return a.localeCompare(b, opts.locale, opts.localeOptions) } // For lexical sort we rely on direct comparison for primitive values } // if a and b are both arrays, compare them element by element if (Array.isArray(a) && Array.isArray(b)) { for (let i = 0; i < Math.min(a.length, b.length); i++) { const result = ascComparator(a[i], b[i], opts) if (result !== 0) { return result } } // All elements are equal up to the minimum length return a.length - b.length } // If both are dates, compare them if (a instanceof Date && b instanceof Date) { return a.getTime() - b.getTime() } // If both are Temporal objects, use compareTemporalValues for correct semantic ordering if (isTemporal(a) && isTemporal(b)) { return compareTemporalValues(a, b) } // Symbols have identity but no built-in order: relational comparison throws. // A stable runtime ID gives tree indexes a total order while preserving // equality only for the same symbol. const aIsSymbol = typeof a === `symbol` const bIsSymbol = typeof b === `symbol` if (aIsSymbol && bIsSymbol) { if (a === b) return 0 return getRuntimeReferenceIdentity(a)[2] - getRuntimeReferenceIdentity(b)[2] } if (aIsSymbol) return 1 if (bIsSymbol) return -1 // 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` if (aIsObject || bIsObject) { // Symbols and objects share the runtime's reference-identity sequence. if (aIsObject && bIsObject) { const aId = getRuntimeReferenceIdentity(a)[2] const bId = getRuntimeReferenceIdentity(b)[2] return aId - bId } // If only one is an object, objects come after primitives if (aIsObject) return 1 if (bIsObject) return -1 } // For primitive values, use direct comparison if (a < b) return -1 if (a > b) return 1 return 0 } /** * Descending comparator function for ordering values * Handles null/undefined as largest values (opposite of ascending) */ export const descComparator = ( a: unknown, b: unknown, opts: CompareOptions, ): number => { return a == null || b == null ? ascComparator(a, b, opts) : ascComparator(b, a, opts) } export function indexObjectKind(value: any): string { if (isUint8Array(value)) return `Binary` if (isTemporal(value)) { const tag = value[Symbol.toStringTag] const fields = value as unknown as DurationFields return tag === `Temporal.Duration` && (fields.years || fields.months || fields.weeks) ? `Temporal.Duration.calendar` : tag } if (Array.isArray(value)) return `Array` return `Object` } export function makeComparator( opts: CompareOptions, scope: `values` | `index` = `values`, ): (a: any, b: any) => number { const compare = opts.direction === `asc` ? ascComparator : descComparator if (scope !== `index`) return (a, b) => compare(a, b, opts) const direction = opts.direction === `asc` ? 1 : -1 return (a, b) => { // An index needs one stable domain for each native comparison law. In // particular, Date order cannot be mixed with reference-object order. if (a != null && b != null) { if (a instanceof Date) a = a.getTime() if (b instanceof Date) b = b.getTime() const aType = typeof a const bType = typeof b if (aType !== bType) return (aType < bType ? -1 : 1) * direction if (aType === `object`) { const aKind = indexObjectKind(a) const bKind = indexObjectKind(b) if (aKind !== bKind) return (aKind < bKind ? -1 : 1) * direction // Binary content and unordered Temporal values use their exact keys // for stable storage order. if ( aKind === `Binary` || aKind === `Temporal.Duration.calendar` || aKind === `Temporal.PlainMonthDay` ) { const left = normalizeValue(a) const right = normalizeValue(b) return (left < right ? -1 : left > right ? 1 : 0) * direction } } } return compare(a, b, opts) } } /** * Wraps a user comparator so an index crashes on a result that cannot order * its storage, instead of silently misplacing rows. */ export function makeCheckedComparator( compareFn: (a: any, b: any) => number, ): (a: any, b: any) => number { return (a, b) => { const result: unknown = compareFn(a, b) if (typeof result !== `number` || Number.isNaN(result)) throw new TypeError( devBuild() && process.env.NODE_ENV !== `production` ? `Index comparator must return a number, but returned ${String(result)}` : codedMessage(166, { result }), ) return result } } /** Default comparator orders values in ascending order with nulls first and locale string comparison. */ export const defaultComparator = makeComparator({ direction: `asc`, nulls: `first`, stringSort: `locale`, }) /** Include host Buffers when the current realm has a different Uint8Array. */ export function isUint8Array(value: unknown): value is Uint8Array { return ( value instanceof Uint8Array || (typeof Buffer !== `undefined` && value instanceof Buffer) ) } /** Compare two Uint8Arrays for content equality. */ function areUint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) { return false } for (let i = 0; i < a.byteLength; i++) { if (a[i] !== b[i]) { return false } } return true } const NORMALIZED_KEY_PREFIX = `\u0000tanstack-db:` function normalizedKey(kind: string, value: string): string { return `${NORMALIZED_KEY_PREFIX}${kind}:${value}` } const binaryKeys = new WeakMap<Uint8Array, string>() function normalizeBinary(value: Uint8Array): string { const cached = binaryKeys.get(value) if (cached) return cached let bytes: string if (value.byteLength <= 8192) { bytes = String.fromCharCode.apply(null, value as unknown as Array<number>) } else { const chunks: Array<string> = [] for (let index = 0; index < value.byteLength; index += 8192) { chunks.push( String.fromCharCode.apply( null, value.subarray(index, index + 8192) as unknown as Array<number>, ), ) } bytes = chunks.join(``) } const key = normalizedKey(`binary`, bytes) binaryKeys.set(value, key) return key } /** * Normalize a value for comparison and Map key usage * Converts values that can't be directly compared or used as Map keys * into comparable primitive representations */ export function normalizeValue(value: any): any { if (typeof value === `string`) { return value.startsWith(NORMALIZED_KEY_PREFIX) ? normalizedKey(`string`, value) : value } if (typeof value !== `object` || value === null) { return value } if (value instanceof Date) { return value.getTime() } if (isTemporal(value)) { const tag = value[Symbol.toStringTag] if (tag === 'Temporal.Duration') { const fields = value as unknown as DurationFields return normalizedKey( tag, durationFields.map((field) => fields[field]).join(','), ) } return normalizedKey(tag, value.toString()) } // Normalize Uint8Arrays/Buffers to a string representation for Map key usage // This enables content-based equality for binary data like ULIDs if (isUint8Array(value)) { return normalizeBinary(value) } return value } /** * Compare values using the equality semantics used by Map keys. */ export function areSameValueZeroEqual(a: unknown, b: unknown): boolean { return a === b || (Number.isNaN(a) && Number.isNaN(b)) } /** * Compare two Temporal values of the same type, returning -1, 0, or 1. * * The tags check type agreement. Types without a static `.compare` (e.g. * `PlainMonthDay`) throw instead of using text order. * * Callers must ensure both arguments are Temporal objects; mixed types throw. */ export function compareTemporalValues(a: unknown, b: unknown): number { const aTag = (a as Record<symbol, unknown>)[Symbol.toStringTag] const bTag = (b as Record<symbol, unknown>)[Symbol.toStringTag] if (aTag !== bTag) { throw new TypeError( devBuild() && process.env.NODE_ENV !== `production` ? `Cannot order Temporal values of different types: ${aTag} vs ${bTag}` : codedMessage(167, { aTag, bTag }), ) } const compare = ( a as { constructor: { compare?: (x: unknown, y: unknown) => number } } ).constructor.compare if (typeof compare !== `function`) { throw new TypeError( devBuild() && process.env.NODE_ENV !== `production` ? `${aTag} has no defined ordering` : codedMessage(168, { aTag }), ) } return compare(a, b) } /** * Order two non-null values, returning -1, 0, or 1. * * Temporal types intentionally throw from `valueOf` to prevent silent * miscomparison via the native relational operators — delegate to * `compareTemporalValues` for them. For everything else (numbers, strings, * Dates via `valueOf`, etc.) the native operators do the right thing. * * Callers must handle null/undefined themselves — this helper assumes both * arguments are non-null. */ export function compareValues(a: unknown, b: unknown): number { if (isTemporal(a) && isTemporal(b)) { return compareTemporalValues(a, b) } return (a as any) < (b as any) ? -1 : (a as any) > (b as any) ? 1 : 0 } /** * Compare two values for equality, with special handling for Uint8Arrays and Buffers */ export function areValuesEqual(a: any, b: any): boolean { // Fast path for reference equality if (a === b) { return true } // Check for Uint8Array/Buffer comparison // If both are Uint8Arrays, compare by content if (isUint8Array(a) && isUint8Array(b)) { return areUint8ArraysEqual(a, b) } // Different types or not Uint8Arrays return false }