@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.
Files changed (60) hide show
  1. package/dist/cjs/collection.cjs +3 -1
  2. package/dist/cjs/collection.cjs.map +1 -1
  3. package/dist/cjs/collection.d.cts +5 -5
  4. package/dist/cjs/query/compiler/group-by.cjs +4 -2
  5. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  6. package/dist/cjs/query/compiler/index.cjs +2 -1
  7. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  8. package/dist/cjs/query/index.d.cts +2 -1
  9. package/dist/cjs/query/ir.cjs +16 -0
  10. package/dist/cjs/query/ir.cjs.map +1 -1
  11. package/dist/cjs/query/ir.d.cts +24 -1
  12. package/dist/cjs/query/live/collection-config-builder.cjs +274 -0
  13. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -0
  14. package/dist/cjs/query/live/collection-config-builder.d.cts +34 -0
  15. package/dist/cjs/query/live/collection-subscriber.cjs +272 -0
  16. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -0
  17. package/dist/cjs/query/live/collection-subscriber.d.cts +28 -0
  18. package/dist/cjs/query/live/types.d.cts +77 -0
  19. package/dist/cjs/query/live-query-collection.cjs +3 -417
  20. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  21. package/dist/cjs/query/live-query-collection.d.cts +1 -58
  22. package/dist/cjs/query/optimizer.cjs +34 -11
  23. package/dist/cjs/query/optimizer.cjs.map +1 -1
  24. package/dist/cjs/types.d.cts +12 -0
  25. package/dist/esm/collection.d.ts +5 -5
  26. package/dist/esm/collection.js +3 -1
  27. package/dist/esm/collection.js.map +1 -1
  28. package/dist/esm/query/compiler/group-by.js +5 -3
  29. package/dist/esm/query/compiler/group-by.js.map +1 -1
  30. package/dist/esm/query/compiler/index.js +3 -2
  31. package/dist/esm/query/compiler/index.js.map +1 -1
  32. package/dist/esm/query/index.d.ts +2 -1
  33. package/dist/esm/query/ir.d.ts +24 -1
  34. package/dist/esm/query/ir.js +17 -1
  35. package/dist/esm/query/ir.js.map +1 -1
  36. package/dist/esm/query/live/collection-config-builder.d.ts +34 -0
  37. package/dist/esm/query/live/collection-config-builder.js +274 -0
  38. package/dist/esm/query/live/collection-config-builder.js.map +1 -0
  39. package/dist/esm/query/live/collection-subscriber.d.ts +28 -0
  40. package/dist/esm/query/live/collection-subscriber.js +272 -0
  41. package/dist/esm/query/live/collection-subscriber.js.map +1 -0
  42. package/dist/esm/query/live/types.d.ts +77 -0
  43. package/dist/esm/query/live-query-collection.d.ts +1 -58
  44. package/dist/esm/query/live-query-collection.js +3 -417
  45. package/dist/esm/query/live-query-collection.js.map +1 -1
  46. package/dist/esm/query/optimizer.js +35 -12
  47. package/dist/esm/query/optimizer.js.map +1 -1
  48. package/dist/esm/types.d.ts +12 -0
  49. package/package.json +1 -1
  50. package/src/collection.ts +14 -6
  51. package/src/query/compiler/group-by.ts +5 -3
  52. package/src/query/compiler/index.ts +3 -2
  53. package/src/query/index.ts +2 -1
  54. package/src/query/ir.ts +48 -1
  55. package/src/query/live/collection-config-builder.ts +446 -0
  56. package/src/query/live/collection-subscriber.ts +479 -0
  57. package/src/query/live/types.ts +93 -0
  58. package/src/query/live-query-collection.ts +8 -791
  59. package/src/query/optimizer.ts +66 -18
  60. package/src/types.ts +74 -0
@@ -0,0 +1,446 @@
1
+ import { D2, output } from "@tanstack/db-ivm"
2
+ import { compileQuery } from "../compiler/index.js"
3
+ import { buildQuery, getQueryIR } from "../builder/index.js"
4
+ import { CollectionSubscriber } from "./collection-subscriber.js"
5
+ import type { RootStreamBuilder } from "@tanstack/db-ivm"
6
+ import type { OrderByOptimizationInfo } from "../compiler/order-by.js"
7
+ import type { Collection } from "../../collection.js"
8
+ import type {
9
+ CollectionConfig,
10
+ KeyedStream,
11
+ ResultStream,
12
+ SyncConfig,
13
+ } from "../../types.js"
14
+ import type { Context, GetResult } from "../builder/types.js"
15
+ import type { BasicExpression, QueryIR } from "../ir.js"
16
+ import type { LazyCollectionCallbacks } from "../compiler/joins.js"
17
+ import type {
18
+ Changes,
19
+ FullSyncState,
20
+ LiveQueryCollectionConfig,
21
+ SyncState,
22
+ } from "./types.js"
23
+
24
+ // Global counter for auto-generated collection IDs
25
+ let liveQueryCollectionCounter = 0
26
+
27
+ export class CollectionConfigBuilder<
28
+ TContext extends Context,
29
+ TResult extends object = GetResult<TContext>,
30
+ > {
31
+ private readonly id: string
32
+ readonly query: QueryIR
33
+ private readonly collections: Record<string, Collection<any, any, any>>
34
+
35
+ // WeakMap to store the keys of the results
36
+ // so that we can retrieve them in the getKey function
37
+ private readonly resultKeys = new WeakMap<object, unknown>()
38
+
39
+ // WeakMap to store the orderBy index for each result
40
+ private readonly orderByIndices = new WeakMap<object, string>()
41
+
42
+ private readonly compare?: (val1: TResult, val2: TResult) => number
43
+
44
+ private graphCache: D2 | undefined
45
+ private inputsCache: Record<string, RootStreamBuilder<unknown>> | undefined
46
+ private pipelineCache: ResultStream | undefined
47
+ public collectionWhereClausesCache:
48
+ | Map<string, BasicExpression<boolean>>
49
+ | undefined
50
+
51
+ // Map of collection IDs to functions that load keys for that lazy collection
52
+ readonly lazyCollectionsCallbacks: Record<string, LazyCollectionCallbacks> =
53
+ {}
54
+ // Set of collection IDs that are lazy collections
55
+ readonly lazyCollections = new Set<string>()
56
+ // Set of collection IDs that include an optimizable ORDER BY clause
57
+ readonly optimizableOrderByCollections: Record<
58
+ string,
59
+ OrderByOptimizationInfo
60
+ > = {}
61
+
62
+ constructor(
63
+ private readonly config: LiveQueryCollectionConfig<TContext, TResult>
64
+ ) {
65
+ // Generate a unique ID if not provided
66
+ this.id = config.id || `live-query-${++liveQueryCollectionCounter}`
67
+
68
+ this.query = buildQueryFromConfig(config)
69
+ this.collections = extractCollectionsFromQuery(this.query)
70
+
71
+ // Create compare function for ordering if the query has orderBy
72
+ if (this.query.orderBy && this.query.orderBy.length > 0) {
73
+ this.compare = createOrderByComparator<TResult>(this.orderByIndices)
74
+ }
75
+
76
+ // Compile the base pipeline once initially
77
+ // This is done to ensure that any errors are thrown immediately and synchronously
78
+ this.compileBasePipeline()
79
+ }
80
+
81
+ getConfig(): CollectionConfig<TResult> {
82
+ return {
83
+ id: this.id,
84
+ getKey:
85
+ this.config.getKey ||
86
+ ((item) => this.resultKeys.get(item) as string | number),
87
+ sync: this.getSyncConfig(),
88
+ compare: this.compare,
89
+ gcTime: this.config.gcTime || 5000, // 5 seconds by default for live queries
90
+ schema: this.config.schema,
91
+ onInsert: this.config.onInsert,
92
+ onUpdate: this.config.onUpdate,
93
+ onDelete: this.config.onDelete,
94
+ startSync: this.config.startSync,
95
+ }
96
+ }
97
+
98
+ // The callback function is called after the graph has run.
99
+ // This gives the callback a chance to load more data if needed,
100
+ // that's used to optimize orderBy operators that set a limit,
101
+ // in order to load some more data if we still don't have enough rows after the pipeline has run.
102
+ // That can happend because even though we load N rows, the pipeline might filter some of these rows out
103
+ // causing the orderBy operator to receive less than N rows or even no rows at all.
104
+ // So this callback would notice that it doesn't have enough rows and load some more.
105
+ // The callback returns a boolean, when it's true it's done loading data and we can mark the collection as ready.
106
+ maybeRunGraph(
107
+ config: Parameters<SyncConfig<TResult>[`sync`]>[0],
108
+ syncState: FullSyncState,
109
+ callback?: () => boolean
110
+ ) {
111
+ const { begin, commit, markReady } = config
112
+
113
+ // We only run the graph if all the collections are ready
114
+ if (
115
+ this.allCollectionsReadyOrInitialCommit() &&
116
+ syncState.subscribedToAllCollections
117
+ ) {
118
+ syncState.graph.run()
119
+ const ready = callback?.() ?? true
120
+ // On the initial run, we may need to do an empty commit to ensure that
121
+ // the collection is initialized
122
+ if (syncState.messagesCount === 0) {
123
+ begin()
124
+ commit()
125
+ }
126
+ // Mark the collection as ready after the first successful run
127
+ if (ready && this.allCollectionsReady()) {
128
+ markReady()
129
+ }
130
+ }
131
+ }
132
+
133
+ private getSyncConfig(): SyncConfig<TResult> {
134
+ return {
135
+ rowUpdateMode: `full`,
136
+ sync: this.syncFn.bind(this),
137
+ }
138
+ }
139
+
140
+ private syncFn(config: Parameters<SyncConfig<TResult>[`sync`]>[0]) {
141
+ const syncState: SyncState = {
142
+ messagesCount: 0,
143
+ subscribedToAllCollections: false,
144
+ unsubscribeCallbacks: new Set<() => void>(),
145
+ }
146
+
147
+ // Extend the pipeline such that it applies the incoming changes to the collection
148
+ const fullSyncState = this.extendPipelineWithChangeProcessing(
149
+ config,
150
+ syncState
151
+ )
152
+
153
+ const loadMoreDataCallbacks = this.subscribeToAllCollections(
154
+ config,
155
+ fullSyncState
156
+ )
157
+
158
+ // Initial run with callback to load more data if needed
159
+ this.maybeRunGraph(config, fullSyncState, loadMoreDataCallbacks)
160
+
161
+ // Return the unsubscribe function
162
+ return () => {
163
+ syncState.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe())
164
+
165
+ // Reset caches so a fresh graph/pipeline is compiled on next start
166
+ // This avoids reusing a finalized D2 graph across GC restarts
167
+ this.graphCache = undefined
168
+ this.inputsCache = undefined
169
+ this.pipelineCache = undefined
170
+ this.collectionWhereClausesCache = undefined
171
+ }
172
+ }
173
+
174
+ private compileBasePipeline() {
175
+ this.graphCache = new D2()
176
+ this.inputsCache = Object.fromEntries(
177
+ Object.entries(this.collections).map(([key]) => [
178
+ key,
179
+ this.graphCache!.newInput<any>(),
180
+ ])
181
+ )
182
+
183
+ // Compile the query and get both pipeline and collection WHERE clauses
184
+ const {
185
+ pipeline: pipelineCache,
186
+ collectionWhereClauses: collectionWhereClausesCache,
187
+ } = compileQuery(
188
+ this.query,
189
+ this.inputsCache as Record<string, KeyedStream>,
190
+ this.collections,
191
+ this.lazyCollectionsCallbacks,
192
+ this.lazyCollections,
193
+ this.optimizableOrderByCollections
194
+ )
195
+
196
+ this.pipelineCache = pipelineCache
197
+ this.collectionWhereClausesCache = collectionWhereClausesCache
198
+ }
199
+
200
+ private maybeCompileBasePipeline() {
201
+ if (!this.graphCache || !this.inputsCache || !this.pipelineCache) {
202
+ this.compileBasePipeline()
203
+ }
204
+ return {
205
+ graph: this.graphCache!,
206
+ inputs: this.inputsCache!,
207
+ pipeline: this.pipelineCache!,
208
+ }
209
+ }
210
+
211
+ private extendPipelineWithChangeProcessing(
212
+ config: Parameters<SyncConfig<TResult>[`sync`]>[0],
213
+ syncState: SyncState
214
+ ): FullSyncState {
215
+ const { begin, commit } = config
216
+ const { graph, inputs, pipeline } = this.maybeCompileBasePipeline()
217
+
218
+ pipeline.pipe(
219
+ output((data) => {
220
+ const messages = data.getInner()
221
+ syncState.messagesCount += messages.length
222
+
223
+ begin()
224
+ messages
225
+ .reduce(
226
+ accumulateChanges<TResult>,
227
+ new Map<unknown, Changes<TResult>>()
228
+ )
229
+ .forEach(this.applyChanges.bind(this, config))
230
+ commit()
231
+ })
232
+ )
233
+
234
+ graph.finalize()
235
+
236
+ // Extend the sync state with the graph, inputs, and pipeline
237
+ syncState.graph = graph
238
+ syncState.inputs = inputs
239
+ syncState.pipeline = pipeline
240
+
241
+ return syncState as FullSyncState
242
+ }
243
+
244
+ private applyChanges(
245
+ config: Parameters<SyncConfig<TResult>[`sync`]>[0],
246
+ changes: {
247
+ deletes: number
248
+ inserts: number
249
+ value: TResult
250
+ orderByIndex: string | undefined
251
+ },
252
+ key: unknown
253
+ ) {
254
+ const { write, collection } = config
255
+ const { deletes, inserts, value, orderByIndex } = changes
256
+
257
+ // Store the key of the result so that we can retrieve it in the
258
+ // getKey function
259
+ this.resultKeys.set(value, key)
260
+
261
+ // Store the orderBy index if it exists
262
+ if (orderByIndex !== undefined) {
263
+ this.orderByIndices.set(value, orderByIndex)
264
+ }
265
+
266
+ // Simple singular insert.
267
+ if (inserts && deletes === 0) {
268
+ write({
269
+ value,
270
+ type: `insert`,
271
+ })
272
+ } else if (
273
+ // Insert & update(s) (updates are a delete & insert)
274
+ inserts > deletes ||
275
+ // Just update(s) but the item is already in the collection (so
276
+ // was inserted previously).
277
+ (inserts === deletes && collection.has(key as string | number))
278
+ ) {
279
+ write({
280
+ value,
281
+ type: `update`,
282
+ })
283
+ // Only delete is left as an option
284
+ } else if (deletes > 0) {
285
+ write({
286
+ value,
287
+ type: `delete`,
288
+ })
289
+ } else {
290
+ throw new Error(
291
+ `Could not apply changes: ${JSON.stringify(changes)}. This should never happen.`
292
+ )
293
+ }
294
+ }
295
+
296
+ private allCollectionsReady() {
297
+ return Object.values(this.collections).every((collection) =>
298
+ collection.isReady()
299
+ )
300
+ }
301
+
302
+ private allCollectionsReadyOrInitialCommit() {
303
+ return Object.values(this.collections).every(
304
+ (collection) =>
305
+ collection.status === `ready` || collection.status === `initialCommit`
306
+ )
307
+ }
308
+
309
+ private subscribeToAllCollections(
310
+ config: Parameters<SyncConfig<TResult>[`sync`]>[0],
311
+ syncState: FullSyncState
312
+ ) {
313
+ const loaders = Object.entries(this.collections).map(
314
+ ([collectionId, collection]) => {
315
+ const collectionSubscriber = new CollectionSubscriber(
316
+ collectionId,
317
+ collection,
318
+ config,
319
+ syncState,
320
+ this
321
+ )
322
+ collectionSubscriber.subscribe()
323
+
324
+ const loadMore =
325
+ collectionSubscriber.loadMoreIfNeeded.bind(collectionSubscriber)
326
+
327
+ return loadMore
328
+ }
329
+ )
330
+
331
+ const loadMoreDataCallback = () => {
332
+ loaders.map((loader) => loader()) // .every((doneLoading) => doneLoading)
333
+ return true
334
+ }
335
+
336
+ // Mark the collections as subscribed in the sync state
337
+ syncState.subscribedToAllCollections = true
338
+
339
+ return loadMoreDataCallback
340
+ }
341
+ }
342
+
343
+ function buildQueryFromConfig<TContext extends Context>(
344
+ config: LiveQueryCollectionConfig<any, any>
345
+ ) {
346
+ // Build the query using the provided query builder function or instance
347
+ if (typeof config.query === `function`) {
348
+ return buildQuery<TContext>(config.query)
349
+ }
350
+ return getQueryIR(config.query)
351
+ }
352
+
353
+ function createOrderByComparator<T extends object>(
354
+ orderByIndices: WeakMap<object, string>
355
+ ) {
356
+ return (val1: T, val2: T): number => {
357
+ // Use the orderBy index stored in the WeakMap
358
+ const index1 = orderByIndices.get(val1)
359
+ const index2 = orderByIndices.get(val2)
360
+
361
+ // Compare fractional indices lexicographically
362
+ if (index1 && index2) {
363
+ if (index1 < index2) {
364
+ return -1
365
+ } else if (index1 > index2) {
366
+ return 1
367
+ } else {
368
+ return 0
369
+ }
370
+ }
371
+
372
+ // Fallback to no ordering if indices are missing
373
+ return 0
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Helper function to extract collections from a compiled query
379
+ * Traverses the query IR to find all collection references
380
+ * Maps collections by their ID (not alias) as expected by the compiler
381
+ */
382
+ function extractCollectionsFromQuery(
383
+ query: any
384
+ ): Record<string, Collection<any, any, any>> {
385
+ const collections: Record<string, any> = {}
386
+
387
+ // Helper function to recursively extract collections from a query or source
388
+ function extractFromSource(source: any) {
389
+ if (source.type === `collectionRef`) {
390
+ collections[source.collection.id] = source.collection
391
+ } else if (source.type === `queryRef`) {
392
+ // Recursively extract from subquery
393
+ extractFromQuery(source.query)
394
+ }
395
+ }
396
+
397
+ // Helper function to recursively extract collections from a query
398
+ function extractFromQuery(q: any) {
399
+ // Extract from FROM clause
400
+ if (q.from) {
401
+ extractFromSource(q.from)
402
+ }
403
+
404
+ // Extract from JOIN clauses
405
+ if (q.join && Array.isArray(q.join)) {
406
+ for (const joinClause of q.join) {
407
+ if (joinClause.from) {
408
+ extractFromSource(joinClause.from)
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ // Start extraction from the root query
415
+ extractFromQuery(query)
416
+
417
+ return collections
418
+ }
419
+
420
+ function accumulateChanges<T>(
421
+ acc: Map<unknown, Changes<T>>,
422
+ [[key, tupleData], multiplicity]: [
423
+ [unknown, [any, string | undefined]],
424
+ number,
425
+ ]
426
+ ) {
427
+ // All queries now consistently return [value, orderByIndex] format
428
+ // where orderByIndex is undefined for queries without ORDER BY
429
+ const [value, orderByIndex] = tupleData as [T, string | undefined]
430
+
431
+ const changes = acc.get(key) || {
432
+ deletes: 0,
433
+ inserts: 0,
434
+ value,
435
+ orderByIndex,
436
+ }
437
+ if (multiplicity < 0) {
438
+ changes.deletes += Math.abs(multiplicity)
439
+ } else if (multiplicity > 0) {
440
+ changes.inserts += multiplicity
441
+ changes.value = value
442
+ changes.orderByIndex = orderByIndex
443
+ }
444
+ acc.set(key, changes)
445
+ return acc
446
+ }