@tanstack/db-ivm 0.1.20 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/d2.cjs +12 -2
- package/dist/cjs/d2.cjs.map +1 -1
- package/dist/cjs/hashing/hash.cjs +92 -104
- package/dist/cjs/hashing/hash.cjs.map +1 -1
- package/dist/cjs/hashing/hash.d.cts +5 -0
- package/dist/cjs/hashing/temporal.cjs +18 -0
- package/dist/cjs/hashing/temporal.cjs.map +1 -0
- package/dist/cjs/hashing/temporal.d.cts +5 -0
- package/dist/cjs/multiset.cjs +47 -92
- package/dist/cjs/multiset.cjs.map +1 -1
- package/dist/cjs/multiset.d.cts +16 -0
- package/dist/cjs/operators/distinct.cjs.map +1 -1
- package/dist/cjs/operators/distinct.d.cts +1 -1
- package/dist/cjs/operators/groupBy.cjs +2 -2
- package/dist/cjs/operators/groupBy.cjs.map +1 -1
- package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs +4 -5
- package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs.map +1 -1
- package/dist/cjs/operators/topKArray.cjs +2 -0
- package/dist/cjs/operators/topKArray.cjs.map +1 -1
- package/dist/cjs/operators/topKState.cjs +30 -0
- package/dist/cjs/operators/topKState.cjs.map +1 -1
- package/dist/cjs/operators/topKState.d.cts +9 -0
- package/dist/cjs/operators/topKWithFractionalIndex.cjs +4 -5
- package/dist/cjs/operators/topKWithFractionalIndex.cjs.map +1 -1
- package/dist/cjs/utils.cjs +15 -61
- package/dist/cjs/utils.cjs.map +1 -1
- package/dist/cjs/utils.d.cts +3 -32
- package/dist/esm/d2.js +12 -2
- package/dist/esm/d2.js.map +1 -1
- package/dist/esm/hashing/hash.d.ts +5 -0
- package/dist/esm/hashing/hash.js +92 -104
- package/dist/esm/hashing/hash.js.map +1 -1
- package/dist/esm/hashing/temporal.d.ts +5 -0
- package/dist/esm/hashing/temporal.js +18 -0
- package/dist/esm/hashing/temporal.js.map +1 -0
- package/dist/esm/multiset.d.ts +16 -0
- package/dist/esm/multiset.js +48 -93
- package/dist/esm/multiset.js.map +1 -1
- package/dist/esm/operators/distinct.d.ts +1 -1
- package/dist/esm/operators/distinct.js.map +1 -1
- package/dist/esm/operators/groupBy.js +2 -2
- package/dist/esm/operators/groupBy.js.map +1 -1
- package/dist/esm/operators/groupedTopKWithFractionalIndex.js +5 -6
- package/dist/esm/operators/groupedTopKWithFractionalIndex.js.map +1 -1
- package/dist/esm/operators/topKArray.js +2 -0
- package/dist/esm/operators/topKArray.js.map +1 -1
- package/dist/esm/operators/topKState.d.ts +9 -0
- package/dist/esm/operators/topKState.js +31 -1
- package/dist/esm/operators/topKState.js.map +1 -1
- package/dist/esm/operators/topKWithFractionalIndex.js +5 -6
- package/dist/esm/operators/topKWithFractionalIndex.js.map +1 -1
- package/dist/esm/utils.d.ts +3 -32
- package/dist/esm/utils.js +15 -61
- package/dist/esm/utils.js.map +1 -1
- package/package.json +3 -1
- package/src/d2.ts +15 -2
- package/src/hashing/hash.ts +147 -135
- package/src/hashing/temporal.ts +25 -0
- package/src/multiset.ts +73 -132
- package/src/operators/distinct.ts +5 -3
- package/src/operators/groupBy.ts +8 -2
- package/src/operators/groupedTopKWithFractionalIndex.ts +10 -6
- package/src/operators/topKArray.ts +4 -0
- package/src/operators/topKState.ts +42 -0
- package/src/operators/topKWithFractionalIndex.ts +10 -6
- package/src/operators/topKWithFractionalIndexBTree.ts +7 -8
- package/src/utils.ts +20 -76
package/src/hashing/hash.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MurmurHashStream, getSymbolIdentity, randomHash } from './murmur.js'
|
|
2
|
+
import { isTemporal } from './temporal.js'
|
|
2
3
|
import type { Hasher } from './murmur.js'
|
|
3
4
|
|
|
4
5
|
/*
|
|
@@ -12,45 +13,27 @@ const NULL = randomHash()
|
|
|
12
13
|
const UNDEFINED = randomHash()
|
|
13
14
|
const KEY = randomHash()
|
|
14
15
|
const FUNCTIONS = randomHash()
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
16
|
+
// Identity is distinct even if two random hash values happen to collide.
|
|
17
|
+
const DATE_MARKER = { hash: randomHash() }
|
|
18
|
+
const REGEXP_MARKER = { hash: randomHash() }
|
|
19
|
+
const OBJECT_MARKER = { hash: randomHash() }
|
|
20
|
+
const ARRAY_MARKER = { hash: randomHash() }
|
|
21
|
+
const MAP_MARKER = { hash: randomHash() }
|
|
22
|
+
const SET_MARKER = { hash: randomHash() }
|
|
23
|
+
const UINT8ARRAY_MARKER = { hash: randomHash() }
|
|
24
|
+
const TEMPORAL_MARKER = { hash: randomHash() }
|
|
22
25
|
// Bound structural recursion and value visits. Shared acyclic subtrees are
|
|
23
26
|
// cached; cycles are rejected rather than given context-dependent hashes.
|
|
24
27
|
const MAX_STRUCTURAL_HASH_WORK = 1_000_000
|
|
25
28
|
const MAX_STRUCTURAL_HASH_DEPTH = 768
|
|
26
29
|
|
|
27
|
-
const temporalTypes = new Set([
|
|
28
|
-
`Temporal.Duration`,
|
|
29
|
-
`Temporal.Instant`,
|
|
30
|
-
`Temporal.PlainDate`,
|
|
31
|
-
`Temporal.PlainDateTime`,
|
|
32
|
-
`Temporal.PlainMonthDay`,
|
|
33
|
-
`Temporal.PlainTime`,
|
|
34
|
-
`Temporal.PlainYearMonth`,
|
|
35
|
-
`Temporal.ZonedDateTime`,
|
|
36
|
-
])
|
|
37
|
-
|
|
38
|
-
interface TemporalLike {
|
|
39
|
-
[Symbol.toStringTag]: string
|
|
40
|
-
toString: () => string
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function isTemporal(input: object): input is TemporalLike {
|
|
44
|
-
const tag = (input as Record<symbol, unknown>)[Symbol.toStringTag]
|
|
45
|
-
return typeof tag === `string` && temporalTypes.has(tag)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
30
|
// Maximum byte length for Uint8Arrays to hash by content instead of reference
|
|
49
31
|
// Arrays smaller than this will be hashed by content, allowing proper equality comparisons
|
|
50
32
|
// for small arrays like ULIDs (16 bytes) while still avoiding performance costs for large arrays
|
|
51
33
|
const UINT8ARRAY_CONTENT_HASH_THRESHOLD = 128
|
|
52
34
|
|
|
53
35
|
const hashCache = new WeakMap<object, number>()
|
|
36
|
+
const referenceValues = new WeakSet<object>()
|
|
54
37
|
|
|
55
38
|
/** @internal Register a mutable handle before it enters a structural value. */
|
|
56
39
|
export function registerOpaqueHash(value: object): void {
|
|
@@ -80,32 +63,33 @@ function hashObject(input: object, context: HashContext): number {
|
|
|
80
63
|
|
|
81
64
|
let valueHash: number | undefined
|
|
82
65
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
66
|
+
const [marker, header, body] = objectParts(input)
|
|
67
|
+
const hasher = new MurmurHashStream()
|
|
68
|
+
hasher.update(marker.hash)
|
|
69
|
+
// Header values of a type with a body (array length, RegExp fields)
|
|
70
|
+
// count toward the work cap; Date, binary, and Temporal headers do not.
|
|
71
|
+
for (const value of header)
|
|
72
|
+
if (body) updateHasher(hasher, value, context)
|
|
73
|
+
else hasher.update(value as string | number)
|
|
74
|
+
if (body) {
|
|
75
|
+
// Sorted string keys, then enumerable symbol keys by identity.
|
|
76
|
+
const keys: Array<string | symbol> = Object.keys(body).sort(keySort)
|
|
77
|
+
keys.push(
|
|
78
|
+
...Object.getOwnPropertySymbols(body)
|
|
79
|
+
.filter((key) =>
|
|
80
|
+
Object.prototype.propertyIsEnumerable.call(body, key),
|
|
81
|
+
)
|
|
82
|
+
.sort(
|
|
83
|
+
(left, right) => getSymbolIdentity(left) - getSymbolIdentity(right),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
for (const key of keys) {
|
|
87
|
+
hasher.update(KEY)
|
|
88
|
+
hasher.update(key)
|
|
89
|
+
updateHasher(hasher, body[key as keyof typeof body], context)
|
|
105
90
|
}
|
|
106
|
-
|
|
107
|
-
valueHash = hashPlainObject(plainObjectInput, marker, context)
|
|
108
91
|
}
|
|
92
|
+
valueHash = hasher.digest()
|
|
109
93
|
} finally {
|
|
110
94
|
context.activeObjects.delete(input)
|
|
111
95
|
}
|
|
@@ -114,59 +98,39 @@ function hashObject(input: object, context: HashContext): number {
|
|
|
114
98
|
return valueHash
|
|
115
99
|
}
|
|
116
100
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
function hashTemporal(input: TemporalLike): number {
|
|
137
|
-
const hasher = new MurmurHashStream()
|
|
138
|
-
hasher.update(TEMPORAL_MARKER)
|
|
139
|
-
hasher.update(input[Symbol.toStringTag])
|
|
140
|
-
hasher.update(input.toString())
|
|
141
|
-
return hasher.digest()
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function hashPlainObject(
|
|
145
|
-
input: object,
|
|
146
|
-
marker: number,
|
|
147
|
-
context: HashContext,
|
|
148
|
-
): number {
|
|
149
|
-
const hasher = new MurmurHashStream()
|
|
150
|
-
|
|
151
|
-
// Mark the type of the input
|
|
152
|
-
hasher.update(marker)
|
|
153
|
-
const keys = Object.keys(input)
|
|
154
|
-
keys.sort(keySort)
|
|
155
|
-
for (const key of keys) {
|
|
156
|
-
hasher.update(KEY)
|
|
157
|
-
hasher.update(key)
|
|
158
|
-
updateHasher(hasher, input[key as keyof typeof input], context)
|
|
159
|
-
}
|
|
160
|
-
const symbolKeys = Object.getOwnPropertySymbols(input)
|
|
161
|
-
.filter((key) => Object.prototype.propertyIsEnumerable.call(input, key))
|
|
162
|
-
.sort((left, right) => getSymbolIdentity(left) - getSymbolIdentity(right))
|
|
163
|
-
for (const key of symbolKeys) {
|
|
164
|
-
hasher.update(KEY)
|
|
165
|
-
hasher.update(key)
|
|
166
|
-
updateHasher(hasher, input[key as keyof typeof input], context)
|
|
101
|
+
/**
|
|
102
|
+
* The one type dispatch shared by structural hashing and equality: a type
|
|
103
|
+
* marker, primitive header values, and an optional object whose enumerable
|
|
104
|
+
* own properties are compared structurally.
|
|
105
|
+
*/
|
|
106
|
+
type ObjectParts = [
|
|
107
|
+
marker: Readonly<{ hash: number }>,
|
|
108
|
+
// A RegExp `lastIndex` can hold any value; the other headers are strings
|
|
109
|
+
// or numbers.
|
|
110
|
+
header: ReadonlyArray<unknown>,
|
|
111
|
+
body?: object,
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
function objectParts(input: object): ObjectParts {
|
|
115
|
+
if (input instanceof Date) {
|
|
116
|
+
const time = input.getTime()
|
|
117
|
+
// Invalid dates are equal to each other; headers compare with `===`.
|
|
118
|
+
return [DATE_MARKER, [Number.isNaN(time) ? `invalid` : time]]
|
|
167
119
|
}
|
|
168
|
-
|
|
169
|
-
|
|
120
|
+
if (isBinaryValue(input))
|
|
121
|
+
return [
|
|
122
|
+
UINT8ARRAY_MARKER,
|
|
123
|
+
[String.fromCharCode.apply(null, input as unknown as Array<number>)],
|
|
124
|
+
]
|
|
125
|
+
if (isTemporal(input))
|
|
126
|
+
return [TEMPORAL_MARKER, [input[Symbol.toStringTag], input.toString()]]
|
|
127
|
+
if (input instanceof RegExp)
|
|
128
|
+
return [REGEXP_MARKER, [input.source, input.flags, input.lastIndex], input]
|
|
129
|
+
if (input instanceof Map) return [MAP_MARKER, [], [...input.entries()]]
|
|
130
|
+
if (input instanceof Set) return [SET_MARKER, [], [...input.values()]]
|
|
131
|
+
return input instanceof Array
|
|
132
|
+
? [ARRAY_MARKER, [input.length], input]
|
|
133
|
+
: [OBJECT_MARKER, [], input]
|
|
170
134
|
}
|
|
171
135
|
|
|
172
136
|
function updateHasher(
|
|
@@ -204,56 +168,50 @@ function updateHasher(
|
|
|
204
168
|
// Functions are assigned a globally unique ID
|
|
205
169
|
// and that ID is cached in the weak map
|
|
206
170
|
hasher.update(cachedReferenceHash(input))
|
|
207
|
-
return
|
|
208
|
-
default:
|
|
209
|
-
console.warn(
|
|
210
|
-
`Ignored input during hashing because it is of type ${typeof input} which is not supported`,
|
|
211
|
-
)
|
|
212
171
|
}
|
|
213
172
|
}
|
|
214
173
|
|
|
215
174
|
function getCachedHash(input: object, context?: HashContext): number {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
// Only an uncached structural root needs graph traversal state. Commit its
|
|
222
|
-
// cache entries after success so a failed traversal cannot poison retries.
|
|
223
|
-
context = {
|
|
224
|
-
activeObjects: new Set(),
|
|
225
|
-
work: 0,
|
|
226
|
-
pendingHashes: new Map(),
|
|
227
|
-
}
|
|
228
|
-
const result = hashObject(input, context)
|
|
229
|
-
for (const [object, valueHash] of context.pendingHashes) {
|
|
230
|
-
hashCache.set(object, valueHash)
|
|
231
|
-
}
|
|
232
|
-
return result
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (context.activeObjects.has(input)) {
|
|
236
|
-
throw new TypeError(`Cannot hash cyclic structural values`)
|
|
237
|
-
}
|
|
175
|
+
// Active objects are neither cached nor pending, so the cycle check below
|
|
176
|
+
// still sees every back-reference.
|
|
177
|
+
const cached = hashCache.get(input) ?? context?.pendingHashes.get(input)
|
|
178
|
+
if (cached !== undefined) return cached
|
|
238
179
|
|
|
239
180
|
// Opaque leaves cannot contain structural back-references. Resolve them
|
|
240
181
|
// before entering structural recursion, even when they have user properties.
|
|
241
182
|
if (isReferenceHashedObject(input)) return cachedReferenceHash(input)
|
|
242
183
|
|
|
243
|
-
|
|
244
|
-
|
|
184
|
+
if (context) {
|
|
185
|
+
if (context.activeObjects.has(input)) {
|
|
186
|
+
throw new TypeError(`Cannot hash cyclic structural values`)
|
|
187
|
+
}
|
|
188
|
+
return hashObject(input, context)
|
|
189
|
+
}
|
|
245
190
|
|
|
246
|
-
|
|
191
|
+
// Only an uncached structural root needs graph traversal state. Commit its
|
|
192
|
+
// cache entries after success so a failed traversal cannot poison retries.
|
|
193
|
+
const root: HashContext = {
|
|
194
|
+
activeObjects: new Set(),
|
|
195
|
+
work: 0,
|
|
196
|
+
pendingHashes: new Map(),
|
|
197
|
+
}
|
|
198
|
+
const result = hashObject(input, root)
|
|
199
|
+
for (const [object, valueHash] of root.pendingHashes) {
|
|
200
|
+
hashCache.set(object, valueHash)
|
|
201
|
+
}
|
|
202
|
+
return result
|
|
247
203
|
}
|
|
248
204
|
|
|
249
205
|
function isReferenceHashedObject(input: object): boolean {
|
|
250
206
|
return (
|
|
251
|
-
input
|
|
207
|
+
referenceValues.has(input) ||
|
|
208
|
+
(typeof File !== `undefined` && input instanceof File) ||
|
|
252
209
|
(isBinaryValue(input) &&
|
|
253
210
|
input.byteLength > UINT8ARRAY_CONTENT_HASH_THRESHOLD)
|
|
254
211
|
)
|
|
255
212
|
}
|
|
256
213
|
|
|
214
|
+
// Buffer can come from another realm than Uint8Array (for example under jsdom).
|
|
257
215
|
function isBinaryValue(input: object): input is Uint8Array {
|
|
258
216
|
return (
|
|
259
217
|
(typeof Buffer !== `undefined` && input instanceof Buffer) ||
|
|
@@ -261,6 +219,59 @@ function isBinaryValue(input: object): input is Uint8Array {
|
|
|
261
219
|
)
|
|
262
220
|
}
|
|
263
221
|
|
|
222
|
+
/** @internal Compare immutable structural values without computing a digest.
|
|
223
|
+
* Pair memoization also permits cyclic values that structural hashing rejects.
|
|
224
|
+
* Reference-valued leaves remain opaque, including registered mutable handles.
|
|
225
|
+
*/
|
|
226
|
+
export function equalHashValues(left: unknown, right: unknown): boolean {
|
|
227
|
+
const compared = new Map<object, Set<object>>()
|
|
228
|
+
const keys = (value: object) =>
|
|
229
|
+
Reflect.ownKeys(value).filter((key) =>
|
|
230
|
+
Object.prototype.propertyIsEnumerable.call(value, key),
|
|
231
|
+
)
|
|
232
|
+
function equal(a: unknown, b: unknown): boolean {
|
|
233
|
+
if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true
|
|
234
|
+
if (
|
|
235
|
+
a === null ||
|
|
236
|
+
b === null ||
|
|
237
|
+
typeof a !== `object` ||
|
|
238
|
+
typeof b !== `object` ||
|
|
239
|
+
isReferenceHashedObject(a) ||
|
|
240
|
+
isReferenceHashedObject(b)
|
|
241
|
+
)
|
|
242
|
+
return false
|
|
243
|
+
// Arrays from another realm take the object marker but keep this check.
|
|
244
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length !== b.length)
|
|
245
|
+
return false
|
|
246
|
+
|
|
247
|
+
// Revisited pairs close cycles and avoid expanding shared subtrees. Check
|
|
248
|
+
// before objectParts copies Map or Set entries; record only pairs with a
|
|
249
|
+
// body, after their markers and headers match.
|
|
250
|
+
const peers = compared.get(a)
|
|
251
|
+
if (peers?.has(b)) return true
|
|
252
|
+
const [aMarker, aHeader, aBody] = objectParts(a)
|
|
253
|
+
const [bMarker, bHeader, bBody] = objectParts(b)
|
|
254
|
+
if (
|
|
255
|
+
aMarker !== bMarker ||
|
|
256
|
+
!aHeader.every((value, index) => value === bHeader[index])
|
|
257
|
+
)
|
|
258
|
+
return false
|
|
259
|
+
if (!aBody || !bBody) return true
|
|
260
|
+
if (peers) peers.add(b)
|
|
261
|
+
else compared.set(a, new Set([b]))
|
|
262
|
+
const aKeys = keys(aBody)
|
|
263
|
+
return (
|
|
264
|
+
aKeys.length === keys(bBody).length &&
|
|
265
|
+
aKeys.every(
|
|
266
|
+
(key) =>
|
|
267
|
+
Object.prototype.propertyIsEnumerable.call(bBody, key) &&
|
|
268
|
+
equal(aBody[key as keyof object], bBody[key as keyof object]),
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
return equal(left, right)
|
|
273
|
+
}
|
|
274
|
+
|
|
264
275
|
let nextRefId = 1
|
|
265
276
|
function cachedReferenceHash(fn: object): number {
|
|
266
277
|
let valueHash = hashCache.get(fn)
|
|
@@ -268,6 +279,7 @@ function cachedReferenceHash(fn: object): number {
|
|
|
268
279
|
valueHash = nextRefId ^ FUNCTIONS
|
|
269
280
|
nextRefId++
|
|
270
281
|
hashCache.set(fn, valueHash)
|
|
282
|
+
referenceValues.add(fn)
|
|
271
283
|
}
|
|
272
284
|
return valueHash
|
|
273
285
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const temporalTypes = new Set([
|
|
2
|
+
`Temporal.Duration`,
|
|
3
|
+
`Temporal.Instant`,
|
|
4
|
+
`Temporal.PlainDate`,
|
|
5
|
+
`Temporal.PlainDateTime`,
|
|
6
|
+
`Temporal.PlainMonthDay`,
|
|
7
|
+
`Temporal.PlainTime`,
|
|
8
|
+
`Temporal.PlainYearMonth`,
|
|
9
|
+
`Temporal.ZonedDateTime`,
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
export interface TemporalLike {
|
|
13
|
+
[Symbol.toStringTag]: string
|
|
14
|
+
toString: () => string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isTemporal(input: object): input is TemporalLike {
|
|
18
|
+
const tag = (input as Record<symbol, unknown>)[Symbol.toStringTag]
|
|
19
|
+
// An own tag on a plain object does not make it a Temporal value.
|
|
20
|
+
return (
|
|
21
|
+
typeof tag === `string` &&
|
|
22
|
+
temporalTypes.has(tag) &&
|
|
23
|
+
Object.getPrototypeOf(input)?.[Symbol.toStringTag] === tag
|
|
24
|
+
)
|
|
25
|
+
}
|
package/src/multiset.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DefaultMap,
|
|
3
|
-
chunkedArrayPush,
|
|
4
|
-
globalObjectIdGenerator,
|
|
5
|
-
} from './utils.js'
|
|
1
|
+
import { chunkedArrayPush, getStringId } from './utils.js'
|
|
6
2
|
import { hash } from './hashing/index.js'
|
|
7
3
|
|
|
8
4
|
export type MultiSetArray<T> = Array<[T, number]>
|
|
@@ -69,139 +65,59 @@ export class MultiSet<T> {
|
|
|
69
65
|
* Produce as output a collection that is logically equivalent to the input
|
|
70
66
|
* but which combines identical instances of the same record into one
|
|
71
67
|
* (record, multiplicity) pair.
|
|
72
|
-
*/
|
|
73
|
-
consolidate(): MultiSet<T> {
|
|
74
|
-
// Check if this looks like a keyed multiset (first item is a tuple of length 2)
|
|
75
|
-
if (this.#inner.length > 0) {
|
|
76
|
-
const firstItem = this.#inner[0]?.[0]
|
|
77
|
-
if (Array.isArray(firstItem) && firstItem.length === 2) {
|
|
78
|
-
return this.#consolidateKeyed()
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Fall back to original method for unkeyed data
|
|
83
|
-
return this.#consolidateUnkeyed()
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Private method for consolidating keyed multisets where keys are strings/numbers
|
|
88
|
-
* and values are compared by reference equality.
|
|
89
68
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
69
|
+
* Record identity depends on the shape of the whole multiset:
|
|
70
|
+
*
|
|
71
|
+
* - Keyed: every record is a `[key, value]` pair with a string or number
|
|
72
|
+
* key. Keys and primitive values compare by value, and object values by
|
|
73
|
+
* reference. A value that is an array of length 2 is a join tuple, and
|
|
74
|
+
* its two elements compare by the same rule. The keyed identity text
|
|
75
|
+
* has known collisions: https://github.com/TanStack/db/issues/1948
|
|
76
|
+
* - Unkeyed, one primitive type: every record is a string, or every record
|
|
77
|
+
* is a number. Records compare as `Map` keys do, and a `-0` record is
|
|
78
|
+
* returned as `0`.
|
|
79
|
+
* - Otherwise, records compare by structure (`hash`).
|
|
92
80
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
81
|
+
* Each identity keeps its first record in input order with the summed
|
|
82
|
+
* multiplicity. Identities whose sum is zero are dropped. The input is not
|
|
83
|
+
* changed.
|
|
95
84
|
*/
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const getTupleId = (tuple: Array<any>): string => {
|
|
108
|
-
if (tuple.length !== 2) {
|
|
109
|
-
throw new Error(`Expected tuple of length 2`)
|
|
110
|
-
}
|
|
111
|
-
const [first, second] = tuple
|
|
112
|
-
return `${globalObjectIdGenerator.getStringId(first)}|${globalObjectIdGenerator.getStringId(second)}`
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Process each item in the multiset
|
|
116
|
-
for (const [data, multiplicity] of this.#inner) {
|
|
117
|
-
// Verify this is still a keyed item (should be [key, value] pair)
|
|
118
|
-
if (!Array.isArray(data) || data.length !== 2) {
|
|
119
|
-
// Found non-keyed item, fall back to unkeyed consolidation
|
|
120
|
-
return this.#consolidateUnkeyed()
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const [key, value] = data
|
|
124
|
-
|
|
125
|
-
// Verify key is string or number as expected for keyed multisets
|
|
126
|
-
if (typeof key !== `string` && typeof key !== `number`) {
|
|
127
|
-
// Found non-string/number key, fall back to unkeyed consolidation
|
|
128
|
-
return this.#consolidateUnkeyed()
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Generate value ID with special handling for join tuples
|
|
132
|
-
let valueId: string
|
|
133
|
-
if (Array.isArray(value) && value.length === 2) {
|
|
134
|
-
// Special case: value is a tuple from join operations
|
|
135
|
-
valueId = getTupleId(value)
|
|
136
|
-
} else {
|
|
137
|
-
// Regular case: use reference/value equality
|
|
138
|
-
valueId = globalObjectIdGenerator.getStringId(value)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Create composite key and consolidate
|
|
142
|
-
const compositeKey = key + `|` + valueId
|
|
143
|
-
consolidated.set(
|
|
144
|
-
compositeKey,
|
|
145
|
-
(consolidated.get(compositeKey) || 0) + multiplicity,
|
|
85
|
+
consolidate(): MultiSet<T> {
|
|
86
|
+
const inner = this.#inner
|
|
87
|
+
// Keyed multisets hold [string | number key, value] pairs. Their values are
|
|
88
|
+
// compared by reference, which avoids hashing. Join tuples (values of
|
|
89
|
+
// length 2) are unpacked so ['A', null] and [null, 'X'] stay distinct.
|
|
90
|
+
if (
|
|
91
|
+
inner.every(
|
|
92
|
+
([data]) =>
|
|
93
|
+
Array.isArray(data) &&
|
|
94
|
+
data.length === 2 &&
|
|
95
|
+
(typeof data[0] === `string` || typeof data[0] === `number`),
|
|
146
96
|
)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
97
|
+
) {
|
|
98
|
+
return consolidateBy(inner, (data) => {
|
|
99
|
+
const [key, value] = data as [string | number, unknown]
|
|
100
|
+
const valueId =
|
|
101
|
+
Array.isArray(value) && value.length === 2
|
|
102
|
+
? `${getStringId(value[0])}|${getStringId(value[1])}`
|
|
103
|
+
: getStringId(value)
|
|
104
|
+
return key + `|` + valueId
|
|
105
|
+
})
|
|
152
106
|
}
|
|
153
107
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
*/
|
|
168
|
-
#consolidateUnkeyed(): MultiSet<T> {
|
|
169
|
-
const consolidated = new DefaultMap<string | number, number>(() => 0)
|
|
170
|
-
const values = new Map<string, any>()
|
|
171
|
-
|
|
172
|
-
let hasString = false
|
|
173
|
-
let hasNumber = false
|
|
174
|
-
let hasOther = false
|
|
175
|
-
for (const [data, _] of this.#inner) {
|
|
176
|
-
if (typeof data === `string`) {
|
|
177
|
-
hasString = true
|
|
178
|
-
} else if (typeof data === `number`) {
|
|
179
|
-
hasNumber = true
|
|
180
|
-
} else {
|
|
181
|
-
hasOther = true
|
|
182
|
-
break
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const requireJson = hasOther || (hasString && hasNumber)
|
|
187
|
-
|
|
188
|
-
for (const [data, multiplicity] of this.#inner) {
|
|
189
|
-
const key = requireJson ? hash(data) : (data as string | number)
|
|
190
|
-
if (requireJson && !values.has(key as string)) {
|
|
191
|
-
values.set(key as string, data)
|
|
192
|
-
}
|
|
193
|
-
consolidated.update(key, (count) => count + multiplicity)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const result: MultiSetArray<T> = []
|
|
197
|
-
for (const [key, multiplicity] of consolidated.entries()) {
|
|
198
|
-
if (multiplicity !== 0) {
|
|
199
|
-
const parsedKey = requireJson ? values.get(key as string) : key
|
|
200
|
-
result.push([parsedKey as T, multiplicity])
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return new MultiSet(result)
|
|
108
|
+
// Unkeyed data of a single primitive type is its own identity, and the
|
|
109
|
+
// result holds the Map key, so -0 becomes 0. Anything else is compared
|
|
110
|
+
// structurally by hash.
|
|
111
|
+
let primitiveType: string | undefined
|
|
112
|
+
const requireHash = inner.some(([data]) => {
|
|
113
|
+
const type = typeof data
|
|
114
|
+
if (type !== `string` && type !== `number`) return true
|
|
115
|
+
primitiveType ??= type
|
|
116
|
+
return type !== primitiveType
|
|
117
|
+
})
|
|
118
|
+
return requireHash
|
|
119
|
+
? consolidateBy(inner, hash)
|
|
120
|
+
: consolidateBy(inner, (data) => data, true)
|
|
205
121
|
}
|
|
206
122
|
|
|
207
123
|
extend(other: MultiSet<T> | MultiSetArray<T>): void {
|
|
@@ -219,3 +135,28 @@ export class MultiSet<T> {
|
|
|
219
135
|
return this.#inner
|
|
220
136
|
}
|
|
221
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sums multiplicities of records with the same identity, keeping the first
|
|
141
|
+
* record seen for each identity (or its Map key, which writes -0 as 0, when
|
|
142
|
+
* `keepIdentity` is set) and dropping records whose sum is zero.
|
|
143
|
+
*/
|
|
144
|
+
function consolidateBy<T>(
|
|
145
|
+
inner: MultiSetArray<T>,
|
|
146
|
+
identityOf: (data: T) => unknown,
|
|
147
|
+
keepIdentity = false,
|
|
148
|
+
): MultiSet<T> {
|
|
149
|
+
const consolidated = new Map<unknown, [T, number]>()
|
|
150
|
+
for (const [data, multiplicity] of inner) {
|
|
151
|
+
const identity = identityOf(data)
|
|
152
|
+
const entry = consolidated.get(identity)
|
|
153
|
+
if (entry) entry[1] += multiplicity
|
|
154
|
+
else consolidated.set(identity, [data, multiplicity])
|
|
155
|
+
}
|
|
156
|
+
const result: MultiSetArray<T> = []
|
|
157
|
+
for (const [identity, [data, multiplicity]] of consolidated) {
|
|
158
|
+
if (multiplicity !== 0)
|
|
159
|
+
result.push([keepIdentity ? (identity as T) : data, multiplicity])
|
|
160
|
+
}
|
|
161
|
+
return new MultiSet(result)
|
|
162
|
+
}
|
|
@@ -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 (
|
|
89
|
-
|
|
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(),
|
package/src/operators/groupBy.ts
CHANGED
|
@@ -237,7 +237,10 @@ export function min<T, V extends CanMinMax>(
|
|
|
237
237
|
reduce: (values) => {
|
|
238
238
|
let minValue: V | undefined
|
|
239
239
|
for (const [value, _multiplicity] of values) {
|
|
240
|
-
if (
|
|
240
|
+
if (
|
|
241
|
+
value !== undefined &&
|
|
242
|
+
(minValue === undefined || value < minValue)
|
|
243
|
+
) {
|
|
241
244
|
minValue = value
|
|
242
245
|
}
|
|
243
246
|
}
|
|
@@ -267,7 +270,10 @@ export function max<T, V extends CanMinMax>(
|
|
|
267
270
|
reduce: (values) => {
|
|
268
271
|
let maxValue: V | undefined
|
|
269
272
|
for (const [value, _multiplicity] of values) {
|
|
270
|
-
if (
|
|
273
|
+
if (
|
|
274
|
+
value !== undefined &&
|
|
275
|
+
(maxValue === undefined || value > maxValue)
|
|
276
|
+
) {
|
|
271
277
|
maxValue = value
|
|
272
278
|
}
|
|
273
279
|
}
|