@tanstack/db 0.1.5 → 0.1.7

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