@tanstack/db-ivm 0.1.26 → 0.2.0

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 (136) hide show
  1. package/README.md +5 -6
  2. package/dist/cjs/hashing/hash.cjs +58 -3
  3. package/dist/cjs/hashing/hash.cjs.map +1 -1
  4. package/dist/cjs/hashing/hash.d.cts +9 -0
  5. package/dist/cjs/hashing/murmur.cjs +26 -15
  6. package/dist/cjs/hashing/murmur.cjs.map +1 -1
  7. package/dist/cjs/hashing/murmur.d.cts +2 -0
  8. package/dist/cjs/index.cjs +0 -26
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/indexes.cjs +52 -16
  11. package/dist/cjs/indexes.cjs.map +1 -1
  12. package/dist/cjs/indexes.d.cts +4 -1
  13. package/dist/cjs/operators/groupBy.cjs +33 -36
  14. package/dist/cjs/operators/groupBy.cjs.map +1 -1
  15. package/dist/cjs/operators/groupBy.d.cts +1 -5
  16. package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs +3 -9
  17. package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs.map +1 -1
  18. package/dist/cjs/operators/groupedTopKWithFractionalIndex.d.cts +1 -15
  19. package/dist/cjs/operators/index.d.cts +0 -6
  20. package/dist/cjs/operators/join.cjs +0 -20
  21. package/dist/cjs/operators/join.cjs.map +1 -1
  22. package/dist/cjs/operators/join.d.cts +0 -40
  23. package/dist/cjs/operators/orderBy.cjs +2 -77
  24. package/dist/cjs/operators/orderBy.cjs.map +1 -1
  25. package/dist/cjs/operators/orderBy.d.cts +2 -31
  26. package/dist/cjs/operators/reduce.cjs +36 -5
  27. package/dist/cjs/operators/reduce.cjs.map +1 -1
  28. package/dist/cjs/operators/reduce.d.cts +11 -2
  29. package/dist/cjs/operators/topKArray.cjs +9 -0
  30. package/dist/cjs/operators/topKArray.cjs.map +1 -1
  31. package/dist/cjs/operators/topKArray.d.cts +6 -0
  32. package/dist/cjs/operators/topKState.cjs +3 -0
  33. package/dist/cjs/operators/topKState.cjs.map +1 -1
  34. package/dist/cjs/operators/topKState.d.cts +1 -0
  35. package/dist/cjs/operators/topKWithFractionalIndex.cjs +5 -9
  36. package/dist/cjs/operators/topKWithFractionalIndex.cjs.map +1 -1
  37. package/dist/cjs/operators/topKWithFractionalIndex.d.cts +7 -12
  38. package/dist/cjs/utils.cjs.map +1 -1
  39. package/dist/cjs/utils.d.cts +0 -7
  40. package/dist/esm/hashing/hash.d.ts +9 -0
  41. package/dist/esm/hashing/hash.js +60 -5
  42. package/dist/esm/hashing/hash.js.map +1 -1
  43. package/dist/esm/hashing/murmur.d.ts +2 -0
  44. package/dist/esm/hashing/murmur.js +26 -15
  45. package/dist/esm/hashing/murmur.js.map +1 -1
  46. package/dist/esm/index.js +3 -29
  47. package/dist/esm/index.js.map +1 -1
  48. package/dist/esm/indexes.d.ts +4 -1
  49. package/dist/esm/indexes.js +52 -16
  50. package/dist/esm/indexes.js.map +1 -1
  51. package/dist/esm/operators/groupBy.d.ts +1 -5
  52. package/dist/esm/operators/groupBy.js +33 -36
  53. package/dist/esm/operators/groupBy.js.map +1 -1
  54. package/dist/esm/operators/groupedTopKWithFractionalIndex.d.ts +1 -6
  55. package/dist/esm/operators/groupedTopKWithFractionalIndex.js +3 -9
  56. package/dist/esm/operators/groupedTopKWithFractionalIndex.js.map +1 -1
  57. package/dist/esm/operators/index.d.ts +0 -6
  58. package/dist/esm/operators/join.d.ts +0 -25
  59. package/dist/esm/operators/join.js +1 -21
  60. package/dist/esm/operators/join.js.map +1 -1
  61. package/dist/esm/operators/orderBy.d.ts +2 -22
  62. package/dist/esm/operators/orderBy.js +3 -78
  63. package/dist/esm/operators/orderBy.js.map +1 -1
  64. package/dist/esm/operators/reduce.d.ts +11 -2
  65. package/dist/esm/operators/reduce.js +36 -5
  66. package/dist/esm/operators/reduce.js.map +1 -1
  67. package/dist/esm/operators/topKArray.d.ts +6 -0
  68. package/dist/esm/operators/topKArray.js +9 -0
  69. package/dist/esm/operators/topKArray.js.map +1 -1
  70. package/dist/esm/operators/topKState.d.ts +1 -0
  71. package/dist/esm/operators/topKState.js +3 -0
  72. package/dist/esm/operators/topKState.js.map +1 -1
  73. package/dist/esm/operators/topKWithFractionalIndex.d.ts +7 -3
  74. package/dist/esm/operators/topKWithFractionalIndex.js +6 -10
  75. package/dist/esm/operators/topKWithFractionalIndex.js.map +1 -1
  76. package/dist/esm/utils.d.ts +0 -7
  77. package/dist/esm/utils.js.map +1 -1
  78. package/package.json +2 -4
  79. package/src/hashing/hash.ts +87 -5
  80. package/src/hashing/murmur.ts +31 -17
  81. package/src/indexes.ts +65 -16
  82. package/src/operators/groupBy.ts +48 -58
  83. package/src/operators/groupedTopKWithFractionalIndex.ts +4 -15
  84. package/src/operators/index.ts +0 -6
  85. package/src/operators/join.ts +0 -90
  86. package/src/operators/orderBy.ts +6 -141
  87. package/src/operators/reduce.ts +60 -3
  88. package/src/operators/topKArray.ts +15 -0
  89. package/src/operators/topKState.ts +7 -0
  90. package/src/operators/topKWithFractionalIndex.ts +15 -16
  91. package/src/utils.ts +0 -30
  92. package/dist/cjs/operators/count.cjs +0 -35
  93. package/dist/cjs/operators/count.cjs.map +0 -1
  94. package/dist/cjs/operators/count.d.cts +0 -19
  95. package/dist/cjs/operators/debug.cjs +0 -39
  96. package/dist/cjs/operators/debug.cjs.map +0 -1
  97. package/dist/cjs/operators/debug.d.cts +0 -16
  98. package/dist/cjs/operators/filterBy.cjs +0 -19
  99. package/dist/cjs/operators/filterBy.cjs.map +0 -1
  100. package/dist/cjs/operators/filterBy.d.cts +0 -9
  101. package/dist/cjs/operators/keying.cjs +0 -16
  102. package/dist/cjs/operators/keying.cjs.map +0 -1
  103. package/dist/cjs/operators/keying.d.cts +0 -22
  104. package/dist/cjs/operators/negate.cjs +0 -27
  105. package/dist/cjs/operators/negate.cjs.map +0 -1
  106. package/dist/cjs/operators/negate.d.cts +0 -13
  107. package/dist/cjs/operators/topK.cjs +0 -39
  108. package/dist/cjs/operators/topK.cjs.map +0 -1
  109. package/dist/cjs/operators/topK.d.cts +0 -34
  110. package/dist/cjs/operators/topKWithFractionalIndexBTree.d.cts +0 -43
  111. package/dist/esm/operators/count.d.ts +0 -13
  112. package/dist/esm/operators/count.js +0 -35
  113. package/dist/esm/operators/count.js.map +0 -1
  114. package/dist/esm/operators/debug.d.ts +0 -16
  115. package/dist/esm/operators/debug.js +0 -39
  116. package/dist/esm/operators/debug.js.map +0 -1
  117. package/dist/esm/operators/filterBy.d.ts +0 -9
  118. package/dist/esm/operators/filterBy.js +0 -19
  119. package/dist/esm/operators/filterBy.js.map +0 -1
  120. package/dist/esm/operators/keying.d.ts +0 -19
  121. package/dist/esm/operators/keying.js +0 -16
  122. package/dist/esm/operators/keying.js.map +0 -1
  123. package/dist/esm/operators/negate.d.ts +0 -13
  124. package/dist/esm/operators/negate.js +0 -27
  125. package/dist/esm/operators/negate.js.map +0 -1
  126. package/dist/esm/operators/topK.d.ts +0 -31
  127. package/dist/esm/operators/topK.js +0 -39
  128. package/dist/esm/operators/topK.js.map +0 -1
  129. package/dist/esm/operators/topKWithFractionalIndexBTree.d.ts +0 -28
  130. package/src/operators/count.ts +0 -51
  131. package/src/operators/debug.ts +0 -57
  132. package/src/operators/filterBy.ts +0 -30
  133. package/src/operators/keying.ts +0 -34
  134. package/src/operators/negate.ts +0 -32
  135. package/src/operators/topK.ts +0 -91
  136. package/src/operators/topKWithFractionalIndexBTree.ts +0 -297
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/db-ivm",
3
- "version": "0.1.26",
3
+ "version": "0.2.0",
4
4
  "description": "Incremental View Maintenance for TanStack DB based on Differential Dataflow",
5
5
  "author": "Sam Willis",
6
6
  "license": "MIT",
@@ -41,14 +41,12 @@
41
41
  "src"
42
42
  ],
43
43
  "dependencies": {
44
- "fractional-indexing": "^3.2.0",
45
- "sorted-btree": "^1.8.1"
44
+ "fractional-indexing": "^3.2.0"
46
45
  },
47
46
  "peerDependencies": {
48
47
  "typescript": ">=4.7"
49
48
  },
50
49
  "devDependencies": {
51
- "@types/debug": "^4.1.12",
52
50
  "@vitest/coverage-istanbul": "^3.2.4",
53
51
  "temporal-polyfill": "^0.3.0"
54
52
  },
@@ -72,8 +72,18 @@ function hashObject(input: object, context: HashContext): number {
72
72
  if (body) updateHasher(hasher, value, context)
73
73
  else hasher.update(value as string | number)
74
74
  if (body) {
75
- // Sorted string keys, then enumerable symbol keys by identity.
76
- const keys: Array<string | symbol> = Object.keys(body).sort(keySort)
75
+ // String keys in UTF-16 code-unit order, then enumerable symbol keys by
76
+ // identity. Code-unit order is total; locale collation can rank two
77
+ // distinct keys as equal and leave their insertion order in the hash.
78
+ const keys: Array<string | symbol> = Object.keys(body)
79
+ // A dense array with no other keys lists its indices in one order for
80
+ // every equal array: integer keys come first, so the last key is the
81
+ // last index. Anything else sorts.
82
+ const denseArray =
83
+ Array.isArray(body) &&
84
+ keys.length === body.length &&
85
+ (keys.length === 0 || keys[keys.length - 1] === String(body.length - 1))
86
+ if (!denseArray) keys.sort()
77
87
  keys.push(
78
88
  ...Object.getOwnPropertySymbols(body)
79
89
  .filter((key) =>
@@ -284,9 +294,81 @@ function cachedReferenceHash(fn: object): number {
284
294
  return valueHash
285
295
  }
286
296
 
297
+ // One punctuation tag per objectParts marker, so values of different kinds
298
+ // never share an identity (and no tag reads as an identifier in this source).
299
+ const MARKER_TAGS = new Map<{ hash: number }, string>([
300
+ [ARRAY_MARKER, `[`],
301
+ [OBJECT_MARKER, `{`],
302
+ [DATE_MARKER, `<`],
303
+ [REGEXP_MARKER, `/`],
304
+ [MAP_MARKER, `&`],
305
+ [SET_MARKER, `*`],
306
+ [UINT8ARRAY_MARKER, `^`],
307
+ [TEMPORAL_MARKER, `?`],
308
+ ])
309
+
310
+ // Nesting a structural identity follows: a min or max input is an array of
311
+ // exact keys, and an exact key can hold a reference tuple.
312
+ const MAX_IDENTITY_DEPTH = 4
313
+
287
314
  /**
288
- * Strings sorted lexicographically.
315
+ * @internal An exact identity string for a value, or undefined when it has
316
+ * no cheap one. Two values that `equalHashValues` treats as equal get the
317
+ * same result, both undefined or one string; two that it treats as different
318
+ * never share a string. The decision uses only what equalHashValues reads:
319
+ * the type, the `objectParts` marker and headers, and enumerable own keys. groupBy uses it
320
+ * to merge equal contributions without hashing them.
289
321
  */
290
- function keySort(a: string, b: string): number {
291
- return a.localeCompare(b)
322
+ export function structuralIdentity(
323
+ value: unknown,
324
+ depth = 0,
325
+ ): string | undefined {
326
+ switch (typeof value) {
327
+ case `number`:
328
+ // The hash treats -0 as 0 and every NaN as one value.
329
+ return `#${value === 0 ? 0 : value}`
330
+ case `string`:
331
+ return `"${value.length}:${value}`
332
+ case `boolean`:
333
+ return value ? `+` : `-`
334
+ case `undefined`:
335
+ return `~`
336
+ case `bigint`:
337
+ return `%${value}`
338
+ case `symbol`:
339
+ return `$${getSymbolIdentity(value)}`
340
+ case `object`: {
341
+ if (value === null) return `!`
342
+ if (depth >= MAX_IDENTITY_DEPTH || isReferenceHashedObject(value))
343
+ return undefined
344
+ const [marker, header, body] = objectParts(value)
345
+ // equalHashValues compares headers with ===, so a NaN header is never
346
+ // equal to another value's, and gets no identity.
347
+ let head = MARKER_TAGS.get(marker)!
348
+ for (const part of header) {
349
+ if (Number.isNaN(part)) return undefined
350
+ head += structuralIdentity(part, depth + 1)
351
+ }
352
+ if (!body) return head
353
+ if (
354
+ Object.getOwnPropertySymbols(body).some((key) =>
355
+ Object.prototype.propertyIsEnumerable.call(body, key),
356
+ )
357
+ )
358
+ return undefined
359
+ let parts = ``
360
+ for (const key of Object.keys(body).sort()) {
361
+ const part = structuralIdentity(
362
+ (body as Record<string, unknown>)[key],
363
+ depth + 1,
364
+ )
365
+ if (part === undefined) return undefined
366
+ parts += `${key.length}:${key}=${part}|`
367
+ }
368
+ // An array's header is its length, so a hole differs from undefined.
369
+ return `${head}:${parts.length}:${parts}`
370
+ }
371
+ default:
372
+ return undefined
373
+ }
292
374
  }
@@ -63,11 +63,8 @@ export interface Hasher {
63
63
  digest: () => number
64
64
  }
65
65
 
66
- // Allocate these once, outside the hot path
67
- const buf = new ArrayBuffer(8)
68
- // dv and u8 are 2 different views on the same buffer `buf`
69
- const dv = new DataView(buf)
70
- const u8 = new Uint8Array(buf)
66
+ // Allocate this once, outside the hot path
67
+ const dv = new DataView(new ArrayBuffer(8))
71
68
 
72
69
  /**
73
70
  * This implementation of Murmur hash uses a random initial seed and random markers.
@@ -106,6 +103,20 @@ export class MurmurHashStream implements Hasher {
106
103
  }
107
104
  }
108
105
 
106
+ /** Write four bytes, low byte first, as four `writeByte` calls would. */
107
+ private writeWord(word: number): void {
108
+ this.length += 4
109
+ if (this.carryBytes === 0) {
110
+ this._mix(word >>> 0)
111
+ return
112
+ }
113
+ // The carry's bytes come first. The word's low bytes complete that block,
114
+ // and its high bytes become the new carry, which keeps its byte count.
115
+ const shift = 8 * this.carryBytes
116
+ this._mix((this.carry | (word << shift)) >>> 0)
117
+ this.carry = word >>> (32 - shift)
118
+ }
119
+
109
120
  update(chunk: symbol | string | number | bigint): void {
110
121
  switch (typeof chunk) {
111
122
  case `symbol`: {
@@ -115,22 +126,25 @@ export class MurmurHashStream implements Hasher {
115
126
  }
116
127
  case `string`:
117
128
  this.update(STRING_MARKER)
118
- for (let i = 0; i < chunk.length; i++) {
119
- const code = chunk.charCodeAt(i)
120
- this.writeByte(code & 0xff)
121
- this.writeByte((code >>> 8) & 0xff)
129
+ {
130
+ // Each code unit is two bytes, so a pair of them is one word.
131
+ const pairs = chunk.length - (chunk.length % 2)
132
+ for (let i = 0; i < pairs; i += 2) {
133
+ this.writeWord(
134
+ chunk.charCodeAt(i) | (chunk.charCodeAt(i + 1) << 16),
135
+ )
136
+ }
137
+ if (pairs < chunk.length) {
138
+ const code = chunk.charCodeAt(pairs)
139
+ this.writeByte(code & 0xff)
140
+ this.writeByte((code >>> 8) & 0xff)
141
+ }
122
142
  }
123
143
  return
124
144
  case `number`:
125
145
  dv.setFloat64(0, chunk, true) // fixed little-endian
126
- this.writeByte(u8[0]!)
127
- this.writeByte(u8[1]!)
128
- this.writeByte(u8[2]!)
129
- this.writeByte(u8[3]!)
130
- this.writeByte(u8[4]!)
131
- this.writeByte(u8[5]!)
132
- this.writeByte(u8[6]!)
133
- this.writeByte(u8[7]!)
146
+ this.writeWord(dv.getUint32(0, true))
147
+ this.writeWord(dv.getUint32(4, true))
134
148
  return
135
149
  case `bigint`: {
136
150
  let value = chunk
package/src/indexes.ts CHANGED
@@ -58,8 +58,13 @@ class PrefixMap<TValue, TPrefix> extends Map<
58
58
  > {
59
59
  /**
60
60
  * Add a value to the PrefixMap. Returns true if the map becomes empty after the operation.
61
+ * With `prefixIdentity`, values that share a prefix are the same value.
61
62
  */
62
- addValue(value: TValue, multiplicity: number): boolean {
63
+ addValue(
64
+ value: TValue,
65
+ multiplicity: number,
66
+ prefixIdentity = false,
67
+ ): boolean {
63
68
  if (multiplicity === 0) return this.size === 0
64
69
 
65
70
  const prefix = getPrefix<TValue, TPrefix>(value)
@@ -73,7 +78,11 @@ class PrefixMap<TValue, TPrefix> extends Map<
73
78
  throw new Error(`Mismatching prefixes, this should never happen`)
74
79
  }
75
80
 
76
- if (currentValue === value || hash(currentValue) === hash(value)) {
81
+ if (
82
+ currentValue === value ||
83
+ (prefixIdentity && prefix !== NO_PREFIX) ||
84
+ hash(currentValue) === hash(value)
85
+ ) {
77
86
  // Same value, update multiplicity
78
87
  const newMultiplicity = currentMultiplicity + multiplicity
79
88
  if (newMultiplicity === 0) {
@@ -150,10 +159,21 @@ export class Index<TKey, TValue, TPrefix = any> {
150
159
  * hash to identify identical values, storing them in a third level value map.
151
160
  */
152
161
  #inner: IndexMap<TKey, TValue, TPrefix>
153
- #consolidatedMultiplicity: Map<TKey, number> = new Map() // sum of multiplicities per key
162
+ // Sum of multiplicities per key, for presence. Only joins read presence; a
163
+ // reduce index is built without it.
164
+ #consolidatedMultiplicity: Map<TKey, number> | undefined
165
+ /**
166
+ * When true, the producer guarantees that a value's string or number
167
+ * prefix determines the value: two values under one key with an equal
168
+ * prefix are equal, so they merge without a structural hash.
169
+ */
170
+ #prefixIdentity: boolean
154
171
 
155
- constructor() {
172
+ constructor(options?: { prefixIdentity?: boolean; trackPresence?: boolean }) {
156
173
  this.#inner = new Map()
174
+ if (options?.trackPresence ?? true)
175
+ this.#consolidatedMultiplicity = new Map()
176
+ this.#prefixIdentity = options?.prefixIdentity ?? false
157
177
  }
158
178
 
159
179
  /**
@@ -194,6 +214,12 @@ export class Index<TKey, TValue, TPrefix = any> {
194
214
  return this.#inner.size
195
215
  }
196
216
 
217
+ #presence(): Map<TKey, number> {
218
+ if (this.#consolidatedMultiplicity === undefined)
219
+ throw new Error(`This Index does not track presence`)
220
+ return this.#consolidatedMultiplicity
221
+ }
222
+
197
223
  /**
198
224
  * This method checks if the index has a given key.
199
225
  * @param key - The key to check.
@@ -209,7 +235,7 @@ export class Index<TKey, TValue, TPrefix = any> {
209
235
  * @returns True if the key has non-zero consolidated multiplicity, false otherwise.
210
236
  */
211
237
  hasPresence(key: TKey): boolean {
212
- return (this.#consolidatedMultiplicity.get(key) || 0) !== 0
238
+ return (this.#presence().get(key) || 0) !== 0
213
239
  }
214
240
 
215
241
  /**
@@ -218,7 +244,7 @@ export class Index<TKey, TValue, TPrefix = any> {
218
244
  * @returns The consolidated multiplicity for the key.
219
245
  */
220
246
  getConsolidatedMultiplicity(key: TKey): number {
221
- return this.#consolidatedMultiplicity.get(key) || 0
247
+ return this.#presence().get(key) || 0
222
248
  }
223
249
 
224
250
  /**
@@ -226,7 +252,7 @@ export class Index<TKey, TValue, TPrefix = any> {
226
252
  * @returns An iterator of keys with non-zero consolidated multiplicity.
227
253
  */
228
254
  getPresenceKeys(): Iterable<TKey> {
229
- return this.#consolidatedMultiplicity.keys()
255
+ return this.#presence().keys()
230
256
  }
231
257
 
232
258
  /**
@@ -235,7 +261,22 @@ export class Index<TKey, TValue, TPrefix = any> {
235
261
  * @returns An array of value tuples [value, multiplicity].
236
262
  */
237
263
  get(key: TKey): Array<[TValue, number]> {
238
- return [...this.getIterator(key)]
264
+ // Built directly, in getIterator's order: join and reduce call this for
265
+ // every changed key.
266
+ const mapOrSingleValue = this.#inner.get(key)
267
+ if (mapOrSingleValue === undefined) return []
268
+ if (isSingleValue(mapOrSingleValue)) return [mapOrSingleValue]
269
+ if (mapOrSingleValue instanceof ValueMap)
270
+ return [...mapOrSingleValue.values()]
271
+ const result: Array<[TValue, number]> = []
272
+ for (const singleValueOrValueMap of mapOrSingleValue.values()) {
273
+ if (isSingleValue(singleValueOrValueMap))
274
+ result.push(singleValueOrValueMap)
275
+ else
276
+ for (const valueTuple of singleValueOrValueMap.values())
277
+ result.push(valueTuple)
278
+ }
279
+ return result
239
280
  }
240
281
 
241
282
  /**
@@ -303,12 +344,15 @@ export class Index<TKey, TValue, TPrefix = any> {
303
344
  if (multiplicity === 0) return
304
345
 
305
346
  // Update consolidated multiplicity tracking
306
- const newConsolidatedMultiplicity =
307
- (this.#consolidatedMultiplicity.get(key) || 0) + multiplicity
308
- if (newConsolidatedMultiplicity === 0) {
309
- this.#consolidatedMultiplicity.delete(key)
310
- } else {
311
- this.#consolidatedMultiplicity.set(key, newConsolidatedMultiplicity)
347
+ const presence = this.#consolidatedMultiplicity
348
+ if (presence) {
349
+ const newConsolidatedMultiplicity =
350
+ (presence.get(key) || 0) + multiplicity
351
+ if (newConsolidatedMultiplicity === 0) {
352
+ presence.delete(key)
353
+ } else {
354
+ presence.set(key, newConsolidatedMultiplicity)
355
+ }
312
356
  }
313
357
 
314
358
  const mapOrSingleValue = this.#inner.get(key)
@@ -348,7 +392,11 @@ export class Index<TKey, TValue, TPrefix = any> {
348
392
  }
349
393
  } else {
350
394
  // Handle existing PrefixMap
351
- const isEmpty = mapOrSingleValue.addValue(value, multiplicity)
395
+ const isEmpty = mapOrSingleValue.addValue(
396
+ value,
397
+ multiplicity,
398
+ this.#prefixIdentity,
399
+ )
352
400
  if (isEmpty) {
353
401
  this.#inner.delete(key)
354
402
  }
@@ -384,7 +432,8 @@ export class Index<TKey, TValue, TPrefix = any> {
384
432
  // Check if they're the same value by prefix/suffix comparison
385
433
  if (
386
434
  isSamePrefix(currentPrefix, newPrefix) &&
387
- (currentValue === newValue || hash(currentValue) === hash(newValue))
435
+ ((this.#prefixIdentity && newPrefix !== NO_PREFIX) ||
436
+ hash(currentValue) === hash(newValue))
388
437
  ) {
389
438
  const newMultiplicity = currentMultiplicity + multiplicity
390
439
  if (newMultiplicity === 0) {
@@ -1,3 +1,4 @@
1
+ import { structuralIdentity } from '../hashing/hash.js'
1
2
  import { serializeValue } from '../utils.js'
2
3
  import { map } from './map.js'
3
4
  import { reduce } from './reduce.js'
@@ -5,19 +6,12 @@ import type { IStreamBuilder, KeyValue } from '../types.js'
5
6
 
6
7
  type GroupKey = Record<string, unknown>
7
8
 
8
- type BasicAggregateFunction<T, R, V = unknown> = {
9
+ type AggregateFunction<T, R, V = unknown> = {
9
10
  preMap: (data: T) => V
10
11
  reduce: (values: Array<[V, number]>) => V
11
12
  postMap?: (result: V) => R
12
13
  }
13
14
 
14
- type PipedAggregateFunction<T, R> = {
15
- pipe: (stream: IStreamBuilder<T>) => IStreamBuilder<KeyValue<string, R>>
16
- }
17
-
18
- type AggregateFunction<T, R, V = unknown> =
19
- BasicAggregateFunction<T, R, V> | PipedAggregateFunction<T, R>
20
-
21
15
  type ExtractAggregateReturnType<T, A> =
22
16
  A extends AggregateFunction<T, infer R, any> ? R : never
23
17
 
@@ -25,12 +19,6 @@ type AggregatesReturnType<T, A> = {
25
19
  [K in keyof A]: ExtractAggregateReturnType<T, A[K]>
26
20
  }
27
21
 
28
- function isPipedAggregateFunction<T, R>(
29
- aggregate: AggregateFunction<T, R>,
30
- ): aggregate is PipedAggregateFunction<T, R> {
31
- return `pipe` in aggregate
32
- }
33
-
34
22
  /**
35
23
  * Groups data by key and applies multiple aggregate operations
36
24
  * @param keyExtractor Function to extract grouping key from data
@@ -43,27 +31,19 @@ export function groupBy<
43
31
  >(keyExtractor: (data: T) => K, aggregates: A = {} as A) {
44
32
  type ResultType = K & AggregatesReturnType<T, A>
45
33
 
46
- const basicAggregates = Object.fromEntries(
47
- Object.entries(aggregates).filter(
48
- ([_, aggregate]) => !isPipedAggregateFunction(aggregate),
49
- ),
50
- ) as Record<string, BasicAggregateFunction<T, any, any>>
51
-
52
- // @ts-expect-error - TODO: we don't use this yet, but we will
53
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
54
- const pipedAggregates = Object.fromEntries(
55
- Object.entries(aggregates).filter(([_, aggregate]) =>
56
- isPipedAggregateFunction(aggregate),
57
- ),
58
- ) as Record<string, PipedAggregateFunction<T, any>>
59
-
60
34
  return (
61
35
  stream: IStreamBuilder<T>,
62
36
  ): IStreamBuilder<KeyValue<string, ResultType>> => {
63
37
  // Special key to store the original key object
64
38
  const KEY_SENTINEL = Symbol(`original_group_key`)
65
39
 
66
- // First map to extract keys and pre-aggregate values
40
+ const aggregateEntries = Object.entries(aggregates)
41
+
42
+ // First map to extract keys and pre-aggregate values. Each contribution
43
+ // leads with an identity built from its group key and pre-aggregate
44
+ // values when every one of them has one. Within one group the reduce
45
+ // index then merges equal contributions by that identity instead of
46
+ // hashing them. A null identity falls back to the structural hash.
67
47
  const withKeysAndValues = stream.pipe(
68
48
  map((data) => {
69
49
  const key = keyExtractor(data)
@@ -72,51 +52,61 @@ export function groupBy<
72
52
  // Create values object with pre-aggregated values
73
53
  const values: Record<string | symbol, unknown> = {}
74
54
 
75
- // Store the original key object
55
+ // Store the original key object. Contributions whose keys serialize
56
+ // alike but hash apart must not merge, so the key is part of the
57
+ // identity.
76
58
  values[KEY_SENTINEL] = key
77
59
 
78
60
  // Add pre-aggregated values
79
- for (const [name, aggregate] of Object.entries(basicAggregates)) {
80
- values[name] = aggregate.preMap(data)
61
+ let identity: string | null = structuralIdentity(key) ?? null
62
+ for (const [name, aggregate] of aggregateEntries) {
63
+ const value = aggregate.preMap(data)
64
+ values[name] = value
65
+ if (identity === null) continue
66
+ const part = structuralIdentity(value)
67
+ identity = part === undefined ? null : `${identity}|${part}`
81
68
  }
82
69
 
83
- return [keyString, values] as KeyValue<
70
+ return [keyString, [identity, values]] as KeyValue<
84
71
  string,
85
- Record<string | symbol, unknown>
72
+ [string | null, Record<string | symbol, unknown>]
86
73
  >
87
74
  }),
88
75
  )
89
76
 
90
77
  // Then reduce to compute aggregates
91
78
  const reduced = withKeysAndValues.pipe(
92
- reduce((values) => {
93
- // Calculate total multiplicity to check if the group should exist
94
- let totalMultiplicity = 0
95
- for (const [_, multiplicity] of values) {
96
- totalMultiplicity += multiplicity
97
- }
79
+ reduce(
80
+ (contributions) => {
81
+ // Calculate total multiplicity to check if the group should exist
82
+ let totalMultiplicity = 0
83
+ for (const [_, multiplicity] of contributions) {
84
+ totalMultiplicity += multiplicity
85
+ }
98
86
 
99
- // If total multiplicity is 0 or negative, the group should be removed completely
100
- if (totalMultiplicity <= 0) {
101
- return []
102
- }
87
+ // If total multiplicity is 0 or negative, the group should be removed completely
88
+ if (totalMultiplicity <= 0) {
89
+ return []
90
+ }
103
91
 
104
- const result: Record<PropertyKey, unknown> = {}
92
+ const result: Record<PropertyKey, unknown> = {}
105
93
 
106
- // Get the original key from first value in group
107
- const originalKey = values[0]?.[0]?.[KEY_SENTINEL]
108
- result[KEY_SENTINEL] = originalKey
94
+ // Get the original key from first value in group
95
+ const originalKey = contributions[0]?.[0][1][KEY_SENTINEL]
96
+ result[KEY_SENTINEL] = originalKey
109
97
 
110
- // Apply each aggregate function
111
- for (const [name, aggregate] of Object.entries(basicAggregates)) {
112
- const preValues = values.map(
113
- ([v, m]) => [v[name], m] as [any, number],
114
- )
115
- result[name] = aggregate.reduce(preValues)
116
- }
98
+ // Apply each aggregate function
99
+ for (const [name, aggregate] of aggregateEntries) {
100
+ const preValues = contributions.map(
101
+ ([[, v], m]) => [v[name], m] as [any, number],
102
+ )
103
+ result[name] = aggregate.reduce(preValues)
104
+ }
117
105
 
118
- return [[result, 1]]
119
- }),
106
+ return [[result, 1]]
107
+ },
108
+ { prefixIdentity: true },
109
+ ),
120
110
  )
121
111
 
122
112
  // Finally map to extract the key and include all values
@@ -132,7 +122,7 @@ export function groupBy<
132
122
  Object.assign(result, key)
133
123
 
134
124
  // Apply postMap if provided
135
- for (const [name, aggregate] of Object.entries(basicAggregates)) {
125
+ for (const [name, aggregate] of aggregateEntries) {
136
126
  if (aggregate.postMap) {
137
127
  result[name] = aggregate.postMap(values[name])
138
128
  } else {
@@ -8,7 +8,7 @@ import {
8
8
  topKBatch,
9
9
  } from './topKState.js'
10
10
  import { TopKArray, createKeyedComparator } from './topKArray.js'
11
- import type { IndexedValue, TopK } from './topKArray.js'
11
+ import type { IndexedValue } from './topKArray.js'
12
12
  import type { DifferenceStreamReader } from '../graph.js'
13
13
  import type { IStreamBuilder, PipedOperator } from '../types.js'
14
14
 
@@ -61,18 +61,6 @@ export class GroupedTopKWithFractionalIndexOperator<
61
61
  options.setWindowFn?.(this.#moveTopK.bind(this))
62
62
  }
63
63
 
64
- /**
65
- * Creates a new TopK data structure for a group.
66
- * Can be overridden in subclasses to use different implementations (e.g., B+ tree).
67
- */
68
- protected createTopK(
69
- offset: number,
70
- limit: number,
71
- comparator: (a: [K, T], b: [K, T]) => number,
72
- ): TopK<[K, T]> {
73
- return new TopKArray(offset, limit, comparator)
74
- }
75
-
76
64
  #getTotalSize(): number {
77
65
  let size = 0
78
66
  for (const state of this.#groupStates.values()) {
@@ -84,8 +72,9 @@ export class GroupedTopKWithFractionalIndexOperator<
84
72
  #getOrCreateGroupState(groupKey: unknown): TopKState<K, T> {
85
73
  let state = this.#groupStates.get(groupKey)
86
74
  if (!state) {
87
- const topK = this.createTopK(this.#offset, this.#limit, this.#comparator)
88
- state = new TopKState(topK)
75
+ state = new TopKState(
76
+ new TopKArray(this.#offset, this.#limit, this.#comparator),
77
+ )
89
78
  this.#groupStates.set(groupKey, state)
90
79
  }
91
80
  return state
@@ -2,20 +2,14 @@ export * from './pipe.js'
2
2
  export * from './map.js'
3
3
  export * from './tap.js'
4
4
  export * from './filter.js'
5
- export * from './negate.js'
6
5
  export * from './concat.js'
7
- export * from './debug.js'
8
6
  export * from './output.js'
9
7
  export * from './consolidate.js'
10
8
  export * from './join.js'
11
9
  export * from './reduce.js'
12
- export * from './count.js'
13
10
  export * from './distinct.js'
14
- export * from './keying.js'
15
- export * from './topK.js'
16
11
  export * from './topKWithFractionalIndex.js'
17
12
  export * from './groupedTopKWithFractionalIndex.js'
18
13
  export * from './orderBy.js'
19
14
  export * from './groupedOrderBy.js'
20
- export * from './filterBy.js'
21
15
  export { groupBy, groupByOperators } from './groupBy.js'