@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.
- package/README.md +5 -6
- package/dist/cjs/hashing/hash.cjs +58 -3
- package/dist/cjs/hashing/hash.cjs.map +1 -1
- package/dist/cjs/hashing/hash.d.cts +9 -0
- package/dist/cjs/hashing/murmur.cjs +26 -15
- package/dist/cjs/hashing/murmur.cjs.map +1 -1
- package/dist/cjs/hashing/murmur.d.cts +2 -0
- package/dist/cjs/index.cjs +0 -26
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/indexes.cjs +52 -16
- package/dist/cjs/indexes.cjs.map +1 -1
- package/dist/cjs/indexes.d.cts +4 -1
- package/dist/cjs/operators/groupBy.cjs +33 -36
- package/dist/cjs/operators/groupBy.cjs.map +1 -1
- package/dist/cjs/operators/groupBy.d.cts +1 -5
- package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs +3 -9
- package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs.map +1 -1
- package/dist/cjs/operators/groupedTopKWithFractionalIndex.d.cts +1 -15
- package/dist/cjs/operators/index.d.cts +0 -6
- package/dist/cjs/operators/join.cjs +0 -20
- package/dist/cjs/operators/join.cjs.map +1 -1
- package/dist/cjs/operators/join.d.cts +0 -40
- package/dist/cjs/operators/orderBy.cjs +2 -77
- package/dist/cjs/operators/orderBy.cjs.map +1 -1
- package/dist/cjs/operators/orderBy.d.cts +2 -31
- package/dist/cjs/operators/reduce.cjs +36 -5
- package/dist/cjs/operators/reduce.cjs.map +1 -1
- package/dist/cjs/operators/reduce.d.cts +11 -2
- package/dist/cjs/operators/topKArray.cjs +9 -0
- package/dist/cjs/operators/topKArray.cjs.map +1 -1
- package/dist/cjs/operators/topKArray.d.cts +6 -0
- package/dist/cjs/operators/topKState.cjs +3 -0
- package/dist/cjs/operators/topKState.cjs.map +1 -1
- package/dist/cjs/operators/topKState.d.cts +1 -0
- package/dist/cjs/operators/topKWithFractionalIndex.cjs +5 -9
- package/dist/cjs/operators/topKWithFractionalIndex.cjs.map +1 -1
- package/dist/cjs/operators/topKWithFractionalIndex.d.cts +7 -12
- package/dist/cjs/utils.cjs.map +1 -1
- package/dist/cjs/utils.d.cts +0 -7
- package/dist/esm/hashing/hash.d.ts +9 -0
- package/dist/esm/hashing/hash.js +60 -5
- package/dist/esm/hashing/hash.js.map +1 -1
- package/dist/esm/hashing/murmur.d.ts +2 -0
- package/dist/esm/hashing/murmur.js +26 -15
- package/dist/esm/hashing/murmur.js.map +1 -1
- package/dist/esm/index.js +3 -29
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/indexes.d.ts +4 -1
- package/dist/esm/indexes.js +52 -16
- package/dist/esm/indexes.js.map +1 -1
- package/dist/esm/operators/groupBy.d.ts +1 -5
- package/dist/esm/operators/groupBy.js +33 -36
- package/dist/esm/operators/groupBy.js.map +1 -1
- package/dist/esm/operators/groupedTopKWithFractionalIndex.d.ts +1 -6
- package/dist/esm/operators/groupedTopKWithFractionalIndex.js +3 -9
- package/dist/esm/operators/groupedTopKWithFractionalIndex.js.map +1 -1
- package/dist/esm/operators/index.d.ts +0 -6
- package/dist/esm/operators/join.d.ts +0 -25
- package/dist/esm/operators/join.js +1 -21
- package/dist/esm/operators/join.js.map +1 -1
- package/dist/esm/operators/orderBy.d.ts +2 -22
- package/dist/esm/operators/orderBy.js +3 -78
- package/dist/esm/operators/orderBy.js.map +1 -1
- package/dist/esm/operators/reduce.d.ts +11 -2
- package/dist/esm/operators/reduce.js +36 -5
- package/dist/esm/operators/reduce.js.map +1 -1
- package/dist/esm/operators/topKArray.d.ts +6 -0
- package/dist/esm/operators/topKArray.js +9 -0
- package/dist/esm/operators/topKArray.js.map +1 -1
- package/dist/esm/operators/topKState.d.ts +1 -0
- package/dist/esm/operators/topKState.js +3 -0
- package/dist/esm/operators/topKState.js.map +1 -1
- package/dist/esm/operators/topKWithFractionalIndex.d.ts +7 -3
- package/dist/esm/operators/topKWithFractionalIndex.js +6 -10
- package/dist/esm/operators/topKWithFractionalIndex.js.map +1 -1
- package/dist/esm/utils.d.ts +0 -7
- package/dist/esm/utils.js.map +1 -1
- package/package.json +2 -4
- package/src/hashing/hash.ts +87 -5
- package/src/hashing/murmur.ts +31 -17
- package/src/indexes.ts +65 -16
- package/src/operators/groupBy.ts +48 -58
- package/src/operators/groupedTopKWithFractionalIndex.ts +4 -15
- package/src/operators/index.ts +0 -6
- package/src/operators/join.ts +0 -90
- package/src/operators/orderBy.ts +6 -141
- package/src/operators/reduce.ts +60 -3
- package/src/operators/topKArray.ts +15 -0
- package/src/operators/topKState.ts +7 -0
- package/src/operators/topKWithFractionalIndex.ts +15 -16
- package/src/utils.ts +0 -30
- package/dist/cjs/operators/count.cjs +0 -35
- package/dist/cjs/operators/count.cjs.map +0 -1
- package/dist/cjs/operators/count.d.cts +0 -19
- package/dist/cjs/operators/debug.cjs +0 -39
- package/dist/cjs/operators/debug.cjs.map +0 -1
- package/dist/cjs/operators/debug.d.cts +0 -16
- package/dist/cjs/operators/filterBy.cjs +0 -19
- package/dist/cjs/operators/filterBy.cjs.map +0 -1
- package/dist/cjs/operators/filterBy.d.cts +0 -9
- package/dist/cjs/operators/keying.cjs +0 -16
- package/dist/cjs/operators/keying.cjs.map +0 -1
- package/dist/cjs/operators/keying.d.cts +0 -22
- package/dist/cjs/operators/negate.cjs +0 -27
- package/dist/cjs/operators/negate.cjs.map +0 -1
- package/dist/cjs/operators/negate.d.cts +0 -13
- package/dist/cjs/operators/topK.cjs +0 -39
- package/dist/cjs/operators/topK.cjs.map +0 -1
- package/dist/cjs/operators/topK.d.cts +0 -34
- package/dist/cjs/operators/topKWithFractionalIndexBTree.d.cts +0 -43
- package/dist/esm/operators/count.d.ts +0 -13
- package/dist/esm/operators/count.js +0 -35
- package/dist/esm/operators/count.js.map +0 -1
- package/dist/esm/operators/debug.d.ts +0 -16
- package/dist/esm/operators/debug.js +0 -39
- package/dist/esm/operators/debug.js.map +0 -1
- package/dist/esm/operators/filterBy.d.ts +0 -9
- package/dist/esm/operators/filterBy.js +0 -19
- package/dist/esm/operators/filterBy.js.map +0 -1
- package/dist/esm/operators/keying.d.ts +0 -19
- package/dist/esm/operators/keying.js +0 -16
- package/dist/esm/operators/keying.js.map +0 -1
- package/dist/esm/operators/negate.d.ts +0 -13
- package/dist/esm/operators/negate.js +0 -27
- package/dist/esm/operators/negate.js.map +0 -1
- package/dist/esm/operators/topK.d.ts +0 -31
- package/dist/esm/operators/topK.js +0 -39
- package/dist/esm/operators/topK.js.map +0 -1
- package/dist/esm/operators/topKWithFractionalIndexBTree.d.ts +0 -28
- package/src/operators/count.ts +0 -51
- package/src/operators/debug.ts +0 -57
- package/src/operators/filterBy.ts +0 -30
- package/src/operators/keying.ts +0 -34
- package/src/operators/negate.ts +0 -32
- package/src/operators/topK.ts +0 -91
- 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.
|
|
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
|
},
|
package/src/hashing/hash.ts
CHANGED
|
@@ -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
|
-
//
|
|
76
|
-
|
|
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
|
-
*
|
|
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
|
|
291
|
-
|
|
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
|
}
|
package/src/hashing/murmur.ts
CHANGED
|
@@ -63,11 +63,8 @@ export interface Hasher {
|
|
|
63
63
|
digest: () => number
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// Allocate
|
|
67
|
-
const
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
127
|
-
this.
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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(
|
|
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
|
-
(
|
|
435
|
+
((this.#prefixIdentity && newPrefix !== NO_PREFIX) ||
|
|
436
|
+
hash(currentValue) === hash(newValue))
|
|
388
437
|
) {
|
|
389
438
|
const newMultiplicity = currentMultiplicity + multiplicity
|
|
390
439
|
if (newMultiplicity === 0) {
|
package/src/operators/groupBy.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
92
|
+
const result: Record<PropertyKey, unknown> = {}
|
|
105
93
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
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
|
package/src/operators/index.ts
CHANGED
|
@@ -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'
|