@tanstack/db-ivm 0.1.20 → 0.1.22

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 (36) hide show
  1. package/dist/cjs/hashing/hash.cjs +52 -18
  2. package/dist/cjs/hashing/hash.cjs.map +1 -1
  3. package/dist/cjs/hashing/hash.d.cts +5 -0
  4. package/dist/cjs/operators/distinct.cjs.map +1 -1
  5. package/dist/cjs/operators/distinct.d.cts +1 -1
  6. package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs +4 -5
  7. package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs.map +1 -1
  8. package/dist/cjs/operators/topKArray.cjs +2 -0
  9. package/dist/cjs/operators/topKArray.cjs.map +1 -1
  10. package/dist/cjs/operators/topKState.cjs +30 -0
  11. package/dist/cjs/operators/topKState.cjs.map +1 -1
  12. package/dist/cjs/operators/topKState.d.cts +9 -0
  13. package/dist/cjs/operators/topKWithFractionalIndex.cjs +4 -5
  14. package/dist/cjs/operators/topKWithFractionalIndex.cjs.map +1 -1
  15. package/dist/esm/hashing/hash.d.ts +5 -0
  16. package/dist/esm/hashing/hash.js +52 -18
  17. package/dist/esm/hashing/hash.js.map +1 -1
  18. package/dist/esm/operators/distinct.d.ts +1 -1
  19. package/dist/esm/operators/distinct.js.map +1 -1
  20. package/dist/esm/operators/groupedTopKWithFractionalIndex.js +5 -6
  21. package/dist/esm/operators/groupedTopKWithFractionalIndex.js.map +1 -1
  22. package/dist/esm/operators/topKArray.js +2 -0
  23. package/dist/esm/operators/topKArray.js.map +1 -1
  24. package/dist/esm/operators/topKState.d.ts +9 -0
  25. package/dist/esm/operators/topKState.js +31 -1
  26. package/dist/esm/operators/topKState.js.map +1 -1
  27. package/dist/esm/operators/topKWithFractionalIndex.js +5 -6
  28. package/dist/esm/operators/topKWithFractionalIndex.js.map +1 -1
  29. package/package.json +3 -1
  30. package/src/hashing/hash.ts +88 -22
  31. package/src/operators/distinct.ts +5 -3
  32. package/src/operators/groupedTopKWithFractionalIndex.ts +10 -6
  33. package/src/operators/topKArray.ts +4 -0
  34. package/src/operators/topKState.ts +42 -0
  35. package/src/operators/topKWithFractionalIndex.ts +10 -6
  36. package/src/operators/topKWithFractionalIndexBTree.ts +7 -8
@@ -13,10 +13,12 @@ const UNDEFINED = randomHash()
13
13
  const KEY = randomHash()
14
14
  const FUNCTIONS = randomHash()
15
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()
16
+ const STRUCTURAL_MARKERS = {
17
+ object: randomHash(),
18
+ array: randomHash(),
19
+ map: randomHash(),
20
+ set: randomHash(),
21
+ }
20
22
  const UINT8ARRAY_MARKER = randomHash()
21
23
  const TEMPORAL_MARKER = randomHash()
22
24
  // Bound structural recursion and value visits. Shared acyclic subtrees are
@@ -51,6 +53,7 @@ function isTemporal(input: object): input is TemporalLike {
51
53
  const UINT8ARRAY_CONTENT_HASH_THRESHOLD = 128
52
54
 
53
55
  const hashCache = new WeakMap<object, number>()
56
+ const referenceValues = new WeakSet<object>()
54
57
 
55
58
  /** @internal Register a mutable handle before it enters a structural value. */
56
59
  export function registerOpaqueHash(value: object): void {
@@ -87,24 +90,12 @@ function hashObject(input: object, context: HashContext): number {
87
90
  } else if (isTemporal(input)) {
88
91
  valueHash = hashTemporal(input)
89
92
  } 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()]
105
- }
106
-
107
- valueHash = hashPlainObject(plainObjectInput, marker, context)
93
+ const [kind, plainObjectInput] = structuralShape(input)
94
+ valueHash = hashPlainObject(
95
+ plainObjectInput,
96
+ STRUCTURAL_MARKERS[kind],
97
+ context,
98
+ )
108
99
  }
109
100
  } finally {
110
101
  context.activeObjects.delete(input)
@@ -261,6 +252,80 @@ function isBinaryValue(input: object): input is Uint8Array {
261
252
  )
262
253
  }
263
254
 
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
+ /** @internal Compare immutable structural values without computing a digest.
264
+ * Pair memoization also permits cyclic values that structural hashing rejects.
265
+ * Reference-valued leaves remain opaque, including registered mutable handles.
266
+ */
267
+ export function equalHashValues(left: unknown, right: unknown): boolean {
268
+ const compared = new Map<object, Set<object>>()
269
+ function equal(a: unknown, b: unknown): boolean {
270
+ if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true
271
+ if (
272
+ a === null ||
273
+ b === null ||
274
+ typeof a !== `object` ||
275
+ typeof b !== `object`
276
+ )
277
+ return false
278
+ if (
279
+ referenceValues.has(a) ||
280
+ referenceValues.has(b) ||
281
+ isReferenceHashedObject(a) ||
282
+ isReferenceHashedObject(b)
283
+ )
284
+ 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
+ )
305
+
306
+ // Revisited pairs close cycles and avoid expanding shared subtrees.
307
+ const peers = compared.get(a)
308
+ if (peers?.has(b)) return true
309
+ if (peers) peers.add(b)
310
+ 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),
317
+ )
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
+ )
325
+ }
326
+ return equal(left, right)
327
+ }
328
+
264
329
  let nextRefId = 1
265
330
  function cachedReferenceHash(fn: object): number {
266
331
  let valueHash = hashCache.get(fn)
@@ -268,6 +333,7 @@ function cachedReferenceHash(fn: object): number {
268
333
  valueHash = nextRefId ^ FUNCTIONS
269
334
  nextRefId++
270
335
  hashCache.set(fn, valueHash)
336
+ referenceValues.add(fn)
271
337
  }
272
338
  return valueHash
273
339
  }
@@ -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(),
@@ -1,7 +1,12 @@
1
1
  import { DifferenceStreamWriter, UnaryOperator } from '../graph.js'
2
2
  import { StreamBuilder } from '../d2.js'
3
3
  import { MultiSet } from '../multiset.js'
4
- import { TopKState, handleMoveIn, handleMoveOut } from './topKState.js'
4
+ import {
5
+ TopKState,
6
+ handleMoveIn,
7
+ handleMoveOut,
8
+ topKBatch,
9
+ } from './topKState.js'
5
10
  import { TopKArray, createKeyedComparator } from './topKArray.js'
6
11
  import type { IndexedValue, TopK } from './topKArray.js'
7
12
  import type { DifferenceStreamReader } from '../graph.js'
@@ -125,11 +130,10 @@ export class GroupedTopKWithFractionalIndexOperator<
125
130
 
126
131
  run(): void {
127
132
  const result: Array<[[K, IndexedValue<T>], number]> = []
128
- for (const message of this.inputMessages()) {
129
- for (const [item, multiplicity] of message.getInner()) {
130
- const [key, value] = item
131
- this.#processElement(key, value, multiplicity, result)
132
- }
133
+ for (const [[key, value], multiplicity] of topKBatch(
134
+ this.inputMessages(),
135
+ )) {
136
+ this.#processElement(key, value, multiplicity, result)
133
137
  }
134
138
 
135
139
  if (result.length > 0) {
@@ -170,6 +170,8 @@ export class TopKArray<V> implements TopK<V> {
170
170
  // Splice is O(n) where n = all elements in the collection (i.e. n >= k) !
171
171
  this.#sortedValues.splice(index, 0, val)
172
172
 
173
+ if (this.#topKStart === this.#topKEnd) return result
174
+
173
175
  // Check if the topK changed
174
176
  if (index < this.#topKEnd) {
175
177
  // The inserted element is either before the top K or within the top K
@@ -209,6 +211,8 @@ export class TopKArray<V> implements TopK<V> {
209
211
  // Remove the value at that position
210
212
  const [removedElem] = this.#sortedValues.splice(index, 1)
211
213
 
214
+ if (this.#topKStart === this.#topKEnd) return result
215
+
212
216
  // Check if the topK changed
213
217
  if (index < this.#topKEnd) {
214
218
  // The removed element is either before the top K or within the top K
@@ -1,4 +1,6 @@
1
+ import { equalHashValues } from '../hashing/hash.js'
1
2
  import { TopKArray } from './topKArray.js'
3
+ import type { MultiSet } from '../multiset.js'
2
4
  import type {
3
5
  IndexedValue,
4
6
  TopK,
@@ -6,6 +8,46 @@ import type {
6
8
  TopKMoveChanges,
7
9
  } from './topKArray.js'
8
10
 
11
+ /** Apply a keyed batch's retractions before additions. An outer join can emit
12
+ * a replacement in the opposite order; key multiplicity alone would hide it.
13
+ * Consolidate first so a transient value added and removed in this same turn
14
+ * cannot be mistaken for the final replacement. Use structural relation
15
+ * identity: map can allocate a fresh value for each side of a cancelling pair.
16
+ * No state survives the run.
17
+ */
18
+ export function* topKBatch<K, T>(messages: Array<MultiSet<[K, T]>>) {
19
+ const keyed = new Map<K, Array<[[K, T], number]>>()
20
+ for (const message of messages) {
21
+ for (const [value, weight] of message.getInner()) {
22
+ const entries = keyed.get(value[0])
23
+ if (entries) entries.push([value, weight])
24
+ else keyed.set(value[0], [[value, weight]])
25
+ }
26
+ }
27
+ const batch: Array<[[K, T], number]> = []
28
+ for (const entries of keyed.values()) {
29
+ // Ordering distinct keys needs only the caller's comparator. In particular,
30
+ // do not traverse irrelevant payloads or merge keys on a hash collision.
31
+ if (entries.length === 1) {
32
+ batch.push(entries[0]!)
33
+ continue
34
+ }
35
+ // A per-key scan keeps normal two-version replacements cheap and permits
36
+ // cycles, at the cost of quadratic comparisons for long same-key histories.
37
+ const consolidated: Array<[[K, T], number]> = []
38
+ for (const [value, weight] of entries) {
39
+ const previous = consolidated.find(([candidate]) =>
40
+ equalHashValues(candidate[1], value[1]),
41
+ )
42
+ if (previous) previous[1] += weight
43
+ else consolidated.push([value, weight])
44
+ }
45
+ batch.push(...consolidated)
46
+ }
47
+ for (const entry of batch) if (entry[1] < 0) yield entry
48
+ for (const entry of batch) if (entry[1] > 0) yield entry
49
+ }
50
+
9
51
  /**
10
52
  * Helper class that manages the state for a single topK window.
11
53
  * Encapsulates the multiplicity tracking and topK data structure,
@@ -1,7 +1,12 @@
1
1
  import { DifferenceStreamWriter, UnaryOperator } from '../graph.js'
2
2
  import { StreamBuilder } from '../d2.js'
3
3
  import { MultiSet } from '../multiset.js'
4
- import { TopKState, handleMoveIn, handleMoveOut } from './topKState.js'
4
+ import {
5
+ TopKState,
6
+ handleMoveIn,
7
+ handleMoveOut,
8
+ topKBatch,
9
+ } from './topKState.js'
5
10
  import { TopKArray, createKeyedComparator } from './topKArray.js'
6
11
  import type { IndexedValue, TopK } from './topKArray.js'
7
12
  import type { DifferenceStreamReader } from '../graph.js'
@@ -76,11 +81,10 @@ export class TopKWithFractionalIndexOperator<
76
81
 
77
82
  run(): void {
78
83
  const result: Array<[[K, IndexedValue<T>], number]> = []
79
- for (const message of this.inputMessages()) {
80
- for (const [item, multiplicity] of message.getInner()) {
81
- const [key, value] = item
82
- this.processElement(key, value, multiplicity, result)
83
- }
84
+ for (const [[key, value], multiplicity] of topKBatch(
85
+ this.inputMessages(),
86
+ )) {
87
+ this.processElement(key, value, multiplicity, result)
84
88
  }
85
89
 
86
90
  if (result.length > 0) {
@@ -106,9 +106,11 @@ class TopKTree<V> implements TopK<V> {
106
106
  return result
107
107
  }
108
108
 
109
- if (this.#tree.size - 1 < this.#topKStart) {
110
- // We don't have a topK yet
111
- // so we don't need to do anything
109
+ if (
110
+ this.#topKStart === this.#topKEnd ||
111
+ this.#tree.size - 1 < this.#topKStart
112
+ ) {
113
+ // The window is empty, or there aren't enough rows to reach its offset.
112
114
  return result
113
115
  }
114
116
 
@@ -178,11 +180,8 @@ class TopKTree<V> implements TopK<V> {
178
180
  return result
179
181
  }
180
182
 
181
- if (this.#comparator(value, getValue(this.#topKFirstElem)) < 0) {
182
- // We deleted an element that was before the topK
183
- // so the topK has shifted one position to the left
184
-
185
- // the old first element moves out of the topK
183
+ if (this.#comparator(value, getValue(this.#topKFirstElem)) <= 0) {
184
+ // Deleting at or before the first selected value advances that boundary.
186
185
  result.moveOut = this.#topKFirstElem
187
186
  // the element that was right after the first element of the topK
188
187
  // is now the new first element of the topK