@tanstack/db-ivm 0.1.22 → 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.
@@ -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/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
+ }
@@ -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
  }