@tanstack/db 0.1.6 → 0.1.8
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/collection.cjs +3 -1
- package/dist/cjs/collection.cjs.map +1 -1
- package/dist/cjs/collection.d.cts +5 -5
- package/dist/cjs/query/compiler/group-by.cjs +4 -2
- package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
- package/dist/cjs/query/compiler/index.cjs +2 -1
- package/dist/cjs/query/compiler/index.cjs.map +1 -1
- package/dist/cjs/query/index.d.cts +2 -1
- package/dist/cjs/query/ir.cjs +16 -0
- package/dist/cjs/query/ir.cjs.map +1 -1
- package/dist/cjs/query/ir.d.cts +24 -1
- package/dist/cjs/query/live/collection-config-builder.cjs +274 -0
- package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -0
- package/dist/cjs/query/live/collection-config-builder.d.cts +34 -0
- package/dist/cjs/query/live/collection-subscriber.cjs +272 -0
- package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -0
- package/dist/cjs/query/live/collection-subscriber.d.cts +28 -0
- package/dist/cjs/query/live/types.d.cts +77 -0
- package/dist/cjs/query/live-query-collection.cjs +3 -417
- package/dist/cjs/query/live-query-collection.cjs.map +1 -1
- package/dist/cjs/query/live-query-collection.d.cts +1 -58
- package/dist/cjs/query/optimizer.cjs +34 -11
- package/dist/cjs/query/optimizer.cjs.map +1 -1
- package/dist/cjs/types.d.cts +12 -0
- package/dist/esm/collection.d.ts +5 -5
- package/dist/esm/collection.js +3 -1
- package/dist/esm/collection.js.map +1 -1
- package/dist/esm/query/compiler/group-by.js +5 -3
- package/dist/esm/query/compiler/group-by.js.map +1 -1
- package/dist/esm/query/compiler/index.js +3 -2
- package/dist/esm/query/compiler/index.js.map +1 -1
- package/dist/esm/query/index.d.ts +2 -1
- package/dist/esm/query/ir.d.ts +24 -1
- package/dist/esm/query/ir.js +17 -1
- package/dist/esm/query/ir.js.map +1 -1
- package/dist/esm/query/live/collection-config-builder.d.ts +34 -0
- package/dist/esm/query/live/collection-config-builder.js +274 -0
- package/dist/esm/query/live/collection-config-builder.js.map +1 -0
- package/dist/esm/query/live/collection-subscriber.d.ts +28 -0
- package/dist/esm/query/live/collection-subscriber.js +272 -0
- package/dist/esm/query/live/collection-subscriber.js.map +1 -0
- package/dist/esm/query/live/types.d.ts +77 -0
- package/dist/esm/query/live-query-collection.d.ts +1 -58
- package/dist/esm/query/live-query-collection.js +3 -417
- package/dist/esm/query/live-query-collection.js.map +1 -1
- package/dist/esm/query/optimizer.js +35 -12
- package/dist/esm/query/optimizer.js.map +1 -1
- package/dist/esm/types.d.ts +12 -0
- package/package.json +1 -1
- package/src/collection.ts +14 -6
- package/src/query/compiler/group-by.ts +5 -3
- package/src/query/compiler/index.ts +3 -2
- package/src/query/index.ts +2 -1
- package/src/query/ir.ts +48 -1
- package/src/query/live/collection-config-builder.ts +446 -0
- package/src/query/live/collection-subscriber.ts +479 -0
- package/src/query/live/types.ts +93 -0
- package/src/query/live-query-collection.ts +8 -791
- package/src/query/optimizer.ts +66 -18
- package/src/types.ts +74 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { MultiSet } from "@tanstack/db-ivm"
|
|
2
|
+
import { createFilterFunctionFromExpression } from "../../change-events.js"
|
|
3
|
+
import { convertToBasicExpression } from "../compiler/expressions.js"
|
|
4
|
+
import type { FullSyncState } from "./types.js"
|
|
5
|
+
import type { MultiSetArray, RootStreamBuilder } from "@tanstack/db-ivm"
|
|
6
|
+
import type { Collection } from "../../collection.js"
|
|
7
|
+
import type { ChangeMessage, SyncConfig } from "../../types.js"
|
|
8
|
+
import type { Context, GetResult } from "../builder/types.js"
|
|
9
|
+
import type { BasicExpression } from "../ir.js"
|
|
10
|
+
import type { CollectionConfigBuilder } from "./collection-config-builder.js"
|
|
11
|
+
|
|
12
|
+
export class CollectionSubscriber<
|
|
13
|
+
TContext extends Context,
|
|
14
|
+
TResult extends object = GetResult<TContext>,
|
|
15
|
+
> {
|
|
16
|
+
// Keep track of the keys we've sent (needed for join and orderBy optimizations)
|
|
17
|
+
private sentKeys = new Set<string | number>()
|
|
18
|
+
|
|
19
|
+
// Keep track of the biggest value we've sent so far (needed for orderBy optimization)
|
|
20
|
+
private biggest: any = undefined
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private collectionId: string,
|
|
24
|
+
private collection: Collection,
|
|
25
|
+
private config: Parameters<SyncConfig<TResult>[`sync`]>[0],
|
|
26
|
+
private syncState: FullSyncState,
|
|
27
|
+
private collectionConfigBuilder: CollectionConfigBuilder<TContext, TResult>
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
subscribe() {
|
|
31
|
+
const collectionAlias = findCollectionAlias(
|
|
32
|
+
this.collectionId,
|
|
33
|
+
this.collectionConfigBuilder.query
|
|
34
|
+
)
|
|
35
|
+
const whereClause = this.getWhereClauseFromAlias(collectionAlias)
|
|
36
|
+
|
|
37
|
+
if (whereClause) {
|
|
38
|
+
// Convert WHERE clause to BasicExpression format for collection subscription
|
|
39
|
+
const whereExpression = convertToBasicExpression(
|
|
40
|
+
whereClause,
|
|
41
|
+
collectionAlias!
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (whereExpression) {
|
|
45
|
+
// Use index optimization for this collection
|
|
46
|
+
this.subscribeToChanges(whereExpression)
|
|
47
|
+
} else {
|
|
48
|
+
// This should not happen - if we have a whereClause but can't create whereExpression,
|
|
49
|
+
// it indicates a bug in our optimization logic
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Failed to convert WHERE clause to collection filter for collection '${this.collectionId}'. ` +
|
|
52
|
+
`This indicates a bug in the query optimization logic.`
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
// No WHERE clause for this collection, use regular subscription
|
|
57
|
+
this.subscribeToChanges()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private subscribeToChanges(whereExpression?: BasicExpression<boolean>) {
|
|
62
|
+
let unsubscribe: () => void
|
|
63
|
+
if (this.collectionConfigBuilder.lazyCollections.has(this.collectionId)) {
|
|
64
|
+
unsubscribe = this.subscribeToMatchingChanges(whereExpression)
|
|
65
|
+
} else if (
|
|
66
|
+
Object.hasOwn(
|
|
67
|
+
this.collectionConfigBuilder.optimizableOrderByCollections,
|
|
68
|
+
this.collectionId
|
|
69
|
+
)
|
|
70
|
+
) {
|
|
71
|
+
unsubscribe = this.subscribeToOrderedChanges(whereExpression)
|
|
72
|
+
} else {
|
|
73
|
+
unsubscribe = this.subscribeToAllChanges(whereExpression)
|
|
74
|
+
}
|
|
75
|
+
this.syncState.unsubscribeCallbacks.add(unsubscribe)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private sendChangesToPipeline(
|
|
79
|
+
changes: Iterable<ChangeMessage<any, string | number>>,
|
|
80
|
+
callback?: () => boolean
|
|
81
|
+
) {
|
|
82
|
+
const input = this.syncState.inputs[this.collectionId]!
|
|
83
|
+
const sentChanges = sendChangesToInput(
|
|
84
|
+
input,
|
|
85
|
+
changes,
|
|
86
|
+
this.collection.config.getKey
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
// Do not provide the callback that loads more data
|
|
90
|
+
// if there's no more data to load
|
|
91
|
+
// otherwise we end up in an infinite loop trying to load more data
|
|
92
|
+
const dataLoader = sentChanges > 0 ? callback : undefined
|
|
93
|
+
|
|
94
|
+
// We need to call `maybeRunGraph` even if there's no data to load
|
|
95
|
+
// because we need to mark the collection as ready if it's not already
|
|
96
|
+
// and that's only done in `maybeRunGraph`
|
|
97
|
+
this.collectionConfigBuilder.maybeRunGraph(
|
|
98
|
+
this.config,
|
|
99
|
+
this.syncState,
|
|
100
|
+
dataLoader
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Wraps the sendChangesToPipeline function
|
|
105
|
+
// in order to turn `update`s into `insert`s
|
|
106
|
+
// for keys that have not been sent to the pipeline yet
|
|
107
|
+
// and filter out deletes for keys that have not been sent
|
|
108
|
+
private sendVisibleChangesToPipeline = (
|
|
109
|
+
changes: Array<ChangeMessage<any, string | number>>,
|
|
110
|
+
loadedInitialState: boolean
|
|
111
|
+
) => {
|
|
112
|
+
if (loadedInitialState) {
|
|
113
|
+
// There was no index for the join key
|
|
114
|
+
// so we loaded the initial state
|
|
115
|
+
// so we can safely assume that the pipeline has seen all keys
|
|
116
|
+
return this.sendChangesToPipeline(changes)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const newChanges = []
|
|
120
|
+
for (const change of changes) {
|
|
121
|
+
let newChange = change
|
|
122
|
+
if (!this.sentKeys.has(change.key)) {
|
|
123
|
+
if (change.type === `update`) {
|
|
124
|
+
newChange = { ...change, type: `insert` }
|
|
125
|
+
} else if (change.type === `delete`) {
|
|
126
|
+
// filter out deletes for keys that have not been sent
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
this.sentKeys.add(change.key)
|
|
130
|
+
}
|
|
131
|
+
newChanges.push(newChange)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return this.sendChangesToPipeline(newChanges)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private loadKeys(
|
|
138
|
+
keys: Iterable<string | number>,
|
|
139
|
+
filterFn: (item: object) => boolean
|
|
140
|
+
) {
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
// Only load the key once
|
|
143
|
+
if (this.sentKeys.has(key)) continue
|
|
144
|
+
|
|
145
|
+
const value = this.collection.get(key)
|
|
146
|
+
if (value !== undefined && filterFn(value)) {
|
|
147
|
+
this.sentKeys.add(key)
|
|
148
|
+
this.sendChangesToPipeline([{ type: `insert`, key, value }])
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private subscribeToAllChanges(
|
|
154
|
+
whereExpression: BasicExpression<boolean> | undefined
|
|
155
|
+
) {
|
|
156
|
+
const sendChangesToPipeline = this.sendChangesToPipeline.bind(this)
|
|
157
|
+
const unsubscribe = this.collection.subscribeChanges(
|
|
158
|
+
sendChangesToPipeline,
|
|
159
|
+
{
|
|
160
|
+
includeInitialState: true,
|
|
161
|
+
...(whereExpression ? { whereExpression } : undefined),
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
return unsubscribe
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private subscribeToMatchingChanges(
|
|
168
|
+
whereExpression: BasicExpression<boolean> | undefined
|
|
169
|
+
) {
|
|
170
|
+
// Flag to indicate we have send to whole initial state of the collection
|
|
171
|
+
// to the pipeline, this is set when there are no indexes that can be used
|
|
172
|
+
// to filter the changes and so the whole state was requested from the collection
|
|
173
|
+
let loadedInitialState = false
|
|
174
|
+
|
|
175
|
+
// Flag to indicate that we have started sending changes to the pipeline.
|
|
176
|
+
// This is set to true by either the first call to `loadKeys` or when the
|
|
177
|
+
// query requests the whole initial state in `loadInitialState`.
|
|
178
|
+
// Until that point we filter out all changes from subscription to the collection.
|
|
179
|
+
let sendChanges = false
|
|
180
|
+
|
|
181
|
+
const sendVisibleChanges = (
|
|
182
|
+
changes: Array<ChangeMessage<any, string | number>>
|
|
183
|
+
) => {
|
|
184
|
+
// We filter out changes when sendChanges is false to ensure that we don't send
|
|
185
|
+
// any changes from the live subscription until the join operator requests either
|
|
186
|
+
// the initial state or its first key. This is needed otherwise it could receive
|
|
187
|
+
// changes which are then later subsumed by the initial state (and that would
|
|
188
|
+
// lead to weird bugs due to the data being received twice).
|
|
189
|
+
this.sendVisibleChangesToPipeline(
|
|
190
|
+
sendChanges ? changes : [],
|
|
191
|
+
loadedInitialState
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const unsubscribe = this.collection.subscribeChanges(sendVisibleChanges, {
|
|
196
|
+
whereExpression,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Create a function that loads keys from the collection
|
|
200
|
+
// into the query pipeline on demand
|
|
201
|
+
const filterFn = whereExpression
|
|
202
|
+
? createFilterFunctionFromExpression(whereExpression)
|
|
203
|
+
: () => true
|
|
204
|
+
const loadKs = (keys: Set<string | number>) => {
|
|
205
|
+
sendChanges = true
|
|
206
|
+
return this.loadKeys(keys, filterFn)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Store the functions to load keys and load initial state in the `lazyCollectionsCallbacks` map
|
|
210
|
+
// This is used by the join operator to dynamically load matching keys from the lazy collection
|
|
211
|
+
// or to get the full initial state of the collection if there's no index for the join key
|
|
212
|
+
this.collectionConfigBuilder.lazyCollectionsCallbacks[this.collectionId] = {
|
|
213
|
+
loadKeys: loadKs,
|
|
214
|
+
loadInitialState: () => {
|
|
215
|
+
// Make sure we only load the initial state once
|
|
216
|
+
if (loadedInitialState) return
|
|
217
|
+
loadedInitialState = true
|
|
218
|
+
sendChanges = true
|
|
219
|
+
|
|
220
|
+
const changes = this.collection.currentStateAsChanges({
|
|
221
|
+
whereExpression,
|
|
222
|
+
})
|
|
223
|
+
this.sendChangesToPipeline(changes)
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
return unsubscribe
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private subscribeToOrderedChanges(
|
|
230
|
+
whereExpression: BasicExpression<boolean> | undefined
|
|
231
|
+
) {
|
|
232
|
+
const { offset, limit, comparator, dataNeeded } =
|
|
233
|
+
this.collectionConfigBuilder.optimizableOrderByCollections[
|
|
234
|
+
this.collectionId
|
|
235
|
+
]!
|
|
236
|
+
|
|
237
|
+
// Load the first `offset + limit` values from the index
|
|
238
|
+
// i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[
|
|
239
|
+
this.loadNextItems(offset + limit)
|
|
240
|
+
|
|
241
|
+
const sendChangesInRange = (
|
|
242
|
+
changes: Iterable<ChangeMessage<any, string | number>>
|
|
243
|
+
) => {
|
|
244
|
+
// Split live updates into a delete of the old value and an insert of the new value
|
|
245
|
+
// and filter out changes that are bigger than the biggest value we've sent so far
|
|
246
|
+
// because they can't affect the topK
|
|
247
|
+
const splittedChanges = splitUpdates(changes)
|
|
248
|
+
let filteredChanges = splittedChanges
|
|
249
|
+
if (dataNeeded!() === 0) {
|
|
250
|
+
// If the topK is full [..., maxSentValue] then we do not need to send changes > maxSentValue
|
|
251
|
+
// because they can never make it into the topK.
|
|
252
|
+
// However, if the topK isn't full yet, we need to also send changes > maxSentValue
|
|
253
|
+
// because they will make it into the topK
|
|
254
|
+
filteredChanges = filterChangesSmallerOrEqualToMax(
|
|
255
|
+
splittedChanges,
|
|
256
|
+
comparator,
|
|
257
|
+
this.biggest
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
this.sendChangesToPipeline(
|
|
261
|
+
filteredChanges,
|
|
262
|
+
this.loadMoreIfNeeded.bind(this)
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Subscribe to changes and only send changes that are smaller than the biggest value we've sent so far
|
|
267
|
+
// values that are bigger don't need to be sent because they can't affect the topK
|
|
268
|
+
const unsubscribe = this.collection.subscribeChanges(sendChangesInRange, {
|
|
269
|
+
whereExpression,
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
return unsubscribe
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// This function is called by maybeRunGraph
|
|
276
|
+
// after each iteration of the query pipeline
|
|
277
|
+
// to ensure that the orderBy operator has enough data to work with
|
|
278
|
+
loadMoreIfNeeded() {
|
|
279
|
+
const orderByInfo =
|
|
280
|
+
this.collectionConfigBuilder.optimizableOrderByCollections[
|
|
281
|
+
this.collectionId
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
if (!orderByInfo) {
|
|
285
|
+
// This query has no orderBy operator
|
|
286
|
+
// so there's no data to load, just return true
|
|
287
|
+
return true
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const { dataNeeded } = orderByInfo
|
|
291
|
+
|
|
292
|
+
if (!dataNeeded) {
|
|
293
|
+
// This should never happen because the topK operator should always set the size callback
|
|
294
|
+
// which in turn should lead to the orderBy operator setting the dataNeeded callback
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Missing dataNeeded callback for collection ${this.collectionId}`
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// `dataNeeded` probes the orderBy operator to see if it needs more data
|
|
301
|
+
// if it needs more data, it returns the number of items it needs
|
|
302
|
+
const n = dataNeeded()
|
|
303
|
+
let noMoreNextItems = false
|
|
304
|
+
if (n > 0) {
|
|
305
|
+
const loadedItems = this.loadNextItems(n)
|
|
306
|
+
noMoreNextItems = loadedItems === 0
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Indicate that we're done loading data if we didn't need to load more data
|
|
310
|
+
// or there's no more data to load
|
|
311
|
+
return n === 0 || noMoreNextItems
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private sendChangesToPipelineWithTracking(
|
|
315
|
+
changes: Iterable<ChangeMessage<any, string | number>>
|
|
316
|
+
) {
|
|
317
|
+
const { comparator } =
|
|
318
|
+
this.collectionConfigBuilder.optimizableOrderByCollections[
|
|
319
|
+
this.collectionId
|
|
320
|
+
]!
|
|
321
|
+
const trackedChanges = this.trackSentValues(changes, comparator)
|
|
322
|
+
this.sendChangesToPipeline(trackedChanges, this.loadMoreIfNeeded.bind(this))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Loads the next `n` items from the collection
|
|
326
|
+
// starting from the biggest item it has sent
|
|
327
|
+
private loadNextItems(n: number) {
|
|
328
|
+
const { valueExtractorForRawRow, index } =
|
|
329
|
+
this.collectionConfigBuilder.optimizableOrderByCollections[
|
|
330
|
+
this.collectionId
|
|
331
|
+
]!
|
|
332
|
+
const biggestSentRow = this.biggest
|
|
333
|
+
const biggestSentValue = biggestSentRow
|
|
334
|
+
? valueExtractorForRawRow(biggestSentRow)
|
|
335
|
+
: biggestSentRow
|
|
336
|
+
// Take the `n` items after the biggest sent value
|
|
337
|
+
const nextOrderedKeys = index.take(n, biggestSentValue)
|
|
338
|
+
const nextInserts: Array<ChangeMessage<any, string | number>> =
|
|
339
|
+
nextOrderedKeys.map((key) => {
|
|
340
|
+
return { type: `insert`, key, value: this.collection.get(key) }
|
|
341
|
+
})
|
|
342
|
+
this.sendChangesToPipelineWithTracking(nextInserts)
|
|
343
|
+
return nextInserts.length
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private getWhereClauseFromAlias(
|
|
347
|
+
collectionAlias: string | undefined
|
|
348
|
+
): BasicExpression<boolean> | undefined {
|
|
349
|
+
const collectionWhereClausesCache =
|
|
350
|
+
this.collectionConfigBuilder.collectionWhereClausesCache
|
|
351
|
+
if (collectionAlias && collectionWhereClausesCache) {
|
|
352
|
+
return collectionWhereClausesCache.get(collectionAlias)
|
|
353
|
+
}
|
|
354
|
+
return undefined
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private *trackSentValues(
|
|
358
|
+
changes: Iterable<ChangeMessage<any, string | number>>,
|
|
359
|
+
comparator: (a: any, b: any) => number
|
|
360
|
+
) {
|
|
361
|
+
for (const change of changes) {
|
|
362
|
+
this.sentKeys.add(change.key)
|
|
363
|
+
|
|
364
|
+
if (!this.biggest) {
|
|
365
|
+
this.biggest = change.value
|
|
366
|
+
} else if (comparator(this.biggest, change.value) < 0) {
|
|
367
|
+
this.biggest = change.value
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
yield change
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Finds the alias for a collection ID in the query
|
|
377
|
+
*/
|
|
378
|
+
function findCollectionAlias(
|
|
379
|
+
collectionId: string,
|
|
380
|
+
query: any
|
|
381
|
+
): string | undefined {
|
|
382
|
+
// Check FROM clause
|
|
383
|
+
if (
|
|
384
|
+
query.from?.type === `collectionRef` &&
|
|
385
|
+
query.from.collection?.id === collectionId
|
|
386
|
+
) {
|
|
387
|
+
return query.from.alias
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Check JOIN clauses
|
|
391
|
+
if (query.join) {
|
|
392
|
+
for (const joinClause of query.join) {
|
|
393
|
+
if (
|
|
394
|
+
joinClause.from?.type === `collectionRef` &&
|
|
395
|
+
joinClause.from.collection?.id === collectionId
|
|
396
|
+
) {
|
|
397
|
+
return joinClause.from.alias
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return undefined
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Helper function to send changes to a D2 input stream
|
|
407
|
+
*/
|
|
408
|
+
function sendChangesToInput(
|
|
409
|
+
input: RootStreamBuilder<unknown>,
|
|
410
|
+
changes: Iterable<ChangeMessage>,
|
|
411
|
+
getKey: (item: ChangeMessage[`value`]) => any
|
|
412
|
+
): number {
|
|
413
|
+
const multiSetArray: MultiSetArray<unknown> = []
|
|
414
|
+
for (const change of changes) {
|
|
415
|
+
const key = getKey(change.value)
|
|
416
|
+
if (change.type === `insert`) {
|
|
417
|
+
multiSetArray.push([[key, change.value], 1])
|
|
418
|
+
} else if (change.type === `update`) {
|
|
419
|
+
multiSetArray.push([[key, change.previousValue], -1])
|
|
420
|
+
multiSetArray.push([[key, change.value], 1])
|
|
421
|
+
} else {
|
|
422
|
+
// change.type === `delete`
|
|
423
|
+
multiSetArray.push([[key, change.value], -1])
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
input.sendData(new MultiSet(multiSetArray))
|
|
427
|
+
return multiSetArray.length
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Splits updates into a delete of the old value and an insert of the new value */
|
|
431
|
+
function* splitUpdates<
|
|
432
|
+
T extends object = Record<string, unknown>,
|
|
433
|
+
TKey extends string | number = string | number,
|
|
434
|
+
>(
|
|
435
|
+
changes: Iterable<ChangeMessage<T, TKey>>
|
|
436
|
+
): Generator<ChangeMessage<T, TKey>> {
|
|
437
|
+
for (const change of changes) {
|
|
438
|
+
if (change.type === `update`) {
|
|
439
|
+
yield { type: `delete`, key: change.key, value: change.previousValue! }
|
|
440
|
+
yield { type: `insert`, key: change.key, value: change.value }
|
|
441
|
+
} else {
|
|
442
|
+
yield change
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function* filterChanges<
|
|
448
|
+
T extends object = Record<string, unknown>,
|
|
449
|
+
TKey extends string | number = string | number,
|
|
450
|
+
>(
|
|
451
|
+
changes: Iterable<ChangeMessage<T, TKey>>,
|
|
452
|
+
f: (change: ChangeMessage<T, TKey>) => boolean
|
|
453
|
+
): Generator<ChangeMessage<T, TKey>> {
|
|
454
|
+
for (const change of changes) {
|
|
455
|
+
if (f(change)) {
|
|
456
|
+
yield change
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Filters changes to only include those that are smaller than the provided max value
|
|
463
|
+
* @param changes - Iterable of changes to filter
|
|
464
|
+
* @param comparator - Comparator function to use for filtering
|
|
465
|
+
* @param maxValue - Range to filter changes within (range boundaries are exclusive)
|
|
466
|
+
* @returns Iterable of changes that fall within the range
|
|
467
|
+
*/
|
|
468
|
+
function* filterChangesSmallerOrEqualToMax<
|
|
469
|
+
T extends object = Record<string, unknown>,
|
|
470
|
+
TKey extends string | number = string | number,
|
|
471
|
+
>(
|
|
472
|
+
changes: Iterable<ChangeMessage<T, TKey>>,
|
|
473
|
+
comparator: (a: any, b: any) => number,
|
|
474
|
+
maxValue: any
|
|
475
|
+
): Generator<ChangeMessage<T, TKey>> {
|
|
476
|
+
yield* filterChanges(changes, (change) => {
|
|
477
|
+
return !maxValue || comparator(change.value, maxValue) <= 0
|
|
478
|
+
})
|
|
479
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { D2, RootStreamBuilder } from "@tanstack/db-ivm"
|
|
2
|
+
import type { CollectionConfig, ResultStream } from "../../types.js"
|
|
3
|
+
import type { InitialQueryBuilder, QueryBuilder } from "../builder/index.js"
|
|
4
|
+
import type { Context, GetResult } from "../builder/types.js"
|
|
5
|
+
|
|
6
|
+
export type Changes<T> = {
|
|
7
|
+
deletes: number
|
|
8
|
+
inserts: number
|
|
9
|
+
value: T
|
|
10
|
+
orderByIndex: string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type SyncState = {
|
|
14
|
+
messagesCount: number
|
|
15
|
+
subscribedToAllCollections: boolean
|
|
16
|
+
unsubscribeCallbacks: Set<() => void>
|
|
17
|
+
|
|
18
|
+
graph?: D2
|
|
19
|
+
inputs?: Record<string, RootStreamBuilder<unknown>>
|
|
20
|
+
pipeline?: ResultStream
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type FullSyncState = Required<SyncState>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configuration interface for live query collection options
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const config: LiveQueryCollectionConfig<any, any> = {
|
|
31
|
+
* // id is optional - will auto-generate "live-query-1", "live-query-2", etc.
|
|
32
|
+
* query: (q) => q
|
|
33
|
+
* .from({ comment: commentsCollection })
|
|
34
|
+
* .join(
|
|
35
|
+
* { user: usersCollection },
|
|
36
|
+
* ({ comment, user }) => eq(comment.user_id, user.id)
|
|
37
|
+
* )
|
|
38
|
+
* .where(({ comment }) => eq(comment.active, true))
|
|
39
|
+
* .select(({ comment, user }) => ({
|
|
40
|
+
* id: comment.id,
|
|
41
|
+
* content: comment.content,
|
|
42
|
+
* authorName: user.name,
|
|
43
|
+
* })),
|
|
44
|
+
* // getKey is optional - defaults to using stream key
|
|
45
|
+
* getKey: (item) => item.id,
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export interface LiveQueryCollectionConfig<
|
|
50
|
+
TContext extends Context,
|
|
51
|
+
TResult extends object = GetResult<TContext> & object,
|
|
52
|
+
> {
|
|
53
|
+
/**
|
|
54
|
+
* Unique identifier for the collection
|
|
55
|
+
* If not provided, defaults to `live-query-${number}` with auto-incrementing number
|
|
56
|
+
*/
|
|
57
|
+
id?: string
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Query builder function that defines the live query
|
|
61
|
+
*/
|
|
62
|
+
query:
|
|
63
|
+
| ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
|
|
64
|
+
| QueryBuilder<TContext>
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Function to extract the key from result items
|
|
68
|
+
* If not provided, defaults to using the key from the D2 stream
|
|
69
|
+
*/
|
|
70
|
+
getKey?: (item: TResult) => string | number
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Optional schema for validation
|
|
74
|
+
*/
|
|
75
|
+
schema?: CollectionConfig<TResult>[`schema`]
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Optional mutation handlers
|
|
79
|
+
*/
|
|
80
|
+
onInsert?: CollectionConfig<TResult>[`onInsert`]
|
|
81
|
+
onUpdate?: CollectionConfig<TResult>[`onUpdate`]
|
|
82
|
+
onDelete?: CollectionConfig<TResult>[`onDelete`]
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Start sync / the query immediately
|
|
86
|
+
*/
|
|
87
|
+
startSync?: boolean
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* GC time for the collection
|
|
91
|
+
*/
|
|
92
|
+
gcTime?: number
|
|
93
|
+
}
|