@tanstack/db-ivm 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { MurmurHashStream, randomHash } from './murmur.js'
1
+ import { MurmurHashStream, getSymbolIdentity, randomHash } from './murmur.js'
2
2
  import type { Hasher } from './murmur.js'
3
3
 
4
4
  /*
@@ -19,6 +19,10 @@ const MAP_MARKER = randomHash()
19
19
  const SET_MARKER = randomHash()
20
20
  const UINT8ARRAY_MARKER = randomHash()
21
21
  const TEMPORAL_MARKER = randomHash()
22
+ // Bound structural recursion and value visits. Shared acyclic subtrees are
23
+ // cached; cycles are rejected rather than given context-dependent hashes.
24
+ const MAX_STRUCTURAL_HASH_WORK = 1_000_000
25
+ const MAX_STRUCTURAL_HASH_DEPTH = 768
22
26
 
23
27
  const temporalTypes = new Set([
24
28
  `Temporal.Duration`,
@@ -48,63 +52,65 @@ const UINT8ARRAY_CONTENT_HASH_THRESHOLD = 128
48
52
 
49
53
  const hashCache = new WeakMap<object, number>()
50
54
 
55
+ /** @internal Register a mutable handle before it enters a structural value. */
56
+ export function registerOpaqueHash(value: object): void {
57
+ cachedReferenceHash(value)
58
+ }
59
+
60
+ type HashContext = {
61
+ activeObjects: Set<object>
62
+ work: number
63
+ pendingHashes: Map<object, number>
64
+ }
65
+
51
66
  export function hash(input: any): number {
52
67
  const hasher = new MurmurHashStream()
53
68
  updateHasher(hasher, input)
54
69
  return hasher.digest()
55
70
  }
56
71
 
57
- function hashObject(input: object): number {
58
- const cachedHash = hashCache.get(input)
59
- if (cachedHash !== undefined) {
60
- return cachedHash
72
+ function hashObject(input: object, context: HashContext): number {
73
+ if (context.activeObjects.size >= MAX_STRUCTURAL_HASH_DEPTH) {
74
+ throw new RangeError(
75
+ `Value is too complex to hash safely: structural depth`,
76
+ )
61
77
  }
62
78
 
79
+ context.activeObjects.add(input)
80
+
63
81
  let valueHash: number | undefined
64
- if (input instanceof Date) {
65
- valueHash = hashDate(input)
66
- } else if (
67
- // Check if input is a Uint8Array or Buffer
68
- (typeof Buffer !== `undefined` && input instanceof Buffer) ||
69
- input instanceof Uint8Array
70
- ) {
71
- // For small Uint8Arrays/Buffers (e.g., ULIDs, UUIDs), hash by content
72
- // to enable proper equality comparisons. For large arrays, hash by reference
73
- // to avoid performance costs.
74
- if (input.byteLength <= UINT8ARRAY_CONTENT_HASH_THRESHOLD) {
82
+ try {
83
+ if (input instanceof Date) {
84
+ valueHash = hashDate(input)
85
+ } else if (isBinaryValue(input)) {
75
86
  valueHash = hashUint8Array(input)
87
+ } else if (isTemporal(input)) {
88
+ valueHash = hashTemporal(input)
76
89
  } else {
77
- // Deeply hashing large arrays would be too costly
78
- // so we track them by reference and cache them in a weak map
79
- return cachedReferenceHash(input)
80
- }
81
- } else if (input instanceof File) {
82
- // Files are always hashed by reference due to their potentially large size
83
- return cachedReferenceHash(input)
84
- } else if (isTemporal(input)) {
85
- valueHash = hashTemporal(input)
86
- } else {
87
- let plainObjectInput = input
88
- let marker = OBJECT_MARKER
89
-
90
- if (input instanceof Array) {
91
- marker = ARRAY_MARKER
92
- }
90
+ let plainObjectInput = input
91
+ let marker = OBJECT_MARKER
93
92
 
94
- if (input instanceof Map) {
95
- marker = MAP_MARKER
96
- plainObjectInput = [...input.entries()]
97
- }
93
+ if (input instanceof Array) {
94
+ marker = ARRAY_MARKER
95
+ }
98
96
 
99
- if (input instanceof Set) {
100
- marker = SET_MARKER
101
- plainObjectInput = [...input.entries()]
102
- }
97
+ if (input instanceof Map) {
98
+ marker = MAP_MARKER
99
+ plainObjectInput = [...input.entries()]
100
+ }
103
101
 
104
- valueHash = hashPlainObject(plainObjectInput, marker)
102
+ if (input instanceof Set) {
103
+ marker = SET_MARKER
104
+ plainObjectInput = [...input.entries()]
105
+ }
106
+
107
+ valueHash = hashPlainObject(plainObjectInput, marker, context)
108
+ }
109
+ } finally {
110
+ context.activeObjects.delete(input)
105
111
  }
106
112
 
107
- hashCache.set(input, valueHash)
113
+ context.pendingHashes.set(input, valueHash)
108
114
  return valueHash
109
115
  }
110
116
 
@@ -135,7 +141,11 @@ function hashTemporal(input: TemporalLike): number {
135
141
  return hasher.digest()
136
142
  }
137
143
 
138
- function hashPlainObject(input: object, marker: number): number {
144
+ function hashPlainObject(
145
+ input: object,
146
+ marker: number,
147
+ context: HashContext,
148
+ ): number {
139
149
  const hasher = new MurmurHashStream()
140
150
 
141
151
  // Mark the type of the input
@@ -145,13 +155,28 @@ function hashPlainObject(input: object, marker: number): number {
145
155
  for (const key of keys) {
146
156
  hasher.update(KEY)
147
157
  hasher.update(key)
148
- updateHasher(hasher, input[key as keyof typeof input])
158
+ updateHasher(hasher, input[key as keyof typeof input], context)
159
+ }
160
+ const symbolKeys = Object.getOwnPropertySymbols(input)
161
+ .filter((key) => Object.prototype.propertyIsEnumerable.call(input, key))
162
+ .sort((left, right) => getSymbolIdentity(left) - getSymbolIdentity(right))
163
+ for (const key of symbolKeys) {
164
+ hasher.update(KEY)
165
+ hasher.update(key)
166
+ updateHasher(hasher, input[key as keyof typeof input], context)
149
167
  }
150
168
 
151
169
  return hasher.digest()
152
170
  }
153
171
 
154
- function updateHasher(hasher: Hasher, input: unknown): void {
172
+ function updateHasher(
173
+ hasher: Hasher,
174
+ input: unknown,
175
+ context?: HashContext,
176
+ ): void {
177
+ if (context && ++context.work > MAX_STRUCTURAL_HASH_WORK) {
178
+ throw new RangeError(`Value is too complex to hash safely: structural work`)
179
+ }
155
180
  if (input === null) {
156
181
  hasher.update(NULL)
157
182
  return
@@ -173,7 +198,7 @@ function updateHasher(hasher: Hasher, input: unknown): void {
173
198
  hasher.update(input)
174
199
  return
175
200
  case `object`:
176
- hasher.update(getCachedHash(input))
201
+ hasher.update(getCachedHash(input, context))
177
202
  return
178
203
  case `function`:
179
204
  // Functions are assigned a globally unique ID
@@ -187,12 +212,53 @@ function updateHasher(hasher: Hasher, input: unknown): void {
187
212
  }
188
213
  }
189
214
 
190
- function getCachedHash(input: object): number {
191
- let valueHash = hashCache.get(input)
192
- if (valueHash === undefined) {
193
- valueHash = hashObject(input)
215
+ function getCachedHash(input: object, context?: HashContext): number {
216
+ if (!context) {
217
+ const cached = hashCache.get(input)
218
+ if (cached !== undefined) return cached
219
+ if (isReferenceHashedObject(input)) return cachedReferenceHash(input)
220
+
221
+ // Only an uncached structural root needs graph traversal state. Commit its
222
+ // cache entries after success so a failed traversal cannot poison retries.
223
+ context = {
224
+ activeObjects: new Set(),
225
+ work: 0,
226
+ pendingHashes: new Map(),
227
+ }
228
+ const result = hashObject(input, context)
229
+ for (const [object, valueHash] of context.pendingHashes) {
230
+ hashCache.set(object, valueHash)
231
+ }
232
+ return result
194
233
  }
195
- return valueHash
234
+
235
+ if (context.activeObjects.has(input)) {
236
+ throw new TypeError(`Cannot hash cyclic structural values`)
237
+ }
238
+
239
+ // Opaque leaves cannot contain structural back-references. Resolve them
240
+ // before entering structural recursion, even when they have user properties.
241
+ if (isReferenceHashedObject(input)) return cachedReferenceHash(input)
242
+
243
+ const valueHash = hashCache.get(input) ?? context.pendingHashes.get(input)
244
+ if (valueHash !== undefined) return valueHash
245
+
246
+ return hashObject(input, context)
247
+ }
248
+
249
+ function isReferenceHashedObject(input: object): boolean {
250
+ return (
251
+ input instanceof File ||
252
+ (isBinaryValue(input) &&
253
+ input.byteLength > UINT8ARRAY_CONTENT_HASH_THRESHOLD)
254
+ )
255
+ }
256
+
257
+ function isBinaryValue(input: object): input is Uint8Array {
258
+ return (
259
+ (typeof Buffer !== `undefined` && input instanceof Buffer) ||
260
+ input instanceof Uint8Array
261
+ )
196
262
  }
197
263
 
198
264
  let nextRefId = 1
@@ -9,6 +9,49 @@ const BIG_INT_MARKER = randomHash()
9
9
  const NEG_BIG_INT_MARKER = randomHash()
10
10
  const SYMBOL_MARKER = randomHash()
11
11
 
12
+ type SymbolIdStore = {
13
+ get: (key: symbol) => number | undefined
14
+ set: (key: symbol, value: number) => unknown
15
+ }
16
+
17
+ const symbolIds = createSymbolIdStore()
18
+ const registeredSymbolIds = new Map<string, number>()
19
+ let nextSymbolId = 0
20
+
21
+ export function getSymbolIdentity(symbol: symbol): number {
22
+ const registeredKey = Symbol.keyFor(symbol)
23
+ if (registeredKey !== undefined) {
24
+ let id = registeredSymbolIds.get(registeredKey)
25
+ if (id === undefined) {
26
+ id = ++nextSymbolId
27
+ registeredSymbolIds.set(registeredKey, id)
28
+ }
29
+ return id
30
+ }
31
+
32
+ let id = symbolIds.get(symbol)
33
+ if (id === undefined) {
34
+ id = ++nextSymbolId
35
+ symbolIds.set(symbol, id)
36
+ }
37
+ return id
38
+ }
39
+
40
+ function createSymbolIdStore(): SymbolIdStore {
41
+ const weakIds = new WeakMap<object, number>() as unknown as SymbolIdStore
42
+ const probe = Symbol()
43
+
44
+ try {
45
+ weakIds.set(probe, 0)
46
+ if (weakIds.get(probe) === 0) return weakIds
47
+ } catch {
48
+ // Older runtimes reject symbols as weak keys. Retain them rather than
49
+ // merge distinct symbols and corrupt differential state.
50
+ }
51
+
52
+ return new Map<symbol, number>()
53
+ }
54
+
12
55
  export type Hash = number
13
56
 
14
57
  export function randomHash() {
@@ -67,16 +110,7 @@ export class MurmurHashStream implements Hasher {
67
110
  switch (typeof chunk) {
68
111
  case `symbol`: {
69
112
  this.update(SYMBOL_MARKER)
70
- const description = chunk.description
71
- if (!description) {
72
- return
73
- }
74
-
75
- for (let i = 0; i < description.length; i++) {
76
- const code = description.charCodeAt(i)
77
- this.writeByte(code & 0xff)
78
- this.writeByte((code >>> 8) & 0xff)
79
- }
113
+ this.update(getSymbolIdentity(chunk))
80
114
  return
81
115
  }
82
116
  case `string`:
package/src/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from './multiset.js'
3
3
  export * from './operators/index.js'
4
4
  export * from './types.js'
5
5
  export { compareKeys, serializeValue } from './utils.js'
6
+ export { registerOpaqueHash } from './hashing/hash.js'
@@ -62,7 +62,7 @@ export function groupBy<
62
62
  stream: IStreamBuilder<T>,
63
63
  ): IStreamBuilder<KeyValue<string, ResultType>> => {
64
64
  // Special key to store the original key object
65
- const KEY_SENTINEL = `__original_key__`
65
+ const KEY_SENTINEL = Symbol(`original_group_key`)
66
66
 
67
67
  // First map to extract keys and pre-aggregate values
68
68
  const withKeysAndValues = stream.pipe(
@@ -71,7 +71,7 @@ export function groupBy<
71
71
  const keyString = serializeValue(key)
72
72
 
73
73
  // Create values object with pre-aggregated values
74
- const values: Record<string, unknown> = {}
74
+ const values: Record<string | symbol, unknown> = {}
75
75
 
76
76
  // Store the original key object
77
77
  values[KEY_SENTINEL] = key
@@ -81,7 +81,10 @@ export function groupBy<
81
81
  values[name] = aggregate.preMap(data)
82
82
  }
83
83
 
84
- return [keyString, values] as KeyValue<string, Record<string, unknown>>
84
+ return [keyString, values] as KeyValue<
85
+ string,
86
+ Record<string | symbol, unknown>
87
+ >
85
88
  }),
86
89
  )
87
90
 
@@ -99,7 +102,7 @@ export function groupBy<
99
102
  return []
100
103
  }
101
104
 
102
- const result: Record<string, unknown> = {}
105
+ const result: Record<PropertyKey, unknown> = {}
103
106
 
104
107
  // Get the original key from first value in group
105
108
  const originalKey = values[0]?.[0]?.[KEY_SENTINEL]
package/src/utils.ts CHANGED
@@ -185,6 +185,14 @@ function range(start: number, end: number): Array<number> {
185
185
  export function compareKeys(a: string | number, b: string | number): number {
186
186
  // Same type: compare directly
187
187
  if (typeof a === typeof b) {
188
+ if (typeof a === `number` && typeof b === `number`) {
189
+ const aIsNaN = Number.isNaN(a)
190
+ const bIsNaN = Number.isNaN(b)
191
+ if (aIsNaN || bIsNaN) {
192
+ if (aIsNaN && bIsNaN) return 0
193
+ return aIsNaN ? 1 : -1
194
+ }
195
+ }
188
196
  if (a < b) return -1
189
197
  if (a > b) return 1
190
198
  return 0
@@ -1,3 +0,0 @@
1
- import { KeyValue } from '../types.js';
2
- import { OrderByOptions } from './orderBy.js';
3
- export declare function orderByWithFractionalIndexBTree<T extends KeyValue<unknown, unknown>, Ve = unknown>(valueExtractor: (value: T extends KeyValue<unknown, infer V> ? V : never) => Ve, options?: OrderByOptions<Ve>): (stream: import('../types.js').IStreamBuilder<T>) => import('../types.js').IStreamBuilder<[T extends KeyValue<infer K, unknown> ? K : never, [T extends KeyValue<unknown, infer V> ? V : never, string]]>;
@@ -1,3 +0,0 @@
1
- import { KeyValue } from '../types.js';
2
- import { OrderByOptions } from './orderBy.js';
3
- export declare function orderByWithFractionalIndexBTree<T extends KeyValue<unknown, unknown>, Ve = unknown>(valueExtractor: (value: T extends KeyValue<unknown, infer V> ? V : never) => Ve, options?: OrderByOptions<Ve>): (stream: import('../types.js').IStreamBuilder<T>) => import('../types.js').IStreamBuilder<[T extends KeyValue<infer K, unknown> ? K : never, [T extends KeyValue<unknown, infer V> ? V : never, string]]>;
@@ -1,20 +0,0 @@
1
- import { orderByWithFractionalIndexBase } from './orderBy.js'
2
- import { topKWithFractionalIndexBTree } from './topKWithFractionalIndexBTree.js'
3
- import type { KeyValue } from '../types.js'
4
- import type { OrderByOptions } from './orderBy.js'
5
-
6
- export function orderByWithFractionalIndexBTree<
7
- T extends KeyValue<unknown, unknown>,
8
- Ve = unknown,
9
- >(
10
- valueExtractor: (
11
- value: T extends KeyValue<unknown, infer V> ? V : never,
12
- ) => Ve,
13
- options?: OrderByOptions<Ve>,
14
- ) {
15
- return orderByWithFractionalIndexBase(
16
- topKWithFractionalIndexBTree,
17
- valueExtractor,
18
- options,
19
- )
20
- }