@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,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
+ }