@tanstack/db-ivm 0.1.22 → 0.1.25

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.
Files changed (42) hide show
  1. package/dist/cjs/d2.cjs +12 -2
  2. package/dist/cjs/d2.cjs.map +1 -1
  3. package/dist/cjs/hashing/hash.cjs +75 -121
  4. package/dist/cjs/hashing/hash.cjs.map +1 -1
  5. package/dist/cjs/hashing/temporal.cjs +18 -0
  6. package/dist/cjs/hashing/temporal.cjs.map +1 -0
  7. package/dist/cjs/hashing/temporal.d.cts +5 -0
  8. package/dist/cjs/indexes.cjs +6 -3
  9. package/dist/cjs/indexes.cjs.map +1 -1
  10. package/dist/cjs/multiset.cjs +55 -92
  11. package/dist/cjs/multiset.cjs.map +1 -1
  12. package/dist/cjs/multiset.d.cts +15 -0
  13. package/dist/cjs/operators/groupBy.cjs +2 -2
  14. package/dist/cjs/operators/groupBy.cjs.map +1 -1
  15. package/dist/cjs/utils.cjs +17 -61
  16. package/dist/cjs/utils.cjs.map +1 -1
  17. package/dist/cjs/utils.d.cts +3 -32
  18. package/dist/esm/d2.js +12 -2
  19. package/dist/esm/d2.js.map +1 -1
  20. package/dist/esm/hashing/hash.js +75 -121
  21. package/dist/esm/hashing/hash.js.map +1 -1
  22. package/dist/esm/hashing/temporal.d.ts +5 -0
  23. package/dist/esm/hashing/temporal.js +18 -0
  24. package/dist/esm/hashing/temporal.js.map +1 -0
  25. package/dist/esm/indexes.js +6 -3
  26. package/dist/esm/indexes.js.map +1 -1
  27. package/dist/esm/multiset.d.ts +15 -0
  28. package/dist/esm/multiset.js +56 -93
  29. package/dist/esm/multiset.js.map +1 -1
  30. package/dist/esm/operators/groupBy.js +2 -2
  31. package/dist/esm/operators/groupBy.js.map +1 -1
  32. package/dist/esm/utils.d.ts +3 -32
  33. package/dist/esm/utils.js +17 -61
  34. package/dist/esm/utils.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/d2.ts +15 -2
  37. package/src/hashing/hash.ts +118 -172
  38. package/src/hashing/temporal.ts +25 -0
  39. package/src/indexes.ts +8 -3
  40. package/src/multiset.ts +79 -132
  41. package/src/operators/groupBy.ts +8 -2
  42. package/src/utils.ts +23 -75
@@ -1,4 +1,5 @@
1
1
  import { MurmurHashStream, getSymbolIdentity, randomHash } from './murmur.js'
2
+ import { isTemporal } from './temporal.js'
2
3
  import type { Hasher } from './murmur.js'
3
4
 
4
5
  /*
@@ -12,41 +13,20 @@ const NULL = randomHash()
12
13
  const UNDEFINED = randomHash()
13
14
  const KEY = randomHash()
14
15
  const FUNCTIONS = randomHash()
15
- const DATE_MARKER = randomHash()
16
- const STRUCTURAL_MARKERS = {
17
- object: randomHash(),
18
- array: randomHash(),
19
- map: randomHash(),
20
- set: randomHash(),
21
- }
22
- const UINT8ARRAY_MARKER = randomHash()
23
- const TEMPORAL_MARKER = randomHash()
16
+ // Identity is distinct even if two random hash values happen to collide.
17
+ const DATE_MARKER = { hash: randomHash() }
18
+ const REGEXP_MARKER = { hash: randomHash() }
19
+ const OBJECT_MARKER = { hash: randomHash() }
20
+ const ARRAY_MARKER = { hash: randomHash() }
21
+ const MAP_MARKER = { hash: randomHash() }
22
+ const SET_MARKER = { hash: randomHash() }
23
+ const UINT8ARRAY_MARKER = { hash: randomHash() }
24
+ const TEMPORAL_MARKER = { hash: randomHash() }
24
25
  // Bound structural recursion and value visits. Shared acyclic subtrees are
25
26
  // cached; cycles are rejected rather than given context-dependent hashes.
26
27
  const MAX_STRUCTURAL_HASH_WORK = 1_000_000
27
28
  const MAX_STRUCTURAL_HASH_DEPTH = 768
28
29
 
29
- const temporalTypes = new Set([
30
- `Temporal.Duration`,
31
- `Temporal.Instant`,
32
- `Temporal.PlainDate`,
33
- `Temporal.PlainDateTime`,
34
- `Temporal.PlainMonthDay`,
35
- `Temporal.PlainTime`,
36
- `Temporal.PlainYearMonth`,
37
- `Temporal.ZonedDateTime`,
38
- ])
39
-
40
- interface TemporalLike {
41
- [Symbol.toStringTag]: string
42
- toString: () => string
43
- }
44
-
45
- function isTemporal(input: object): input is TemporalLike {
46
- const tag = (input as Record<symbol, unknown>)[Symbol.toStringTag]
47
- return typeof tag === `string` && temporalTypes.has(tag)
48
- }
49
-
50
30
  // Maximum byte length for Uint8Arrays to hash by content instead of reference
51
31
  // Arrays smaller than this will be hashed by content, allowing proper equality comparisons
52
32
  // for small arrays like ULIDs (16 bytes) while still avoiding performance costs for large arrays
@@ -83,20 +63,33 @@ function hashObject(input: object, context: HashContext): number {
83
63
 
84
64
  let valueHash: number | undefined
85
65
  try {
86
- if (input instanceof Date) {
87
- valueHash = hashDate(input)
88
- } else if (isBinaryValue(input)) {
89
- valueHash = hashUint8Array(input)
90
- } else if (isTemporal(input)) {
91
- valueHash = hashTemporal(input)
92
- } else {
93
- const [kind, plainObjectInput] = structuralShape(input)
94
- valueHash = hashPlainObject(
95
- plainObjectInput,
96
- STRUCTURAL_MARKERS[kind],
97
- context,
66
+ const [marker, header, body] = objectParts(input)
67
+ const hasher = new MurmurHashStream()
68
+ hasher.update(marker.hash)
69
+ // Header values of a type with a body (array length, RegExp fields)
70
+ // count toward the work cap; Date, binary, and Temporal headers do not.
71
+ for (const value of header)
72
+ if (body) updateHasher(hasher, value, context)
73
+ else hasher.update(value as string | number)
74
+ if (body) {
75
+ // Sorted string keys, then enumerable symbol keys by identity.
76
+ const keys: Array<string | symbol> = Object.keys(body).sort(keySort)
77
+ keys.push(
78
+ ...Object.getOwnPropertySymbols(body)
79
+ .filter((key) =>
80
+ Object.prototype.propertyIsEnumerable.call(body, key),
81
+ )
82
+ .sort(
83
+ (left, right) => getSymbolIdentity(left) - getSymbolIdentity(right),
84
+ ),
98
85
  )
86
+ for (const key of keys) {
87
+ hasher.update(KEY)
88
+ hasher.update(key)
89
+ updateHasher(hasher, body[key as keyof typeof body], context)
90
+ }
99
91
  }
92
+ valueHash = hasher.digest()
100
93
  } finally {
101
94
  context.activeObjects.delete(input)
102
95
  }
@@ -105,59 +98,39 @@ function hashObject(input: object, context: HashContext): number {
105
98
  return valueHash
106
99
  }
107
100
 
108
- function hashDate(input: Date): number {
109
- const hasher = new MurmurHashStream()
110
- hasher.update(DATE_MARKER)
111
- hasher.update(input.getTime())
112
- return hasher.digest()
113
- }
114
-
115
- function hashUint8Array(input: Uint8Array): number {
116
- const hasher = new MurmurHashStream()
117
- hasher.update(UINT8ARRAY_MARKER)
118
- // Hash the byte length first to differentiate arrays of different sizes
119
- hasher.update(input.byteLength)
120
- // Hash each byte in the array
121
- for (let i = 0; i < input.byteLength; i++) {
122
- hasher.writeByte(input[i]!)
123
- }
124
- return hasher.digest()
125
- }
126
-
127
- function hashTemporal(input: TemporalLike): number {
128
- const hasher = new MurmurHashStream()
129
- hasher.update(TEMPORAL_MARKER)
130
- hasher.update(input[Symbol.toStringTag])
131
- hasher.update(input.toString())
132
- return hasher.digest()
133
- }
134
-
135
- function hashPlainObject(
136
- input: object,
137
- marker: number,
138
- context: HashContext,
139
- ): number {
140
- const hasher = new MurmurHashStream()
141
-
142
- // Mark the type of the input
143
- hasher.update(marker)
144
- const keys = Object.keys(input)
145
- keys.sort(keySort)
146
- for (const key of keys) {
147
- hasher.update(KEY)
148
- hasher.update(key)
149
- updateHasher(hasher, input[key as keyof typeof input], context)
150
- }
151
- const symbolKeys = Object.getOwnPropertySymbols(input)
152
- .filter((key) => Object.prototype.propertyIsEnumerable.call(input, key))
153
- .sort((left, right) => getSymbolIdentity(left) - getSymbolIdentity(right))
154
- for (const key of symbolKeys) {
155
- hasher.update(KEY)
156
- hasher.update(key)
157
- updateHasher(hasher, input[key as keyof typeof input], context)
101
+ /**
102
+ * The one type dispatch shared by structural hashing and equality: a type
103
+ * marker, primitive header values, and an optional object whose enumerable
104
+ * own properties are compared structurally.
105
+ */
106
+ type ObjectParts = [
107
+ marker: Readonly<{ hash: number }>,
108
+ // A RegExp `lastIndex` can hold any value; the other headers are strings
109
+ // or numbers.
110
+ header: ReadonlyArray<unknown>,
111
+ body?: object,
112
+ ]
113
+
114
+ function objectParts(input: object): ObjectParts {
115
+ if (input instanceof Date) {
116
+ const time = input.getTime()
117
+ // Invalid dates are equal to each other; headers compare with `===`.
118
+ return [DATE_MARKER, [Number.isNaN(time) ? `invalid` : time]]
158
119
  }
159
-
160
- return hasher.digest()
120
+ if (isBinaryValue(input))
121
+ return [
122
+ UINT8ARRAY_MARKER,
123
+ [String.fromCharCode.apply(null, input as unknown as Array<number>)],
124
+ ]
125
+ if (isTemporal(input))
126
+ return [TEMPORAL_MARKER, [input[Symbol.toStringTag], input.toString()]]
127
+ if (input instanceof RegExp)
128
+ return [REGEXP_MARKER, [input.source, input.flags, input.lastIndex], input]
129
+ if (input instanceof Map) return [MAP_MARKER, [], [...input.entries()]]
130
+ if (input instanceof Set) return [SET_MARKER, [], [...input.values()]]
131
+ return input instanceof Array
132
+ ? [ARRAY_MARKER, [input.length], input]
133
+ : [OBJECT_MARKER, [], input]
161
134
  }
162
135
 
163
136
  function updateHasher(
@@ -195,56 +168,50 @@ function updateHasher(
195
168
  // Functions are assigned a globally unique ID
196
169
  // and that ID is cached in the weak map
197
170
  hasher.update(cachedReferenceHash(input))
198
- return
199
- default:
200
- console.warn(
201
- `Ignored input during hashing because it is of type ${typeof input} which is not supported`,
202
- )
203
171
  }
204
172
  }
205
173
 
206
174
  function getCachedHash(input: object, context?: HashContext): number {
207
- if (!context) {
208
- const cached = hashCache.get(input)
209
- if (cached !== undefined) return cached
210
- if (isReferenceHashedObject(input)) return cachedReferenceHash(input)
211
-
212
- // Only an uncached structural root needs graph traversal state. Commit its
213
- // cache entries after success so a failed traversal cannot poison retries.
214
- context = {
215
- activeObjects: new Set(),
216
- work: 0,
217
- pendingHashes: new Map(),
218
- }
219
- const result = hashObject(input, context)
220
- for (const [object, valueHash] of context.pendingHashes) {
221
- hashCache.set(object, valueHash)
222
- }
223
- return result
224
- }
225
-
226
- if (context.activeObjects.has(input)) {
227
- throw new TypeError(`Cannot hash cyclic structural values`)
228
- }
175
+ // Active objects are neither cached nor pending, so the cycle check below
176
+ // still sees every back-reference.
177
+ const cached = hashCache.get(input) ?? context?.pendingHashes.get(input)
178
+ if (cached !== undefined) return cached
229
179
 
230
180
  // Opaque leaves cannot contain structural back-references. Resolve them
231
181
  // before entering structural recursion, even when they have user properties.
232
182
  if (isReferenceHashedObject(input)) return cachedReferenceHash(input)
233
183
 
234
- const valueHash = hashCache.get(input) ?? context.pendingHashes.get(input)
235
- if (valueHash !== undefined) return valueHash
184
+ if (context) {
185
+ if (context.activeObjects.has(input)) {
186
+ throw new TypeError(`Cannot hash cyclic structural values`)
187
+ }
188
+ return hashObject(input, context)
189
+ }
236
190
 
237
- return hashObject(input, context)
191
+ // Only an uncached structural root needs graph traversal state. Commit its
192
+ // cache entries after success so a failed traversal cannot poison retries.
193
+ const root: HashContext = {
194
+ activeObjects: new Set(),
195
+ work: 0,
196
+ pendingHashes: new Map(),
197
+ }
198
+ const result = hashObject(input, root)
199
+ for (const [object, valueHash] of root.pendingHashes) {
200
+ hashCache.set(object, valueHash)
201
+ }
202
+ return result
238
203
  }
239
204
 
240
205
  function isReferenceHashedObject(input: object): boolean {
241
206
  return (
242
- input instanceof File ||
207
+ referenceValues.has(input) ||
208
+ (typeof File !== `undefined` && input instanceof File) ||
243
209
  (isBinaryValue(input) &&
244
210
  input.byteLength > UINT8ARRAY_CONTENT_HASH_THRESHOLD)
245
211
  )
246
212
  }
247
213
 
214
+ // Buffer can come from another realm than Uint8Array (for example under jsdom).
248
215
  function isBinaryValue(input: object): input is Uint8Array {
249
216
  return (
250
217
  (typeof Buffer !== `undefined` && input instanceof Buffer) ||
@@ -252,75 +219,54 @@ function isBinaryValue(input: object): input is Uint8Array {
252
219
  )
253
220
  }
254
221
 
255
- function structuralShape(
256
- input: object,
257
- ): [keyof typeof STRUCTURAL_MARKERS, object] {
258
- if (input instanceof Map) return [`map`, [...input.entries()]]
259
- if (input instanceof Set) return [`set`, [...input.values()]]
260
- return [input instanceof Array ? `array` : `object`, input]
261
- }
262
-
263
222
  /** @internal Compare immutable structural values without computing a digest.
264
223
  * Pair memoization also permits cyclic values that structural hashing rejects.
265
224
  * Reference-valued leaves remain opaque, including registered mutable handles.
266
225
  */
267
226
  export function equalHashValues(left: unknown, right: unknown): boolean {
268
227
  const compared = new Map<object, Set<object>>()
228
+ const keys = (value: object) =>
229
+ Reflect.ownKeys(value).filter((key) =>
230
+ Object.prototype.propertyIsEnumerable.call(value, key),
231
+ )
269
232
  function equal(a: unknown, b: unknown): boolean {
270
233
  if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true
271
234
  if (
272
235
  a === null ||
273
236
  b === null ||
274
237
  typeof a !== `object` ||
275
- typeof b !== `object`
276
- )
277
- return false
278
- if (
279
- referenceValues.has(a) ||
280
- referenceValues.has(b) ||
238
+ typeof b !== `object` ||
281
239
  isReferenceHashedObject(a) ||
282
240
  isReferenceHashedObject(b)
283
241
  )
284
242
  return false
285
- if (a instanceof Date || b instanceof Date)
286
- return (
287
- a instanceof Date &&
288
- b instanceof Date &&
289
- equal(a.getTime(), b.getTime())
290
- )
291
- if (isBinaryValue(a) || isBinaryValue(b))
292
- return (
293
- isBinaryValue(a) &&
294
- isBinaryValue(b) &&
295
- a.byteLength === b.byteLength &&
296
- a.every((value, index) => value === b[index])
297
- )
298
- if (isTemporal(a) || isTemporal(b))
299
- return (
300
- isTemporal(a) &&
301
- isTemporal(b) &&
302
- a[Symbol.toStringTag] === b[Symbol.toStringTag] &&
303
- a.toString() === b.toString()
304
- )
243
+ // Arrays from another realm take the object marker but keep this check.
244
+ if (Array.isArray(a) && Array.isArray(b) && a.length !== b.length)
245
+ return false
305
246
 
306
- // Revisited pairs close cycles and avoid expanding shared subtrees.
247
+ // Revisited pairs close cycles and avoid expanding shared subtrees. Check
248
+ // before objectParts copies Map or Set entries; record only pairs with a
249
+ // body, after their markers and headers match.
307
250
  const peers = compared.get(a)
308
251
  if (peers?.has(b)) return true
252
+ const [aMarker, aHeader, aBody] = objectParts(a)
253
+ const [bMarker, bHeader, bBody] = objectParts(b)
254
+ if (
255
+ aMarker !== bMarker ||
256
+ !aHeader.every((value, index) => value === bHeader[index])
257
+ )
258
+ return false
259
+ if (!aBody || !bBody) return true
309
260
  if (peers) peers.add(b)
310
261
  else compared.set(a, new Set([b]))
311
- const [aKind, aShape] = structuralShape(a)
312
- const [bKind, bShape] = structuralShape(b)
313
- if (aKind !== bKind) return false
314
- const keys = (value: object) =>
315
- Reflect.ownKeys(value).filter((key) =>
316
- Object.prototype.propertyIsEnumerable.call(value, key),
262
+ const aKeys = keys(aBody)
263
+ return (
264
+ aKeys.length === keys(bBody).length &&
265
+ aKeys.every(
266
+ (key) =>
267
+ Object.prototype.propertyIsEnumerable.call(bBody, key) &&
268
+ equal(aBody[key as keyof object], bBody[key as keyof object]),
317
269
  )
318
- const aKeys = keys(aShape)
319
- if (aKeys.length !== keys(bShape).length) return false
320
- return aKeys.every(
321
- (key) =>
322
- Object.prototype.propertyIsEnumerable.call(bShape, key) &&
323
- equal(aShape[key as keyof object], bShape[key as keyof object]),
324
270
  )
325
271
  }
326
272
  return equal(left, right)
@@ -0,0 +1,25 @@
1
+ const temporalTypes = new Set([
2
+ `Temporal.Duration`,
3
+ `Temporal.Instant`,
4
+ `Temporal.PlainDate`,
5
+ `Temporal.PlainDateTime`,
6
+ `Temporal.PlainMonthDay`,
7
+ `Temporal.PlainTime`,
8
+ `Temporal.PlainYearMonth`,
9
+ `Temporal.ZonedDateTime`,
10
+ ])
11
+
12
+ export interface TemporalLike {
13
+ [Symbol.toStringTag]: string
14
+ toString: () => string
15
+ }
16
+
17
+ export function isTemporal(input: object): input is TemporalLike {
18
+ const tag = (input as Record<symbol, unknown>)[Symbol.toStringTag]
19
+ // An own tag on a plain object does not make it a Temporal value.
20
+ return (
21
+ typeof tag === `string` &&
22
+ temporalTypes.has(tag) &&
23
+ Object.getPrototypeOf(input)?.[Symbol.toStringTag] === tag
24
+ )
25
+ }
package/src/indexes.ts CHANGED
@@ -69,7 +69,7 @@ class PrefixMap<TValue, TPrefix> extends Map<
69
69
  const [currentValue, currentMultiplicity] = valueMapOrSingleValue
70
70
  const currentPrefix = getPrefix<TValue, TPrefix>(currentValue)
71
71
 
72
- if (currentPrefix !== prefix) {
72
+ if (!isSamePrefix(currentPrefix, prefix)) {
73
73
  throw new Error(`Mismatching prefixes, this should never happen`)
74
74
  }
75
75
 
@@ -383,7 +383,7 @@ export class Index<TKey, TValue, TPrefix = any> {
383
383
 
384
384
  // Check if they're the same value by prefix/suffix comparison
385
385
  if (
386
- currentPrefix === newPrefix &&
386
+ isSamePrefix(currentPrefix, newPrefix) &&
387
387
  (currentValue === newValue || hash(currentValue) === hash(newValue))
388
388
  ) {
389
389
  const newMultiplicity = currentMultiplicity + multiplicity
@@ -406,7 +406,7 @@ export class Index<TKey, TValue, TPrefix = any> {
406
406
  // At least one has a prefix, use PrefixMap
407
407
  const prefixMap = new PrefixMap<TValue, TPrefix>()
408
408
 
409
- if (currentPrefix === newPrefix) {
409
+ if (isSamePrefix(currentPrefix, newPrefix)) {
410
410
  // Same prefix, different suffixes - need ValueMap within PrefixMap
411
411
  const valueMap = new ValueMap<TValue>()
412
412
  valueMap.set(hash(currentValue), currentSingleValue)
@@ -478,6 +478,11 @@ export class Index<TKey, TValue, TPrefix = any> {
478
478
  * @param value - The value to extract the prefix from.
479
479
  * @returns The prefix and the suffix.
480
480
  */
481
+ // Prefixes are Map keys, so they compare as a Map does: NaN equals NaN.
482
+ function isSamePrefix(a: unknown, b: unknown): boolean {
483
+ return a === b || (Number.isNaN(a) && Number.isNaN(b))
484
+ }
485
+
481
486
  function getPrefix<TValue, TPrefix>(value: TValue): TPrefix | NO_PREFIX {
482
487
  // If the value is an array and the first element is a string or number, then the
483
488
  // first element is the prefix. This is used to distinguish between values without
package/src/multiset.ts CHANGED
@@ -1,8 +1,4 @@
1
- import {
2
- DefaultMap,
3
- chunkedArrayPush,
4
- globalObjectIdGenerator,
5
- } from './utils.js'
1
+ import { chunkedArrayPush, getStringId } from './utils.js'
6
2
  import { hash } from './hashing/index.js'
7
3
 
8
4
  export type MultiSetArray<T> = Array<[T, number]>
@@ -69,139 +65,65 @@ export class MultiSet<T> {
69
65
  * Produce as output a collection that is logically equivalent to the input
70
66
  * but which combines identical instances of the same record into one
71
67
  * (record, multiplicity) pair.
72
- */
73
- consolidate(): MultiSet<T> {
74
- // Check if this looks like a keyed multiset (first item is a tuple of length 2)
75
- if (this.#inner.length > 0) {
76
- const firstItem = this.#inner[0]?.[0]
77
- if (Array.isArray(firstItem) && firstItem.length === 2) {
78
- return this.#consolidateKeyed()
79
- }
80
- }
81
-
82
- // Fall back to original method for unkeyed data
83
- return this.#consolidateUnkeyed()
84
- }
85
-
86
- /**
87
- * Private method for consolidating keyed multisets where keys are strings/numbers
88
- * and values are compared by reference equality.
89
68
  *
90
- * This method provides significant performance improvements over the hash-based approach
91
- * by using WeakMap for object reference tracking and avoiding expensive serialization.
69
+ * Record identity depends on the shape of the whole multiset:
70
+ *
71
+ * - Keyed: every record is a `[key, value]` pair with a string or number
72
+ * key. Keys and primitive values compare by value, and object values by
73
+ * reference. A value that is an array of length 2 is a join tuple, and
74
+ * its two elements compare by the same rule.
75
+ * - Unkeyed, one primitive type: every record is a string, or every record
76
+ * is a number. Records compare as `Map` keys do, and a `-0` record is
77
+ * returned as `0`.
78
+ * - Otherwise, records compare by structure (`hash`).
92
79
  *
93
- * Special handling for join operations: When values are tuples of length 2 (common in joins),
94
- * we unpack them and compare each element individually to maintain proper equality semantics.
80
+ * Each identity keeps its first record in input order with the summed
81
+ * multiplicity. Identities whose sum is zero are dropped. The input is not
82
+ * changed.
95
83
  */
96
- #consolidateKeyed(): MultiSet<T> {
97
- const consolidated = new Map<string, number>()
98
- const values = new Map<string, T>()
99
-
100
- // Use global object ID generator for consistent reference equality
101
-
102
- /**
103
- * Special handler for tuples (arrays of length 2) commonly produced by join operations.
104
- * Unpacks the tuple and generates an ID based on both elements to ensure proper
105
- * consolidation of join results like ['A', null] and [null, 'X'].
106
- */
107
- const getTupleId = (tuple: Array<any>): string => {
108
- if (tuple.length !== 2) {
109
- throw new Error(`Expected tuple of length 2`)
110
- }
111
- const [first, second] = tuple
112
- return `${globalObjectIdGenerator.getStringId(first)}|${globalObjectIdGenerator.getStringId(second)}`
113
- }
114
-
115
- // Process each item in the multiset
116
- for (const [data, multiplicity] of this.#inner) {
117
- // Verify this is still a keyed item (should be [key, value] pair)
118
- if (!Array.isArray(data) || data.length !== 2) {
119
- // Found non-keyed item, fall back to unkeyed consolidation
120
- return this.#consolidateUnkeyed()
121
- }
122
-
123
- const [key, value] = data
124
-
125
- // Verify key is string or number as expected for keyed multisets
126
- if (typeof key !== `string` && typeof key !== `number`) {
127
- // Found non-string/number key, fall back to unkeyed consolidation
128
- return this.#consolidateUnkeyed()
129
- }
130
-
131
- // Generate value ID with special handling for join tuples
132
- let valueId: string
133
- if (Array.isArray(value) && value.length === 2) {
134
- // Special case: value is a tuple from join operations
135
- valueId = getTupleId(value)
136
- } else {
137
- // Regular case: use reference/value equality
138
- valueId = globalObjectIdGenerator.getStringId(value)
139
- }
140
-
141
- // Create composite key and consolidate
142
- const compositeKey = key + `|` + valueId
143
- consolidated.set(
144
- compositeKey,
145
- (consolidated.get(compositeKey) || 0) + multiplicity,
84
+ consolidate(): MultiSet<T> {
85
+ const inner = this.#inner
86
+ // Keyed multisets hold [string | number key, value] pairs. Their values are
87
+ // compared by reference, which avoids hashing. Join tuples (values of
88
+ // length 2) are unpacked so ['A', null] and [null, 'X'] stay distinct.
89
+ if (
90
+ inner.every(
91
+ ([data]) =>
92
+ Array.isArray(data) &&
93
+ data.length === 2 &&
94
+ (typeof data[0] === `string` || typeof data[0] === `number`),
146
95
  )
147
-
148
- // Store the original data for the first occurrence
149
- if (!values.has(compositeKey)) {
150
- values.set(compositeKey, data as T)
151
- }
96
+ ) {
97
+ const referenceIds = new Map<object | symbol, number>()
98
+ return consolidateBy(inner, (data) => {
99
+ const [key, value] = data as [string | number, unknown]
100
+ // Length prefixes keep user text from crossing component boundaries.
101
+ let valueId: string
102
+ if (Array.isArray(value) && value.length === 2) {
103
+ const firstId = getStringId(value[0], referenceIds)
104
+ const secondId = getStringId(value[1], referenceIds)
105
+ valueId = `tuple_${firstId.length}:${firstId}${secondId}`
106
+ } else {
107
+ valueId = `leaf_${getStringId(value, referenceIds)}`
108
+ }
109
+ const keyId = getStringId(key, referenceIds)
110
+ return `${keyId.length}:${keyId}${valueId}`
111
+ })
152
112
  }
153
113
 
154
- // Build result array, filtering out zero multiplicities
155
- const result: MultiSetArray<T> = []
156
- for (const [compositeKey, multiplicity] of consolidated) {
157
- if (multiplicity !== 0) {
158
- result.push([values.get(compositeKey)!, multiplicity])
159
- }
160
- }
161
-
162
- return new MultiSet(result)
163
- }
164
-
165
- /**
166
- * Private method for consolidating unkeyed multisets using the original approach.
167
- */
168
- #consolidateUnkeyed(): MultiSet<T> {
169
- const consolidated = new DefaultMap<string | number, number>(() => 0)
170
- const values = new Map<string, any>()
171
-
172
- let hasString = false
173
- let hasNumber = false
174
- let hasOther = false
175
- for (const [data, _] of this.#inner) {
176
- if (typeof data === `string`) {
177
- hasString = true
178
- } else if (typeof data === `number`) {
179
- hasNumber = true
180
- } else {
181
- hasOther = true
182
- break
183
- }
184
- }
185
-
186
- const requireJson = hasOther || (hasString && hasNumber)
187
-
188
- for (const [data, multiplicity] of this.#inner) {
189
- const key = requireJson ? hash(data) : (data as string | number)
190
- if (requireJson && !values.has(key as string)) {
191
- values.set(key as string, data)
192
- }
193
- consolidated.update(key, (count) => count + multiplicity)
194
- }
195
-
196
- const result: MultiSetArray<T> = []
197
- for (const [key, multiplicity] of consolidated.entries()) {
198
- if (multiplicity !== 0) {
199
- const parsedKey = requireJson ? values.get(key as string) : key
200
- result.push([parsedKey as T, multiplicity])
201
- }
202
- }
203
-
204
- return new MultiSet(result)
114
+ // Unkeyed data of a single primitive type is its own identity, and the
115
+ // result holds the Map key, so -0 becomes 0. Anything else is compared
116
+ // structurally by hash.
117
+ let primitiveType: string | undefined
118
+ const requireHash = inner.some(([data]) => {
119
+ const type = typeof data
120
+ if (type !== `string` && type !== `number`) return true
121
+ primitiveType ??= type
122
+ return type !== primitiveType
123
+ })
124
+ return requireHash
125
+ ? consolidateBy(inner, hash)
126
+ : consolidateBy(inner, (data) => data, true)
205
127
  }
206
128
 
207
129
  extend(other: MultiSet<T> | MultiSetArray<T>): void {
@@ -219,3 +141,28 @@ export class MultiSet<T> {
219
141
  return this.#inner
220
142
  }
221
143
  }
144
+
145
+ /**
146
+ * Sums multiplicities of records with the same identity, keeping the first
147
+ * record seen for each identity (or its Map key, which writes -0 as 0, when
148
+ * `keepIdentity` is set) and dropping records whose sum is zero.
149
+ */
150
+ function consolidateBy<T>(
151
+ inner: MultiSetArray<T>,
152
+ identityOf: (data: T) => unknown,
153
+ keepIdentity = false,
154
+ ): MultiSet<T> {
155
+ const consolidated = new Map<unknown, [T, number]>()
156
+ for (const [data, multiplicity] of inner) {
157
+ const identity = identityOf(data)
158
+ const entry = consolidated.get(identity)
159
+ if (entry) entry[1] += multiplicity
160
+ else consolidated.set(identity, [data, multiplicity])
161
+ }
162
+ const result: MultiSetArray<T> = []
163
+ for (const [identity, [data, multiplicity]] of consolidated) {
164
+ if (multiplicity !== 0)
165
+ result.push([keepIdentity ? (identity as T) : data, multiplicity])
166
+ }
167
+ return new MultiSet(result)
168
+ }