@tanstack/db-ivm 0.1.20 → 0.1.24

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 (67) 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 +92 -104
  4. package/dist/cjs/hashing/hash.cjs.map +1 -1
  5. package/dist/cjs/hashing/hash.d.cts +5 -0
  6. package/dist/cjs/hashing/temporal.cjs +18 -0
  7. package/dist/cjs/hashing/temporal.cjs.map +1 -0
  8. package/dist/cjs/hashing/temporal.d.cts +5 -0
  9. package/dist/cjs/multiset.cjs +47 -92
  10. package/dist/cjs/multiset.cjs.map +1 -1
  11. package/dist/cjs/multiset.d.cts +16 -0
  12. package/dist/cjs/operators/distinct.cjs.map +1 -1
  13. package/dist/cjs/operators/distinct.d.cts +1 -1
  14. package/dist/cjs/operators/groupBy.cjs +2 -2
  15. package/dist/cjs/operators/groupBy.cjs.map +1 -1
  16. package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs +4 -5
  17. package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs.map +1 -1
  18. package/dist/cjs/operators/topKArray.cjs +2 -0
  19. package/dist/cjs/operators/topKArray.cjs.map +1 -1
  20. package/dist/cjs/operators/topKState.cjs +30 -0
  21. package/dist/cjs/operators/topKState.cjs.map +1 -1
  22. package/dist/cjs/operators/topKState.d.cts +9 -0
  23. package/dist/cjs/operators/topKWithFractionalIndex.cjs +4 -5
  24. package/dist/cjs/operators/topKWithFractionalIndex.cjs.map +1 -1
  25. package/dist/cjs/utils.cjs +15 -61
  26. package/dist/cjs/utils.cjs.map +1 -1
  27. package/dist/cjs/utils.d.cts +3 -32
  28. package/dist/esm/d2.js +12 -2
  29. package/dist/esm/d2.js.map +1 -1
  30. package/dist/esm/hashing/hash.d.ts +5 -0
  31. package/dist/esm/hashing/hash.js +92 -104
  32. package/dist/esm/hashing/hash.js.map +1 -1
  33. package/dist/esm/hashing/temporal.d.ts +5 -0
  34. package/dist/esm/hashing/temporal.js +18 -0
  35. package/dist/esm/hashing/temporal.js.map +1 -0
  36. package/dist/esm/multiset.d.ts +16 -0
  37. package/dist/esm/multiset.js +48 -93
  38. package/dist/esm/multiset.js.map +1 -1
  39. package/dist/esm/operators/distinct.d.ts +1 -1
  40. package/dist/esm/operators/distinct.js.map +1 -1
  41. package/dist/esm/operators/groupBy.js +2 -2
  42. package/dist/esm/operators/groupBy.js.map +1 -1
  43. package/dist/esm/operators/groupedTopKWithFractionalIndex.js +5 -6
  44. package/dist/esm/operators/groupedTopKWithFractionalIndex.js.map +1 -1
  45. package/dist/esm/operators/topKArray.js +2 -0
  46. package/dist/esm/operators/topKArray.js.map +1 -1
  47. package/dist/esm/operators/topKState.d.ts +9 -0
  48. package/dist/esm/operators/topKState.js +31 -1
  49. package/dist/esm/operators/topKState.js.map +1 -1
  50. package/dist/esm/operators/topKWithFractionalIndex.js +5 -6
  51. package/dist/esm/operators/topKWithFractionalIndex.js.map +1 -1
  52. package/dist/esm/utils.d.ts +3 -32
  53. package/dist/esm/utils.js +15 -61
  54. package/dist/esm/utils.js.map +1 -1
  55. package/package.json +3 -1
  56. package/src/d2.ts +15 -2
  57. package/src/hashing/hash.ts +147 -135
  58. package/src/hashing/temporal.ts +25 -0
  59. package/src/multiset.ts +73 -132
  60. package/src/operators/distinct.ts +5 -3
  61. package/src/operators/groupBy.ts +8 -2
  62. package/src/operators/groupedTopKWithFractionalIndex.ts +10 -6
  63. package/src/operators/topKArray.ts +4 -0
  64. package/src/operators/topKState.ts +42 -0
  65. package/src/operators/topKWithFractionalIndex.ts +10 -6
  66. package/src/operators/topKWithFractionalIndexBTree.ts +7 -8
  67. package/src/utils.ts +20 -76
@@ -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,45 +13,27 @@ 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 OBJECT_MARKER = randomHash()
17
- const ARRAY_MARKER = randomHash()
18
- const MAP_MARKER = randomHash()
19
- const SET_MARKER = randomHash()
20
- const UINT8ARRAY_MARKER = randomHash()
21
- 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() }
22
25
  // Bound structural recursion and value visits. Shared acyclic subtrees are
23
26
  // cached; cycles are rejected rather than given context-dependent hashes.
24
27
  const MAX_STRUCTURAL_HASH_WORK = 1_000_000
25
28
  const MAX_STRUCTURAL_HASH_DEPTH = 768
26
29
 
27
- const temporalTypes = new Set([
28
- `Temporal.Duration`,
29
- `Temporal.Instant`,
30
- `Temporal.PlainDate`,
31
- `Temporal.PlainDateTime`,
32
- `Temporal.PlainMonthDay`,
33
- `Temporal.PlainTime`,
34
- `Temporal.PlainYearMonth`,
35
- `Temporal.ZonedDateTime`,
36
- ])
37
-
38
- interface TemporalLike {
39
- [Symbol.toStringTag]: string
40
- toString: () => string
41
- }
42
-
43
- function isTemporal(input: object): input is TemporalLike {
44
- const tag = (input as Record<symbol, unknown>)[Symbol.toStringTag]
45
- return typeof tag === `string` && temporalTypes.has(tag)
46
- }
47
-
48
30
  // Maximum byte length for Uint8Arrays to hash by content instead of reference
49
31
  // Arrays smaller than this will be hashed by content, allowing proper equality comparisons
50
32
  // for small arrays like ULIDs (16 bytes) while still avoiding performance costs for large arrays
51
33
  const UINT8ARRAY_CONTENT_HASH_THRESHOLD = 128
52
34
 
53
35
  const hashCache = new WeakMap<object, number>()
36
+ const referenceValues = new WeakSet<object>()
54
37
 
55
38
  /** @internal Register a mutable handle before it enters a structural value. */
56
39
  export function registerOpaqueHash(value: object): void {
@@ -80,32 +63,33 @@ function hashObject(input: object, context: HashContext): number {
80
63
 
81
64
  let valueHash: number | undefined
82
65
  try {
83
- if (input instanceof Date) {
84
- valueHash = hashDate(input)
85
- } else if (isBinaryValue(input)) {
86
- valueHash = hashUint8Array(input)
87
- } else if (isTemporal(input)) {
88
- valueHash = hashTemporal(input)
89
- } else {
90
- let plainObjectInput = input
91
- let marker = OBJECT_MARKER
92
-
93
- if (input instanceof Array) {
94
- marker = ARRAY_MARKER
95
- }
96
-
97
- if (input instanceof Map) {
98
- marker = MAP_MARKER
99
- plainObjectInput = [...input.entries()]
100
- }
101
-
102
- if (input instanceof Set) {
103
- marker = SET_MARKER
104
- plainObjectInput = [...input.entries()]
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
+ ),
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)
105
90
  }
106
-
107
- valueHash = hashPlainObject(plainObjectInput, marker, context)
108
91
  }
92
+ valueHash = hasher.digest()
109
93
  } finally {
110
94
  context.activeObjects.delete(input)
111
95
  }
@@ -114,59 +98,39 @@ function hashObject(input: object, context: HashContext): number {
114
98
  return valueHash
115
99
  }
116
100
 
117
- function hashDate(input: Date): number {
118
- const hasher = new MurmurHashStream()
119
- hasher.update(DATE_MARKER)
120
- hasher.update(input.getTime())
121
- return hasher.digest()
122
- }
123
-
124
- function hashUint8Array(input: Uint8Array): number {
125
- const hasher = new MurmurHashStream()
126
- hasher.update(UINT8ARRAY_MARKER)
127
- // Hash the byte length first to differentiate arrays of different sizes
128
- hasher.update(input.byteLength)
129
- // Hash each byte in the array
130
- for (let i = 0; i < input.byteLength; i++) {
131
- hasher.writeByte(input[i]!)
132
- }
133
- return hasher.digest()
134
- }
135
-
136
- function hashTemporal(input: TemporalLike): number {
137
- const hasher = new MurmurHashStream()
138
- hasher.update(TEMPORAL_MARKER)
139
- hasher.update(input[Symbol.toStringTag])
140
- hasher.update(input.toString())
141
- return hasher.digest()
142
- }
143
-
144
- function hashPlainObject(
145
- input: object,
146
- marker: number,
147
- context: HashContext,
148
- ): number {
149
- const hasher = new MurmurHashStream()
150
-
151
- // Mark the type of the input
152
- hasher.update(marker)
153
- const keys = Object.keys(input)
154
- keys.sort(keySort)
155
- for (const key of keys) {
156
- hasher.update(KEY)
157
- hasher.update(key)
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)
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]]
167
119
  }
168
-
169
- 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]
170
134
  }
171
135
 
172
136
  function updateHasher(
@@ -204,56 +168,50 @@ function updateHasher(
204
168
  // Functions are assigned a globally unique ID
205
169
  // and that ID is cached in the weak map
206
170
  hasher.update(cachedReferenceHash(input))
207
- return
208
- default:
209
- console.warn(
210
- `Ignored input during hashing because it is of type ${typeof input} which is not supported`,
211
- )
212
171
  }
213
172
  }
214
173
 
215
174
  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
233
- }
234
-
235
- if (context.activeObjects.has(input)) {
236
- throw new TypeError(`Cannot hash cyclic structural values`)
237
- }
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
238
179
 
239
180
  // Opaque leaves cannot contain structural back-references. Resolve them
240
181
  // before entering structural recursion, even when they have user properties.
241
182
  if (isReferenceHashedObject(input)) return cachedReferenceHash(input)
242
183
 
243
- const valueHash = hashCache.get(input) ?? context.pendingHashes.get(input)
244
- 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
+ }
245
190
 
246
- 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
247
203
  }
248
204
 
249
205
  function isReferenceHashedObject(input: object): boolean {
250
206
  return (
251
- input instanceof File ||
207
+ referenceValues.has(input) ||
208
+ (typeof File !== `undefined` && input instanceof File) ||
252
209
  (isBinaryValue(input) &&
253
210
  input.byteLength > UINT8ARRAY_CONTENT_HASH_THRESHOLD)
254
211
  )
255
212
  }
256
213
 
214
+ // Buffer can come from another realm than Uint8Array (for example under jsdom).
257
215
  function isBinaryValue(input: object): input is Uint8Array {
258
216
  return (
259
217
  (typeof Buffer !== `undefined` && input instanceof Buffer) ||
@@ -261,6 +219,59 @@ function isBinaryValue(input: object): input is Uint8Array {
261
219
  )
262
220
  }
263
221
 
222
+ /** @internal Compare immutable structural values without computing a digest.
223
+ * Pair memoization also permits cyclic values that structural hashing rejects.
224
+ * Reference-valued leaves remain opaque, including registered mutable handles.
225
+ */
226
+ export function equalHashValues(left: unknown, right: unknown): boolean {
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
+ )
232
+ function equal(a: unknown, b: unknown): boolean {
233
+ if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true
234
+ if (
235
+ a === null ||
236
+ b === null ||
237
+ typeof a !== `object` ||
238
+ typeof b !== `object` ||
239
+ isReferenceHashedObject(a) ||
240
+ isReferenceHashedObject(b)
241
+ )
242
+ return false
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
246
+
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.
250
+ const peers = compared.get(a)
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
260
+ if (peers) peers.add(b)
261
+ else compared.set(a, new Set([b]))
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]),
269
+ )
270
+ )
271
+ }
272
+ return equal(left, right)
273
+ }
274
+
264
275
  let nextRefId = 1
265
276
  function cachedReferenceHash(fn: object): number {
266
277
  let valueHash = hashCache.get(fn)
@@ -268,6 +279,7 @@ function cachedReferenceHash(fn: object): number {
268
279
  valueHash = nextRefId ^ FUNCTIONS
269
280
  nextRefId++
270
281
  hashCache.set(fn, valueHash)
282
+ referenceValues.add(fn)
271
283
  }
272
284
  return valueHash
273
285
  }
@@ -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/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,59 @@ 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. The keyed identity text
75
+ * has known collisions: https://github.com/TanStack/db/issues/1948
76
+ * - Unkeyed, one primitive type: every record is a string, or every record
77
+ * is a number. Records compare as `Map` keys do, and a `-0` record is
78
+ * returned as `0`.
79
+ * - Otherwise, records compare by structure (`hash`).
92
80
  *
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.
81
+ * Each identity keeps its first record in input order with the summed
82
+ * multiplicity. Identities whose sum is zero are dropped. The input is not
83
+ * changed.
95
84
  */
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,
85
+ consolidate(): MultiSet<T> {
86
+ const inner = this.#inner
87
+ // Keyed multisets hold [string | number key, value] pairs. Their values are
88
+ // compared by reference, which avoids hashing. Join tuples (values of
89
+ // length 2) are unpacked so ['A', null] and [null, 'X'] stay distinct.
90
+ if (
91
+ inner.every(
92
+ ([data]) =>
93
+ Array.isArray(data) &&
94
+ data.length === 2 &&
95
+ (typeof data[0] === `string` || typeof data[0] === `number`),
146
96
  )
147
-
148
- // Store the original data for the first occurrence
149
- if (!values.has(compositeKey)) {
150
- values.set(compositeKey, data as T)
151
- }
97
+ ) {
98
+ return consolidateBy(inner, (data) => {
99
+ const [key, value] = data as [string | number, unknown]
100
+ const valueId =
101
+ Array.isArray(value) && value.length === 2
102
+ ? `${getStringId(value[0])}|${getStringId(value[1])}`
103
+ : getStringId(value)
104
+ return key + `|` + valueId
105
+ })
152
106
  }
153
107
 
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)
108
+ // Unkeyed data of a single primitive type is its own identity, and the
109
+ // result holds the Map key, so -0 becomes 0. Anything else is compared
110
+ // structurally by hash.
111
+ let primitiveType: string | undefined
112
+ const requireHash = inner.some(([data]) => {
113
+ const type = typeof data
114
+ if (type !== `string` && type !== `number`) return true
115
+ primitiveType ??= type
116
+ return type !== primitiveType
117
+ })
118
+ return requireHash
119
+ ? consolidateBy(inner, hash)
120
+ : consolidateBy(inner, (data) => data, true)
205
121
  }
206
122
 
207
123
  extend(other: MultiSet<T> | MultiSetArray<T>): void {
@@ -219,3 +135,28 @@ export class MultiSet<T> {
219
135
  return this.#inner
220
136
  }
221
137
  }
138
+
139
+ /**
140
+ * Sums multiplicities of records with the same identity, keeping the first
141
+ * record seen for each identity (or its Map key, which writes -0 as 0, when
142
+ * `keepIdentity` is set) and dropping records whose sum is zero.
143
+ */
144
+ function consolidateBy<T>(
145
+ inner: MultiSetArray<T>,
146
+ identityOf: (data: T) => unknown,
147
+ keepIdentity = false,
148
+ ): MultiSet<T> {
149
+ const consolidated = new Map<unknown, [T, number]>()
150
+ for (const [data, multiplicity] of inner) {
151
+ const identity = identityOf(data)
152
+ const entry = consolidated.get(identity)
153
+ if (entry) entry[1] += multiplicity
154
+ else consolidated.set(identity, [data, multiplicity])
155
+ }
156
+ const result: MultiSetArray<T> = []
157
+ for (const [identity, [data, multiplicity]] of consolidated) {
158
+ if (multiplicity !== 0)
159
+ result.push([keepIdentity ? (identity as T) : data, multiplicity])
160
+ }
161
+ return new MultiSet(result)
162
+ }
@@ -85,10 +85,12 @@ export class DistinctOperator<
85
85
  export function distinct<T extends KeyValue<any, any>>(
86
86
  by: (value: T) => any = (value: T) => value,
87
87
  ) {
88
- return (stream: IStreamBuilder<T>): IStreamBuilder<T> => {
89
- const output = new StreamBuilder<T>(
88
+ return (
89
+ stream: IStreamBuilder<T>,
90
+ ): IStreamBuilder<KeyValue<number, GetValue<T>>> => {
91
+ const output = new StreamBuilder<KeyValue<number, GetValue<T>>>(
90
92
  stream.graph,
91
- new DifferenceStreamWriter<T>(),
93
+ new DifferenceStreamWriter<KeyValue<number, GetValue<T>>>(),
92
94
  )
93
95
  const operator = new DistinctOperator<T>(
94
96
  stream.graph.getNextOperatorId(),
@@ -237,7 +237,10 @@ export function min<T, V extends CanMinMax>(
237
237
  reduce: (values) => {
238
238
  let minValue: V | undefined
239
239
  for (const [value, _multiplicity] of values) {
240
- if (!minValue || (value && value < minValue)) {
240
+ if (
241
+ value !== undefined &&
242
+ (minValue === undefined || value < minValue)
243
+ ) {
241
244
  minValue = value
242
245
  }
243
246
  }
@@ -267,7 +270,10 @@ export function max<T, V extends CanMinMax>(
267
270
  reduce: (values) => {
268
271
  let maxValue: V | undefined
269
272
  for (const [value, _multiplicity] of values) {
270
- if (!maxValue || (value && value > maxValue)) {
273
+ if (
274
+ value !== undefined &&
275
+ (maxValue === undefined || value > maxValue)
276
+ ) {
271
277
  maxValue = value
272
278
  }
273
279
  }