@tanstack/db-ivm 0.0.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.
@@ -0,0 +1,347 @@
1
+ import { generateKeyBetween } from "fractional-indexing"
2
+ import { DifferenceStreamWriter, UnaryOperator } from "../graph.js"
3
+ import { StreamBuilder } from "../d2.js"
4
+ import { MultiSet } from "../multiset.js"
5
+ import { Index } from "../indexes.js"
6
+ import { binarySearch, globalObjectIdGenerator } from "../utils.js"
7
+ import type { DifferenceStreamReader } from "../graph.js"
8
+ import type { IStreamBuilder, KeyValue, PipedOperator } from "../types.js"
9
+
10
+ export interface TopKWithFractionalIndexOptions {
11
+ limit?: number
12
+ offset?: number
13
+ }
14
+
15
+ export type TopKChanges<V> = {
16
+ /** Indicates which element moves into the topK (if any) */
17
+ moveIn: IndexedValue<V> | null
18
+ /** Indicates which element moves out of the topK (if any) */
19
+ moveOut: IndexedValue<V> | null
20
+ }
21
+
22
+ /**
23
+ * A topK data structure that supports insertions and deletions
24
+ * and returns changes to the topK.
25
+ */
26
+ export interface TopK<V> {
27
+ insert: (value: V) => TopKChanges<V>
28
+ delete: (value: V) => TopKChanges<V>
29
+ }
30
+
31
+ /**
32
+ * Implementation of a topK data structure.
33
+ * Uses a sorted array internally to store the values and keeps a topK window over that array.
34
+ * Inserts and deletes are O(n) operations because worst case an element is inserted/deleted
35
+ * at the start of the array which causes all the elements to shift to the right/left.
36
+ */
37
+ class TopKArray<V> implements TopK<V> {
38
+ #sortedValues: Array<IndexedValue<V>> = []
39
+ #comparator: (a: V, b: V) => number
40
+ #topKStart: number
41
+ #topKEnd: number
42
+
43
+ constructor(
44
+ offset: number,
45
+ limit: number,
46
+ comparator: (a: V, b: V) => number
47
+ ) {
48
+ this.#topKStart = offset
49
+ this.#topKEnd = offset + limit
50
+ this.#comparator = comparator
51
+ }
52
+
53
+ insert(value: V): TopKChanges<V> {
54
+ const result: TopKChanges<V> = { moveIn: null, moveOut: null }
55
+
56
+ // Lookup insert position
57
+ const index = this.#findIndex(value)
58
+ // Generate fractional index based on the fractional indices of the elements before and after it
59
+ const indexBefore =
60
+ index === 0 ? null : getIndex(this.#sortedValues[index - 1]!)
61
+ const indexAfter =
62
+ index === this.#sortedValues.length
63
+ ? null
64
+ : getIndex(this.#sortedValues[index]!)
65
+ const fractionalIndex = generateKeyBetween(indexBefore, indexAfter)
66
+
67
+ // Insert the value at the correct position
68
+ const val = indexedValue(value, fractionalIndex)
69
+ // Splice is O(n) where n = all elements in the collection (i.e. n >= k) !
70
+ this.#sortedValues.splice(index, 0, val)
71
+
72
+ // Check if the topK changed
73
+ if (index < this.#topKEnd) {
74
+ // The inserted element is either before the top K or within the top K
75
+ // If it is before the top K then it moves the element that was right before the topK into the topK
76
+ // If it is within the top K then the inserted element moves into the top K
77
+ // In both cases the last element of the old top K now moves out of the top K
78
+ const moveInIndex = Math.max(index, this.#topKStart)
79
+ if (moveInIndex < this.#sortedValues.length) {
80
+ // We actually have a topK
81
+ // because in some cases there may not be enough elements in the array to reach the start of the topK
82
+ // e.g. [1, 2, 3] with K = 2 and offset = 3 does not have a topK
83
+ result.moveIn = this.#sortedValues[moveInIndex]!
84
+
85
+ // We need to remove the element that falls out of the top K
86
+ // The element that falls out of the top K has shifted one to the right
87
+ // because of the element we inserted, so we find it at index topKEnd
88
+ if (this.#topKEnd < this.#sortedValues.length) {
89
+ result.moveOut = this.#sortedValues[this.#topKEnd]!
90
+ }
91
+ }
92
+ }
93
+
94
+ return result
95
+ }
96
+
97
+ /**
98
+ * Deletes a value that may or may not be in the topK.
99
+ * IMPORTANT: this assumes that the value is present in the collection
100
+ * if it's not the case it will remove the element
101
+ * that is on the position where the provided `value` would be.
102
+ */
103
+ delete(value: V): TopKChanges<V> {
104
+ const result: TopKChanges<V> = { moveIn: null, moveOut: null }
105
+
106
+ // Lookup delete position
107
+ const index = this.#findIndex(value)
108
+ // Remove the value at that position
109
+ const [removedElem] = this.#sortedValues.splice(index, 1)
110
+
111
+ // Check if the topK changed
112
+ if (index < this.#topKEnd) {
113
+ // The removed element is either before the top K or within the top K
114
+ // If it is before the top K then the first element of the topK moves out of the topK
115
+ // If it is within the top K then the removed element moves out of the topK
116
+ result.moveOut = removedElem!
117
+ if (index < this.#topKStart) {
118
+ // The removed element is before the topK
119
+ // so actually, the first element of the topK moves out of the topK
120
+ // and not the element that we removed
121
+ // The first element of the topK is now at index topKStart - 1
122
+ // since we removed an element before the topK
123
+ const moveOutIndex = this.#topKStart - 1
124
+ if (moveOutIndex < this.#sortedValues.length) {
125
+ result.moveOut = this.#sortedValues[moveOutIndex]!
126
+ } else {
127
+ // No value is moving out of the topK
128
+ // because there are no elements in the topK
129
+ result.moveOut = null
130
+ }
131
+ }
132
+
133
+ // Since we removed an element that was before or in the topK
134
+ // the first element after the topK moved one position to the left
135
+ // and thus falls into the topK now
136
+ const moveInIndex = this.#topKEnd - 1
137
+ if (moveInIndex < this.#sortedValues.length) {
138
+ result.moveIn = this.#sortedValues[moveInIndex]!
139
+ }
140
+ }
141
+
142
+ return result
143
+ }
144
+
145
+ // TODO: see if there is a way to refactor the code for insert and delete in the topK above
146
+ // because they are very similar, one is shifting the topK window to the left and the other is shifting it to the right
147
+ // so i have the feeling there is a common pattern here and we can implement both cases using that pattern
148
+
149
+ #findIndex(value: V): number {
150
+ return binarySearch(this.#sortedValues, indexedValue(value, ``), (a, b) =>
151
+ this.#comparator(getValue(a), getValue(b))
152
+ )
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Operator for fractional indexed topK operations
158
+ * This operator maintains fractional indices for sorted elements
159
+ * and only updates indices when elements move position
160
+ */
161
+ export class TopKWithFractionalIndexOperator<K, V1> extends UnaryOperator<
162
+ [K, V1],
163
+ [K, IndexedValue<V1>]
164
+ > {
165
+ #index = new Index<K, V1>()
166
+
167
+ /**
168
+ * topK data structure that supports insertions and deletions
169
+ * and returns changes to the topK.
170
+ */
171
+ #topK: TopK<TaggedValue<V1>>
172
+
173
+ constructor(
174
+ id: number,
175
+ inputA: DifferenceStreamReader<[K, V1]>,
176
+ output: DifferenceStreamWriter<[K, [V1, string]]>,
177
+ comparator: (a: V1, b: V1) => number,
178
+ options: TopKWithFractionalIndexOptions
179
+ ) {
180
+ super(id, inputA, output)
181
+ const limit = options.limit ?? Infinity
182
+ const offset = options.offset ?? 0
183
+ const compareTaggedValues = (a: TaggedValue<V1>, b: TaggedValue<V1>) => {
184
+ // First compare on the value
185
+ const valueComparison = comparator(untagValue(a), untagValue(b))
186
+ if (valueComparison !== 0) {
187
+ return valueComparison
188
+ }
189
+ // If the values are equal, compare on the tag (object identity)
190
+ const tieBreakerA = getTag(a)
191
+ const tieBreakerB = getTag(b)
192
+ return tieBreakerA - tieBreakerB
193
+ }
194
+ this.#topK = this.createTopK(offset, limit, compareTaggedValues)
195
+ }
196
+
197
+ protected createTopK(
198
+ offset: number,
199
+ limit: number,
200
+ comparator: (a: TaggedValue<V1>, b: TaggedValue<V1>) => number
201
+ ): TopK<TaggedValue<V1>> {
202
+ return new TopKArray(offset, limit, comparator)
203
+ }
204
+
205
+ run(): void {
206
+ const result: Array<[[K, [V1, string]], number]> = []
207
+ for (const message of this.inputMessages()) {
208
+ for (const [item, multiplicity] of message.getInner()) {
209
+ const [key, value] = item
210
+ this.processElement(key, value, multiplicity, result)
211
+ }
212
+ }
213
+
214
+ if (result.length > 0) {
215
+ this.output.sendData(new MultiSet(result))
216
+ }
217
+ }
218
+
219
+ processElement(
220
+ key: K,
221
+ value: V1,
222
+ multiplicity: number,
223
+ result: Array<[[K, [V1, string]], number]>
224
+ ): void {
225
+ const oldMultiplicity = this.#index.getMultiplicity(key, value)
226
+ this.#index.addValue(key, [value, multiplicity])
227
+ const newMultiplicity = this.#index.getMultiplicity(key, value)
228
+
229
+ let res: TopKChanges<TaggedValue<V1>> = {
230
+ moveIn: null,
231
+ moveOut: null,
232
+ }
233
+ if (oldMultiplicity <= 0 && newMultiplicity > 0) {
234
+ // The value was invisible but should now be visible
235
+ // Need to insert it into the array of sorted values
236
+ const taggedValue = tagValue(value)
237
+ res = this.#topK.insert(taggedValue)
238
+ } else if (oldMultiplicity > 0 && newMultiplicity <= 0) {
239
+ // The value was visible but should now be invisible
240
+ // Need to remove it from the array of sorted values
241
+ const taggedValue = tagValue(value)
242
+ res = this.#topK.delete(taggedValue)
243
+ } else {
244
+ // The value was invisible and it remains invisible
245
+ // or it was visible and remains visible
246
+ // so it doesn't affect the topK
247
+ }
248
+
249
+ if (res.moveIn) {
250
+ const valueWithoutTieBreaker = mapValue(res.moveIn, untagValue)
251
+ result.push([[key, valueWithoutTieBreaker], 1])
252
+ }
253
+
254
+ if (res.moveOut) {
255
+ const valueWithoutTieBreaker = mapValue(res.moveOut, untagValue)
256
+ result.push([[key, valueWithoutTieBreaker], -1])
257
+ }
258
+
259
+ return
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Limits the number of results based on a comparator, with optional offset.
265
+ * This works on a keyed stream, where the key is the first element of the tuple.
266
+ * The ordering is within a key group, i.e. elements are sorted within a key group
267
+ * and the limit + offset is applied to that sorted group.
268
+ * To order the entire stream, key by the same value for all elements such as null.
269
+ *
270
+ * Uses fractional indexing to minimize the number of changes when elements move positions.
271
+ * Each element is assigned a fractional index that is lexicographically sortable.
272
+ * When elements move, only the indices of the moved elements are updated, not all elements.
273
+ *
274
+ * @param comparator - A function that compares two elements
275
+ * @param options - An optional object containing limit and offset properties
276
+ * @returns A piped operator that orders the elements and limits the number of results
277
+ */
278
+ export function topKWithFractionalIndex<
279
+ KType extends T extends KeyValue<infer K, infer _V> ? K : never,
280
+ V1Type extends T extends KeyValue<KType, infer V> ? V : never,
281
+ T,
282
+ >(
283
+ comparator: (a: V1Type, b: V1Type) => number,
284
+ options?: TopKWithFractionalIndexOptions
285
+ ): PipedOperator<T, KeyValue<KType, [V1Type, string]>> {
286
+ const opts = options || {}
287
+
288
+ return (
289
+ stream: IStreamBuilder<T>
290
+ ): IStreamBuilder<KeyValue<KType, [V1Type, string]>> => {
291
+ const output = new StreamBuilder<KeyValue<KType, [V1Type, string]>>(
292
+ stream.graph,
293
+ new DifferenceStreamWriter<KeyValue<KType, [V1Type, string]>>()
294
+ )
295
+ const operator = new TopKWithFractionalIndexOperator<KType, V1Type>(
296
+ stream.graph.getNextOperatorId(),
297
+ stream.connectReader() as DifferenceStreamReader<KeyValue<KType, V1Type>>,
298
+ output.writer,
299
+ comparator,
300
+ opts
301
+ )
302
+ stream.graph.addOperator(operator)
303
+ stream.graph.addStream(output.connectReader())
304
+ return output
305
+ }
306
+ }
307
+
308
+ // Abstraction for fractionally indexed values
309
+ export type FractionalIndex = string
310
+ export type IndexedValue<V> = [V, FractionalIndex]
311
+
312
+ export function indexedValue<V>(
313
+ value: V,
314
+ index: FractionalIndex
315
+ ): IndexedValue<V> {
316
+ return [value, index]
317
+ }
318
+
319
+ export function getValue<V>(indexedVal: IndexedValue<V>): V {
320
+ return indexedVal[0]
321
+ }
322
+
323
+ export function getIndex<V>(indexedVal: IndexedValue<V>): FractionalIndex {
324
+ return indexedVal[1]
325
+ }
326
+
327
+ function mapValue<V, W>(
328
+ indexedVal: IndexedValue<V>,
329
+ f: (value: V) => W
330
+ ): IndexedValue<W> {
331
+ return [f(getValue(indexedVal)), getIndex(indexedVal)]
332
+ }
333
+
334
+ export type Tag = number
335
+ export type TaggedValue<V> = [V, Tag]
336
+
337
+ function tagValue<V>(value: V): TaggedValue<V> {
338
+ return [value, globalObjectIdGenerator.getId(value)]
339
+ }
340
+
341
+ function untagValue<V>(tieBreakerTaggedValue: TaggedValue<V>): V {
342
+ return tieBreakerTaggedValue[0]
343
+ }
344
+
345
+ function getTag<V>(tieBreakerTaggedValue: TaggedValue<V>): Tag {
346
+ return tieBreakerTaggedValue[1]
347
+ }
@@ -0,0 +1,306 @@
1
+ import { generateKeyBetween } from "fractional-indexing"
2
+ import { DifferenceStreamWriter } from "../graph.js"
3
+ import { StreamBuilder } from "../d2.js"
4
+ import {
5
+ TopKWithFractionalIndexOperator,
6
+ getIndex,
7
+ getValue,
8
+ indexedValue,
9
+ } from "./topKWithFractionalIndex.js"
10
+ import type { IStreamBuilder, KeyValue, PipedOperator } from "../types.js"
11
+ import type { DifferenceStreamReader } from "../graph.js"
12
+ import type {
13
+ IndexedValue,
14
+ TaggedValue,
15
+ TopK,
16
+ TopKChanges,
17
+ TopKWithFractionalIndexOptions,
18
+ } from "./topKWithFractionalIndex.js"
19
+
20
+ interface BTree<Key, Value> {
21
+ nextLowerPair: (key: Key) => [Key, Value] | undefined
22
+ nextHigherPair: (key: Key) => [Key, Value] | undefined
23
+ set: (key: Key, value: Value, overwrite?: boolean) => boolean
24
+ maxKey: () => Key | undefined
25
+ get: (key: Key, defaultValue?: Value) => Value | undefined
26
+ delete: (key: Key) => boolean
27
+ size: number
28
+ }
29
+
30
+ interface BTreeClass {
31
+ new <Key, Value>(
32
+ entries?: Array<[Key, Value]>,
33
+ compare?: (a: Key, b: Key) => number,
34
+ maxNodeSize?: number
35
+ ): BTree<Key, Value>
36
+ }
37
+
38
+ let BTree: BTreeClass | undefined
39
+
40
+ export async function loadBTree() {
41
+ if (BTree === undefined) {
42
+ const { default: _BTreeClass } = await import(`sorted-btree`)
43
+ BTree = _BTreeClass
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Implementation of a topK data structure that uses a B+ tree.
49
+ * The tree allows for logarithmic time insertions and deletions.
50
+ */
51
+ class TopKTree<V> implements TopK<V> {
52
+ #comparator: (a: V, b: V) => number
53
+ // topK is a window at position [topKStart, topKEnd[
54
+ // i.e. `topKStart` is inclusive and `topKEnd` is exclusive
55
+ #topKStart: number
56
+ #topKEnd: number
57
+
58
+ #tree: BTree<V, IndexedValue<V>>
59
+ #topKFirstElem: IndexedValue<V> | null = null // inclusive
60
+ #topKLastElem: IndexedValue<V> | null = null // inclusive
61
+
62
+ constructor(
63
+ offset: number,
64
+ limit: number,
65
+ comparator: (a: V, b: V) => number
66
+ ) {
67
+ if (BTree === undefined) {
68
+ throw new Error(
69
+ `B+ tree not loaded. You need to call loadBTree() before using TopKTree.`
70
+ )
71
+ }
72
+
73
+ this.#topKStart = offset
74
+ this.#topKEnd = offset + limit
75
+ this.#comparator = comparator
76
+ this.#tree = new BTree(undefined, comparator)
77
+ }
78
+
79
+ /**
80
+ * Insert a *new* value.
81
+ * Ignores the value if it is already present.
82
+ */
83
+ insert(value: V): TopKChanges<V> {
84
+ const result: TopKChanges<V> = { moveIn: null, moveOut: null }
85
+
86
+ // Get the elements before and after the value
87
+ const [, indexedValueBefore] = this.#tree.nextLowerPair(value) ?? [
88
+ null,
89
+ null,
90
+ ]
91
+ const [, indexedValueAfter] = this.#tree.nextHigherPair(value) ?? [
92
+ null,
93
+ null,
94
+ ]
95
+
96
+ const indexBefore = indexedValueBefore ? getIndex(indexedValueBefore) : null
97
+ const indexAfter = indexedValueAfter ? getIndex(indexedValueAfter) : null
98
+
99
+ // Generate a fractional index for the value
100
+ // based on the fractional indices of the elements before and after it
101
+ const fractionalIndex = generateKeyBetween(indexBefore, indexAfter)
102
+ const insertedElem = indexedValue(value, fractionalIndex)
103
+
104
+ // Insert the value into the tree
105
+ const inserted = this.#tree.set(value, insertedElem, false)
106
+ if (!inserted) {
107
+ // The value was already present in the tree
108
+ // ignore this insertions since we don't support overwrites!
109
+ return result
110
+ }
111
+
112
+ if (this.#tree.size - 1 < this.#topKStart) {
113
+ // We don't have a topK yet
114
+ // so we don't need to do anything
115
+ return result
116
+ }
117
+
118
+ if (this.#topKFirstElem) {
119
+ // We have a topK containing at least 1 element
120
+ if (this.#comparator(value, getValue(this.#topKFirstElem)) < 0) {
121
+ // The element was inserted before the topK
122
+ // so it moves the element that is right before the topK into the topK
123
+ const firstElem = getValue(this.#topKFirstElem)
124
+ const [, newFirstElem] = this.#tree.nextLowerPair(firstElem)!
125
+ this.#topKFirstElem = newFirstElem
126
+ result.moveIn = this.#topKFirstElem
127
+ } else if (
128
+ !this.#topKLastElem ||
129
+ this.#comparator(value, getValue(this.#topKLastElem)) < 0
130
+ ) {
131
+ // The element was inserted within the topK
132
+ result.moveIn = insertedElem
133
+ }
134
+
135
+ if (
136
+ this.#topKLastElem &&
137
+ this.#comparator(value, getValue(this.#topKLastElem)) < 0
138
+ ) {
139
+ // The element was inserted before or within the topK
140
+ // the newly inserted element pushes the last element of the topK out of the topK
141
+ // so the one before that becomes the new last element of the topK
142
+ const lastElem = this.#topKLastElem
143
+ const lastValue = getValue(lastElem)
144
+ const [, newLastElem] = this.#tree.nextLowerPair(lastValue)!
145
+ this.#topKLastElem = newLastElem
146
+ result.moveOut = lastElem
147
+ }
148
+ }
149
+
150
+ // If the tree has as many elements as the offset (i.e. #topKStart)
151
+ // then the insertion shifted the elements 1 position to the right
152
+ // and the last element in the tree is now the first element of the topK
153
+ if (this.#tree.size - 1 === this.#topKStart) {
154
+ const topKFirstKey = this.#tree.maxKey()!
155
+ this.#topKFirstElem = this.#tree.get(topKFirstKey)!
156
+ result.moveIn = this.#topKFirstElem
157
+ }
158
+
159
+ // By inserting this new element we now have a complete topK
160
+ // store the last element of the topK
161
+ if (this.#tree.size === this.#topKEnd) {
162
+ const topKLastKey = this.#tree.maxKey()!
163
+ this.#topKLastElem = this.#tree.get(topKLastKey)!
164
+ }
165
+
166
+ return result
167
+ }
168
+
169
+ delete(value: V): TopKChanges<V> {
170
+ const result: TopKChanges<V> = { moveIn: null, moveOut: null }
171
+
172
+ const deletedElem = this.#tree.get(value)
173
+ const deleted = this.#tree.delete(value)
174
+ if (!deleted) {
175
+ return result
176
+ }
177
+
178
+ if (!this.#topKFirstElem) {
179
+ // We didn't have a topK before the delete
180
+ // so we still can't have a topK after the delete
181
+ return result
182
+ }
183
+
184
+ if (this.#comparator(value, getValue(this.#topKFirstElem)) < 0) {
185
+ // We deleted an element that was before the topK
186
+ // so the topK has shifted one position to the left
187
+
188
+ // the old first element moves out of the topK
189
+ result.moveOut = this.#topKFirstElem
190
+ // the element that was right after the first element of the topK
191
+ // is now the new first element of the topK
192
+ const firstElem = getValue(this.#topKFirstElem)
193
+ const [, newFirstElem] = this.#tree.nextHigherPair(firstElem) ?? [
194
+ null,
195
+ null,
196
+ ]
197
+ this.#topKFirstElem = newFirstElem
198
+ } else if (
199
+ !this.#topKLastElem ||
200
+ // TODO: if on equal order the element is inserted *after* the already existing one
201
+ // then this check should become < 0
202
+ this.#comparator(value, getValue(this.#topKLastElem)) <= 0
203
+ ) {
204
+ // The element we deleted was within the topK
205
+ // so we need to signal that that element is no longer in the topK
206
+ result.moveOut = deletedElem!
207
+ }
208
+
209
+ if (
210
+ this.#topKLastElem &&
211
+ // TODO: if on equal order the element is inserted *after* the already existing one
212
+ // then this check should become < 0
213
+ this.#comparator(value, getValue(this.#topKLastElem)) <= 0
214
+ ) {
215
+ // The element we deleted was before or within the topK
216
+ // So the first element after the topK moved one position to the left
217
+ // and thus falls into the topK now
218
+ const lastElem = this.#topKLastElem
219
+ const lastValue = getValue(lastElem)
220
+ const [, newLastElem] = this.#tree.nextHigherPair(lastValue) ?? [
221
+ null,
222
+ null,
223
+ ]
224
+ this.#topKLastElem = newLastElem
225
+ if (newLastElem) {
226
+ result.moveIn = newLastElem
227
+ }
228
+ }
229
+
230
+ return result
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Operator for fractional indexed topK operations
236
+ * This operator maintains fractional indices for sorted elements
237
+ * and only updates indices when elements move position
238
+ */
239
+ export class TopKWithFractionalIndexBTreeOperator<
240
+ K,
241
+ V1,
242
+ > extends TopKWithFractionalIndexOperator<K, V1> {
243
+ protected override createTopK(
244
+ offset: number,
245
+ limit: number,
246
+ comparator: (a: TaggedValue<V1>, b: TaggedValue<V1>) => number
247
+ ): TopK<TaggedValue<V1>> {
248
+ if (BTree === undefined) {
249
+ throw new Error(
250
+ `B+ tree not loaded. You need to call loadBTree() before using TopKWithFractionalIndexBTreeOperator.`
251
+ )
252
+ }
253
+ return new TopKTree(offset, limit, comparator)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Limits the number of results based on a comparator, with optional offset.
259
+ * This works on a keyed stream, where the key is the first element of the tuple.
260
+ * The ordering is within a key group, i.e. elements are sorted within a key group
261
+ * and the limit + offset is applied to that sorted group.
262
+ * To order the entire stream, key by the same value for all elements such as null.
263
+ *
264
+ * Uses fractional indexing to minimize the number of changes when elements move positions.
265
+ * Each element is assigned a fractional index that is lexicographically sortable.
266
+ * When elements move, only the indices of the moved elements are updated, not all elements.
267
+ *
268
+ * @param comparator - A function that compares two elements
269
+ * @param options - An optional object containing limit and offset properties
270
+ * @returns A piped operator that orders the elements and limits the number of results
271
+ */
272
+ export function topKWithFractionalIndexBTree<
273
+ KType extends T extends KeyValue<infer K, infer _V> ? K : never,
274
+ V1Type extends T extends KeyValue<KType, infer V> ? V : never,
275
+ T,
276
+ >(
277
+ comparator: (a: V1Type, b: V1Type) => number,
278
+ options?: TopKWithFractionalIndexOptions
279
+ ): PipedOperator<T, KeyValue<KType, [V1Type, string]>> {
280
+ const opts = options || {}
281
+
282
+ if (BTree === undefined) {
283
+ throw new Error(
284
+ `B+ tree not loaded. You need to call loadBTree() before using topKWithFractionalIndexBTree.`
285
+ )
286
+ }
287
+
288
+ return (
289
+ stream: IStreamBuilder<T>
290
+ ): IStreamBuilder<KeyValue<KType, [V1Type, string]>> => {
291
+ const output = new StreamBuilder<KeyValue<KType, [V1Type, string]>>(
292
+ stream.graph,
293
+ new DifferenceStreamWriter<KeyValue<KType, [V1Type, string]>>()
294
+ )
295
+ const operator = new TopKWithFractionalIndexOperator<KType, V1Type>(
296
+ stream.graph.getNextOperatorId(),
297
+ stream.connectReader() as DifferenceStreamReader<KeyValue<KType, V1Type>>,
298
+ output.writer,
299
+ comparator,
300
+ opts
301
+ )
302
+ stream.graph.addOperator(operator)
303
+ stream.graph.addStream(output.connectReader())
304
+ return output
305
+ }
306
+ }