@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,215 @@
1
+ import {
2
+ DefaultMap,
3
+ chunkedArrayPush,
4
+ globalObjectIdGenerator,
5
+ hash,
6
+ } from "./utils.js"
7
+
8
+ export type MultiSetArray<T> = Array<[T, number]>
9
+ export type KeyedData<T> = [key: string, value: T]
10
+
11
+ /**
12
+ * A multiset of data.
13
+ */
14
+ export class MultiSet<T> {
15
+ #inner: MultiSetArray<T>
16
+
17
+ constructor(data: MultiSetArray<T> = []) {
18
+ this.#inner = data
19
+ }
20
+
21
+ toString(indent = false): string {
22
+ return `MultiSet(${JSON.stringify(this.#inner, null, indent ? 2 : undefined)})`
23
+ }
24
+
25
+ toJSON(): string {
26
+ return JSON.stringify(Array.from(this.getInner()))
27
+ }
28
+
29
+ static fromJSON<U>(json: string): MultiSet<U> {
30
+ return new MultiSet(JSON.parse(json))
31
+ }
32
+
33
+ /**
34
+ * Apply a function to all records in the collection.
35
+ */
36
+ map<U>(f: (data: T) => U): MultiSet<U> {
37
+ return new MultiSet(
38
+ this.#inner.map(([data, multiplicity]) => [f(data), multiplicity])
39
+ )
40
+ }
41
+
42
+ /**
43
+ * Filter out records for which a function f(record) evaluates to False.
44
+ */
45
+ filter(f: (data: T) => boolean): MultiSet<T> {
46
+ return new MultiSet(this.#inner.filter(([data, _]) => f(data)))
47
+ }
48
+
49
+ /**
50
+ * Negate all multiplicities in the collection.
51
+ */
52
+ negate(): MultiSet<T> {
53
+ return new MultiSet(
54
+ this.#inner.map(([data, multiplicity]) => [data, -multiplicity])
55
+ )
56
+ }
57
+
58
+ /**
59
+ * Concatenate two collections together.
60
+ */
61
+ concat(other: MultiSet<T>): MultiSet<T> {
62
+ const out: MultiSetArray<T> = []
63
+ chunkedArrayPush(out, this.#inner)
64
+ chunkedArrayPush(out, other.getInner())
65
+ return new MultiSet(out)
66
+ }
67
+
68
+ /**
69
+ * Produce as output a collection that is logically equivalent to the input
70
+ * but which combines identical instances of the same record into one
71
+ * (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
+ *
90
+ * This method provides significant performance improvements over the hash-based approach
91
+ * by using WeakMap for object reference tracking and avoiding expensive serialization.
92
+ *
93
+ * Special handling for join operations: When values are tuples of length 2 (common in joins),
94
+ * we unpack them and compare each element individually to maintain proper equality semantics.
95
+ */
96
+ #consolidateKeyed(): MultiSet<T> {
97
+ const consolidated = new Map<string, number>()
98
+ const values = new Map<string, T>()
99
+
100
+ // Use global object ID generator for consistent reference equality
101
+
102
+ /**
103
+ * Special handler for tuples (arrays of length 2) commonly produced by join operations.
104
+ * Unpacks the tuple and generates an ID based on both elements to ensure proper
105
+ * consolidation of join results like ['A', null] and [null, 'X'].
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
146
+ )
147
+
148
+ // Store the original data for the first occurrence
149
+ if (!values.has(compositeKey)) {
150
+ values.set(compositeKey, data as T)
151
+ }
152
+ }
153
+
154
+ // Build result array, filtering out zero multiplicities
155
+ const result: MultiSetArray<T> = []
156
+ for (const [compositeKey, multiplicity] of consolidated) {
157
+ if (multiplicity !== 0) {
158
+ result.push([values.get(compositeKey)!, multiplicity])
159
+ }
160
+ }
161
+
162
+ return new MultiSet(result)
163
+ }
164
+
165
+ /**
166
+ * Private method for consolidating unkeyed multisets using the original approach.
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)
205
+ }
206
+
207
+ extend(other: MultiSet<T> | MultiSetArray<T>): void {
208
+ const otherArray = other instanceof MultiSet ? other.getInner() : other
209
+ chunkedArrayPush(this.#inner, otherArray)
210
+ }
211
+
212
+ getInner(): MultiSetArray<T> {
213
+ return this.#inner
214
+ }
215
+ }
@@ -0,0 +1,45 @@
1
+ import { BinaryOperator, DifferenceStreamWriter } from "../graph.js"
2
+ import { StreamBuilder } from "../d2.js"
3
+ import type { IStreamBuilder, PipedOperator } from "../types.js"
4
+
5
+ /**
6
+ * Operator that concatenates two input streams
7
+ */
8
+ export class ConcatOperator<T, T2> extends BinaryOperator<T | T2> {
9
+ run(): void {
10
+ for (const message of this.inputAMessages()) {
11
+ this.output.sendData(message)
12
+ }
13
+
14
+ for (const message of this.inputBMessages()) {
15
+ this.output.sendData(message)
16
+ }
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Concatenates two input streams
22
+ * @param other - The other stream to concatenate
23
+ */
24
+ export function concat<T, T2>(
25
+ other: IStreamBuilder<T2>
26
+ ): PipedOperator<T, T | T2> {
27
+ return (stream: IStreamBuilder<T | T2>): IStreamBuilder<T | T2> => {
28
+ if (stream.graph !== other.graph) {
29
+ throw new Error(`Cannot concat streams from different graphs`)
30
+ }
31
+ const output = new StreamBuilder<T | T2>(
32
+ stream.graph,
33
+ new DifferenceStreamWriter<T | T2>()
34
+ )
35
+ const operator = new ConcatOperator<T, T2>(
36
+ stream.graph.getNextOperatorId(),
37
+ stream.connectReader(),
38
+ other.connectReader(),
39
+ output.writer
40
+ )
41
+ stream.graph.addOperator(operator)
42
+ stream.graph.addStream(output.connectReader())
43
+ return output
44
+ }
45
+ }
@@ -0,0 +1,50 @@
1
+ import { DifferenceStreamWriter, UnaryOperator } from "../graph.js"
2
+ import { StreamBuilder } from "../d2.js"
3
+ import { MultiSet } from "../multiset.js"
4
+ import type { IStreamBuilder, PipedOperator } from "../types.js"
5
+
6
+ /**
7
+ * Operator that consolidates collections
8
+ */
9
+ export class ConsolidateOperator<T> extends UnaryOperator<T> {
10
+ run(): void {
11
+ const messages = this.inputMessages()
12
+ if (messages.length === 0) {
13
+ return
14
+ }
15
+
16
+ // Combine all messages into a single MultiSet
17
+ const combined = new MultiSet<T>()
18
+ for (const message of messages) {
19
+ combined.extend(message)
20
+ }
21
+
22
+ // Consolidate the combined MultiSet
23
+ const consolidated = combined.consolidate()
24
+
25
+ // Only send if there are results
26
+ if (consolidated.getInner().length > 0) {
27
+ this.output.sendData(consolidated)
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Consolidates the elements in the stream
34
+ */
35
+ export function consolidate<T>(): PipedOperator<T, T> {
36
+ return (stream: IStreamBuilder<T>): IStreamBuilder<T> => {
37
+ const output = new StreamBuilder<T>(
38
+ stream.graph,
39
+ new DifferenceStreamWriter<T>()
40
+ )
41
+ const operator = new ConsolidateOperator<T>(
42
+ stream.graph.getNextOperatorId(),
43
+ stream.connectReader(),
44
+ output.writer
45
+ )
46
+ stream.graph.addOperator(operator)
47
+ stream.graph.addStream(output.connectReader())
48
+ return output
49
+ }
50
+ }
@@ -0,0 +1,52 @@
1
+ import { DifferenceStreamWriter } from "../graph.js"
2
+ import { StreamBuilder } from "../d2.js"
3
+ import { ReduceOperator } from "./reduce.js"
4
+ import type { DifferenceStreamReader } from "../graph.js"
5
+ import type { IStreamBuilder, KeyValue } from "../types.js"
6
+
7
+ /**
8
+ * Operator that counts elements by key (version-free)
9
+ */
10
+ export class CountOperator<K, V> extends ReduceOperator<K, V, number> {
11
+ constructor(
12
+ id: number,
13
+ inputA: DifferenceStreamReader<[K, V]>,
14
+ output: DifferenceStreamWriter<[K, number]>
15
+ ) {
16
+ const countInner = (vals: Array<[V, number]>): Array<[number, number]> => {
17
+ let totalCount = 0
18
+ for (const [_, diff] of vals) {
19
+ totalCount += diff
20
+ }
21
+ return [[totalCount, 1]]
22
+ }
23
+
24
+ super(id, inputA, output, countInner)
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Counts the number of elements by key (version-free)
30
+ */
31
+ export function count<
32
+ KType extends T extends KeyValue<infer K, infer _V> ? K : never,
33
+ VType extends T extends KeyValue<KType, infer V> ? V : never,
34
+ T,
35
+ >() {
36
+ return (
37
+ stream: IStreamBuilder<T>
38
+ ): IStreamBuilder<KeyValue<KType, number>> => {
39
+ const output = new StreamBuilder<KeyValue<KType, number>>(
40
+ stream.graph,
41
+ new DifferenceStreamWriter<KeyValue<KType, number>>()
42
+ )
43
+ const operator = new CountOperator<KType, VType>(
44
+ stream.graph.getNextOperatorId(),
45
+ stream.connectReader() as DifferenceStreamReader<KeyValue<KType, VType>>,
46
+ output.writer
47
+ )
48
+ stream.graph.addOperator(operator)
49
+ stream.graph.addStream(output.connectReader())
50
+ return output
51
+ }
52
+ }
@@ -0,0 +1,58 @@
1
+ import { DifferenceStreamWriter, UnaryOperator } from "../graph.js"
2
+ import { StreamBuilder } from "../d2.js"
3
+ import type { IStreamBuilder, PipedOperator } from "../types.js"
4
+ import type { DifferenceStreamReader } from "../graph.js"
5
+
6
+ /**
7
+ * Operator that logs debug information about the stream
8
+ */
9
+ export class DebugOperator<T> extends UnaryOperator<T> {
10
+ #name: string
11
+ #indent: boolean
12
+
13
+ constructor(
14
+ id: number,
15
+ inputA: DifferenceStreamReader<T>,
16
+ output: DifferenceStreamWriter<T>,
17
+ name: string,
18
+ indent: boolean = false
19
+ ) {
20
+ super(id, inputA, output)
21
+ this.#name = name
22
+ this.#indent = indent
23
+ }
24
+
25
+ run(): void {
26
+ for (const message of this.inputMessages()) {
27
+ console.log(`debug ${this.#name} data: ${message.toString(this.#indent)}`)
28
+ this.output.sendData(message)
29
+ }
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Logs debug information about the stream using console.log
35
+ * @param name - The name to prefix debug messages with
36
+ * @param indent - Whether to indent the debug output
37
+ */
38
+ export function debug<T>(
39
+ name: string,
40
+ indent: boolean = false
41
+ ): PipedOperator<T, T> {
42
+ return (stream: IStreamBuilder<T>): IStreamBuilder<T> => {
43
+ const output = new StreamBuilder<T>(
44
+ stream.graph,
45
+ new DifferenceStreamWriter<T>()
46
+ )
47
+ const operator = new DebugOperator<T>(
48
+ stream.graph.getNextOperatorId(),
49
+ stream.connectReader(),
50
+ output.writer,
51
+ name,
52
+ indent
53
+ )
54
+ stream.graph.addOperator(operator)
55
+ stream.graph.addStream(output.connectReader())
56
+ return output
57
+ }
58
+ }
@@ -0,0 +1,98 @@
1
+ import { DifferenceStreamWriter, UnaryOperator } from "../graph.js"
2
+ import { StreamBuilder } from "../d2.js"
3
+ import { hash } from "../utils.js"
4
+ import { MultiSet } from "../multiset.js"
5
+ import type { DifferenceStreamReader } from "../graph.js"
6
+ import type { IStreamBuilder } from "../types.js"
7
+
8
+ type HashedValue = string
9
+ type Multiplicity = number
10
+
11
+ /**
12
+ * Operator that removes duplicates
13
+ */
14
+ export class DistinctOperator<T> extends UnaryOperator<T> {
15
+ #by: (value: T) => any
16
+ #values: Map<HashedValue, Multiplicity> // keeps track of the number of times each value has been seen
17
+
18
+ constructor(
19
+ id: number,
20
+ input: DifferenceStreamReader<T>,
21
+ output: DifferenceStreamWriter<T>,
22
+ by: (value: T) => any = (value: T) => value
23
+ ) {
24
+ super(id, input, output)
25
+ this.#by = by
26
+ this.#values = new Map()
27
+ }
28
+
29
+ run(): void {
30
+ const updatedValues = new Map<HashedValue, [Multiplicity, T]>()
31
+
32
+ // Compute the new multiplicity for each value
33
+ for (const message of this.inputMessages()) {
34
+ for (const [value, diff] of message.getInner()) {
35
+ const hashedValue = hash(this.#by(value))
36
+
37
+ const oldMultiplicity =
38
+ updatedValues.get(hashedValue)?.[0] ??
39
+ this.#values.get(hashedValue) ??
40
+ 0
41
+ const newMultiplicity = oldMultiplicity + diff
42
+
43
+ updatedValues.set(hashedValue, [newMultiplicity, value])
44
+ }
45
+ }
46
+
47
+ const result: Array<[T, number]> = []
48
+
49
+ // Check which values became visible or disappeared
50
+ for (const [
51
+ hashedValue,
52
+ [newMultiplicity, value],
53
+ ] of updatedValues.entries()) {
54
+ const oldMultiplicity = this.#values.get(hashedValue) ?? 0
55
+
56
+ if (newMultiplicity === 0) {
57
+ this.#values.delete(hashedValue)
58
+ } else {
59
+ this.#values.set(hashedValue, newMultiplicity)
60
+ }
61
+
62
+ if (oldMultiplicity <= 0 && newMultiplicity > 0) {
63
+ // The value wasn't present in the stream
64
+ // but with this change it is now present in the stream
65
+ result.push([value, 1])
66
+ } else if (oldMultiplicity > 0 && newMultiplicity <= 0) {
67
+ // The value was present in the stream
68
+ // but with this change it is no longer present in the stream
69
+ result.push([value, -1])
70
+ }
71
+ }
72
+
73
+ if (result.length > 0) {
74
+ this.output.sendData(new MultiSet(result))
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Removes duplicate values
81
+ */
82
+ export function distinct<T>(by: (value: T) => any = (value: T) => value) {
83
+ return (stream: IStreamBuilder<T>): IStreamBuilder<T> => {
84
+ const output = new StreamBuilder<T>(
85
+ stream.graph,
86
+ new DifferenceStreamWriter<T>()
87
+ )
88
+ const operator = new DistinctOperator<T>(
89
+ stream.graph.getNextOperatorId(),
90
+ stream.connectReader(),
91
+ output.writer,
92
+ by
93
+ )
94
+ stream.graph.addOperator(operator)
95
+ stream.graph.addStream(output.connectReader())
96
+ return output
97
+ }
98
+ }
@@ -0,0 +1,48 @@
1
+ import { DifferenceStreamWriter, LinearUnaryOperator } from "../graph.js"
2
+ import { StreamBuilder } from "../d2.js"
3
+ import type { IStreamBuilder, PipedOperator } from "../types.js"
4
+ import type { DifferenceStreamReader } from "../graph.js"
5
+ import type { MultiSet } from "../multiset.js"
6
+
7
+ /**
8
+ * Operator that filters elements from the input stream
9
+ */
10
+ export class FilterOperator<T> extends LinearUnaryOperator<T, T> {
11
+ #f: (data: T) => boolean
12
+
13
+ constructor(
14
+ id: number,
15
+ inputA: DifferenceStreamReader<T>,
16
+ output: DifferenceStreamWriter<T>,
17
+ f: (data: T) => boolean
18
+ ) {
19
+ super(id, inputA, output)
20
+ this.#f = f
21
+ }
22
+
23
+ inner(collection: MultiSet<T>): MultiSet<T> {
24
+ return collection.filter(this.#f)
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Filters elements from the input stream
30
+ * @param f - The predicate to filter elements
31
+ */
32
+ export function filter<T>(f: (data: T) => boolean): PipedOperator<T, T> {
33
+ return (stream: IStreamBuilder<T>): IStreamBuilder<T> => {
34
+ const output = new StreamBuilder<T>(
35
+ stream.graph,
36
+ new DifferenceStreamWriter<T>()
37
+ )
38
+ const operator = new FilterOperator<T>(
39
+ stream.graph.getNextOperatorId(),
40
+ stream.connectReader(),
41
+ output.writer,
42
+ f
43
+ )
44
+ stream.graph.addOperator(operator)
45
+ stream.graph.addStream(output.connectReader())
46
+ return output
47
+ }
48
+ }
@@ -0,0 +1,31 @@
1
+ import { map } from "./map.js"
2
+ import { innerJoin } from "./join.js"
3
+ import { consolidate } from "./consolidate.js"
4
+ import type { KeyValue } from "../types.js"
5
+ import type { IStreamBuilder, PipedOperator } from "../types"
6
+
7
+ /**
8
+ * Filters the elements of a keyed stream, by keys of another stream.
9
+ * This allows you to build pipelies where you have multiple outputs that are related,
10
+ * such as a streams of issues and comments for a project.
11
+ *
12
+ * @param other - The other stream to filter by, which must have the same key type as the input stream
13
+ */
14
+ export function filterBy<
15
+ K,
16
+ V1 extends T extends KeyValue<infer _KT, infer VT> ? VT : never,
17
+ T,
18
+ >(
19
+ other: IStreamBuilder<KeyValue<K, unknown>>
20
+ ): PipedOperator<T, KeyValue<K, V1>> {
21
+ return (stream: IStreamBuilder<T>): IStreamBuilder<KeyValue<K, V1>> => {
22
+ const otherKeys = other.pipe(
23
+ map(([key, _]) => [key, null] as KeyValue<K, null>)
24
+ )
25
+ return stream.pipe(
26
+ innerJoin(otherKeys),
27
+ map(([key, [value, _]]) => [key, value] as KeyValue<K, V1>),
28
+ consolidate()
29
+ )
30
+ }
31
+ }