@tanstack/db 0.4.9 → 0.4.11

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 (100) hide show
  1. package/dist/cjs/collection/events.cjs +9 -51
  2. package/dist/cjs/collection/events.cjs.map +1 -1
  3. package/dist/cjs/collection/events.d.cts +18 -7
  4. package/dist/cjs/collection/index.cjs +9 -12
  5. package/dist/cjs/collection/index.cjs.map +1 -1
  6. package/dist/cjs/collection/index.d.cts +13 -14
  7. package/dist/cjs/collection/subscription.cjs +62 -6
  8. package/dist/cjs/collection/subscription.cjs.map +1 -1
  9. package/dist/cjs/collection/subscription.d.cts +16 -3
  10. package/dist/cjs/collection/sync.cjs +58 -6
  11. package/dist/cjs/collection/sync.cjs.map +1 -1
  12. package/dist/cjs/collection/sync.d.cts +18 -4
  13. package/dist/cjs/errors.cjs +8 -0
  14. package/dist/cjs/errors.cjs.map +1 -1
  15. package/dist/cjs/errors.d.cts +6 -0
  16. package/dist/cjs/event-emitter.cjs +94 -0
  17. package/dist/cjs/event-emitter.cjs.map +1 -0
  18. package/dist/cjs/event-emitter.d.cts +45 -0
  19. package/dist/cjs/index.cjs +1 -0
  20. package/dist/cjs/index.cjs.map +1 -1
  21. package/dist/cjs/local-only.cjs.map +1 -1
  22. package/dist/cjs/local-only.d.cts +2 -5
  23. package/dist/cjs/query/compiler/index.cjs +6 -2
  24. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  25. package/dist/cjs/query/compiler/index.d.cts +3 -2
  26. package/dist/cjs/query/compiler/joins.cjs +6 -3
  27. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  28. package/dist/cjs/query/compiler/joins.d.cts +2 -2
  29. package/dist/cjs/query/compiler/order-by.cjs +18 -4
  30. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  31. package/dist/cjs/query/compiler/order-by.d.cts +2 -1
  32. package/dist/cjs/query/compiler/types.d.cts +4 -0
  33. package/dist/cjs/query/index.d.cts +1 -0
  34. package/dist/cjs/query/live/collection-config-builder.cjs +43 -6
  35. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  36. package/dist/cjs/query/live/collection-config-builder.d.cts +27 -1
  37. package/dist/cjs/query/live/collection-subscriber.cjs +29 -0
  38. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  39. package/dist/cjs/query/live/collection-subscriber.d.cts +1 -0
  40. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  41. package/dist/cjs/query/live-query-collection.d.cts +2 -2
  42. package/dist/cjs/types.d.cts +82 -11
  43. package/dist/esm/collection/events.d.ts +18 -7
  44. package/dist/esm/collection/events.js +9 -51
  45. package/dist/esm/collection/events.js.map +1 -1
  46. package/dist/esm/collection/index.d.ts +13 -14
  47. package/dist/esm/collection/index.js +9 -12
  48. package/dist/esm/collection/index.js.map +1 -1
  49. package/dist/esm/collection/subscription.d.ts +16 -3
  50. package/dist/esm/collection/subscription.js +62 -6
  51. package/dist/esm/collection/subscription.js.map +1 -1
  52. package/dist/esm/collection/sync.d.ts +18 -4
  53. package/dist/esm/collection/sync.js +59 -7
  54. package/dist/esm/collection/sync.js.map +1 -1
  55. package/dist/esm/errors.d.ts +6 -0
  56. package/dist/esm/errors.js +8 -0
  57. package/dist/esm/errors.js.map +1 -1
  58. package/dist/esm/event-emitter.d.ts +45 -0
  59. package/dist/esm/event-emitter.js +94 -0
  60. package/dist/esm/event-emitter.js.map +1 -0
  61. package/dist/esm/index.js +2 -1
  62. package/dist/esm/local-only.d.ts +2 -5
  63. package/dist/esm/local-only.js.map +1 -1
  64. package/dist/esm/query/compiler/index.d.ts +3 -2
  65. package/dist/esm/query/compiler/index.js +6 -2
  66. package/dist/esm/query/compiler/index.js.map +1 -1
  67. package/dist/esm/query/compiler/joins.d.ts +2 -2
  68. package/dist/esm/query/compiler/joins.js +6 -3
  69. package/dist/esm/query/compiler/joins.js.map +1 -1
  70. package/dist/esm/query/compiler/order-by.d.ts +2 -1
  71. package/dist/esm/query/compiler/order-by.js +18 -4
  72. package/dist/esm/query/compiler/order-by.js.map +1 -1
  73. package/dist/esm/query/compiler/types.d.ts +4 -0
  74. package/dist/esm/query/index.d.ts +1 -0
  75. package/dist/esm/query/live/collection-config-builder.d.ts +27 -1
  76. package/dist/esm/query/live/collection-config-builder.js +44 -7
  77. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  78. package/dist/esm/query/live/collection-subscriber.d.ts +1 -0
  79. package/dist/esm/query/live/collection-subscriber.js +29 -0
  80. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  81. package/dist/esm/query/live-query-collection.d.ts +2 -2
  82. package/dist/esm/query/live-query-collection.js.map +1 -1
  83. package/dist/esm/types.d.ts +82 -11
  84. package/package.json +2 -2
  85. package/src/collection/events.ts +25 -74
  86. package/src/collection/index.ts +15 -19
  87. package/src/collection/subscription.ts +88 -6
  88. package/src/collection/sync.ts +81 -9
  89. package/src/errors.ts +12 -0
  90. package/src/event-emitter.ts +118 -0
  91. package/src/local-only.ts +5 -12
  92. package/src/query/compiler/index.ts +9 -1
  93. package/src/query/compiler/joins.ts +7 -1
  94. package/src/query/compiler/order-by.ts +23 -2
  95. package/src/query/compiler/types.ts +5 -0
  96. package/src/query/index.ts +1 -0
  97. package/src/query/live/collection-config-builder.ts +76 -7
  98. package/src/query/live/collection-subscriber.ts +50 -0
  99. package/src/query/live-query-collection.ts +8 -4
  100. package/src/types.ts +93 -11
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CollectionConfigurationError,
2
3
  CollectionIsInErrorStateError,
3
4
  DuplicateKeySyncError,
4
5
  NoPendingSyncTransactionCommitError,
@@ -13,12 +14,13 @@ import type {
13
14
  ChangeMessage,
14
15
  CleanupFn,
15
16
  CollectionConfig,
16
- OnLoadMoreOptions,
17
+ LoadSubsetOptions,
17
18
  SyncConfigRes,
18
19
  } from "../types"
19
20
  import type { CollectionImpl } from "./index.js"
20
21
  import type { CollectionStateManager } from "./state"
21
22
  import type { CollectionLifecycleManager } from "./lifecycle"
23
+ import type { CollectionEventsManager } from "./events.js"
22
24
 
23
25
  export class CollectionSyncManager<
24
26
  TOutput extends object = Record<string, unknown>,
@@ -29,31 +31,38 @@ export class CollectionSyncManager<
29
31
  private collection!: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
30
32
  private state!: CollectionStateManager<TOutput, TKey, TSchema, TInput>
31
33
  private lifecycle!: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
34
+ private _events!: CollectionEventsManager
32
35
  private config!: CollectionConfig<TOutput, TKey, TSchema>
33
36
  private id: string
37
+ private syncMode: `eager` | `on-demand`
34
38
 
35
39
  public preloadPromise: Promise<void> | null = null
36
40
  public syncCleanupFn: (() => void) | null = null
37
- public syncOnLoadMoreFn:
38
- | ((options: OnLoadMoreOptions) => void | Promise<void>)
41
+ public syncLoadSubsetFn:
42
+ | ((options: LoadSubsetOptions) => true | Promise<void>)
39
43
  | null = null
40
44
 
45
+ private pendingLoadSubsetPromises: Set<Promise<void>> = new Set()
46
+
41
47
  /**
42
48
  * Creates a new CollectionSyncManager instance
43
49
  */
44
50
  constructor(config: CollectionConfig<TOutput, TKey, TSchema>, id: string) {
45
51
  this.config = config
46
52
  this.id = id
53
+ this.syncMode = config.syncMode ?? `eager`
47
54
  }
48
55
 
49
56
  setDeps(deps: {
50
57
  collection: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
51
58
  state: CollectionStateManager<TOutput, TKey, TSchema, TInput>
52
59
  lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
60
+ events: CollectionEventsManager
53
61
  }) {
54
62
  this.collection = deps.collection
55
63
  this.state = deps.state
56
64
  this.lifecycle = deps.lifecycle
65
+ this._events = deps.events
57
66
  }
58
67
 
59
68
  /**
@@ -189,8 +198,16 @@ export class CollectionSyncManager<
189
198
  // Store cleanup function if provided
190
199
  this.syncCleanupFn = syncRes?.cleanup ?? null
191
200
 
192
- // Store onLoadMore function if provided
193
- this.syncOnLoadMoreFn = syncRes?.onLoadMore ?? null
201
+ // Store loadSubset function if provided
202
+ this.syncLoadSubsetFn = syncRes?.loadSubset ?? null
203
+
204
+ // Validate: on-demand mode requires a loadSubset function
205
+ if (this.syncMode === `on-demand` && !this.syncLoadSubsetFn) {
206
+ throw new CollectionConfigurationError(
207
+ `Collection "${this.id}" is configured with syncMode "on-demand" but the sync function did not return a loadSubset handler. ` +
208
+ `Either provide a loadSubset handler or use syncMode "eager".`
209
+ )
210
+ }
194
211
  } catch (error) {
195
212
  this.lifecycle.setStatus(`error`)
196
213
  throw error
@@ -239,16 +256,71 @@ export class CollectionSyncManager<
239
256
  return this.preloadPromise
240
257
  }
241
258
 
259
+ /**
260
+ * Gets whether the collection is currently loading more data
261
+ */
262
+ public get isLoadingSubset(): boolean {
263
+ return this.pendingLoadSubsetPromises.size > 0
264
+ }
265
+
266
+ /**
267
+ * Tracks a load promise for isLoadingSubset state.
268
+ * @internal This is for internal coordination (e.g., live-query glue code), not for general use.
269
+ */
270
+ public trackLoadPromise(promise: Promise<void>): void {
271
+ const loadingStarting = !this.isLoadingSubset
272
+ this.pendingLoadSubsetPromises.add(promise)
273
+
274
+ if (loadingStarting) {
275
+ this._events.emit(`loadingSubset:change`, {
276
+ type: `loadingSubset:change`,
277
+ collection: this.collection,
278
+ isLoadingSubset: true,
279
+ previousIsLoadingSubset: false,
280
+ loadingSubsetTransition: `start`,
281
+ })
282
+ }
283
+
284
+ promise.finally(() => {
285
+ const loadingEnding =
286
+ this.pendingLoadSubsetPromises.size === 1 &&
287
+ this.pendingLoadSubsetPromises.has(promise)
288
+ this.pendingLoadSubsetPromises.delete(promise)
289
+
290
+ if (loadingEnding) {
291
+ this._events.emit(`loadingSubset:change`, {
292
+ type: `loadingSubset:change`,
293
+ collection: this.collection,
294
+ isLoadingSubset: false,
295
+ previousIsLoadingSubset: true,
296
+ loadingSubsetTransition: `end`,
297
+ })
298
+ }
299
+ })
300
+ }
301
+
242
302
  /**
243
303
  * Requests the sync layer to load more data.
244
304
  * @param options Options to control what data is being loaded
245
305
  * @returns If data loading is asynchronous, this method returns a promise that resolves when the data is loaded.
246
- * If data loading is synchronous, the data is loaded when the method returns.
306
+ * Returns true if no sync function is configured, if syncMode is 'eager', or if there is no work to do.
247
307
  */
248
- public syncMore(options: OnLoadMoreOptions): void | Promise<void> {
249
- if (this.syncOnLoadMoreFn) {
250
- return this.syncOnLoadMoreFn(options)
308
+ public loadSubset(options: LoadSubsetOptions): Promise<void> | true {
309
+ // Bypass loadSubset when syncMode is 'eager'
310
+ if (this.syncMode === `eager`) {
311
+ return true
312
+ }
313
+
314
+ if (this.syncLoadSubsetFn) {
315
+ const result = this.syncLoadSubsetFn(options)
316
+ // If the result is a promise, track it
317
+ if (result instanceof Promise) {
318
+ this.trackLoadPromise(result)
319
+ return result
320
+ }
251
321
  }
322
+
323
+ return true
252
324
  }
253
325
 
254
326
  public cleanup(): void {
package/src/errors.ts CHANGED
@@ -629,3 +629,15 @@ export class MissingAliasInputsError extends QueryCompilationError {
629
629
  )
630
630
  }
631
631
  }
632
+
633
+ /**
634
+ * Error thrown when setWindow is called on a collection without an ORDER BY clause.
635
+ */
636
+ export class SetWindowRequiresOrderByError extends QueryCompilationError {
637
+ constructor() {
638
+ super(
639
+ `setWindow() can only be called on collections with an ORDER BY clause. ` +
640
+ `Add .orderBy() to your query to enable window movement.`
641
+ )
642
+ }
643
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Generic type-safe event emitter
3
+ * @template TEvents - Record of event names to event payload types
4
+ */
5
+ export class EventEmitter<TEvents extends Record<string, any>> {
6
+ private listeners = new Map<
7
+ keyof TEvents,
8
+ Set<(event: TEvents[keyof TEvents]) => void>
9
+ >()
10
+
11
+ /**
12
+ * Subscribe to an event
13
+ * @param event - Event name to listen for
14
+ * @param callback - Function to call when event is emitted
15
+ * @returns Unsubscribe function
16
+ */
17
+ on<T extends keyof TEvents>(
18
+ event: T,
19
+ callback: (event: TEvents[T]) => void
20
+ ): () => void {
21
+ if (!this.listeners.has(event)) {
22
+ this.listeners.set(event, new Set())
23
+ }
24
+ this.listeners.get(event)!.add(callback as (event: any) => void)
25
+
26
+ return () => {
27
+ this.listeners.get(event)?.delete(callback as (event: any) => void)
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Subscribe to an event once (automatically unsubscribes after first emission)
33
+ * @param event - Event name to listen for
34
+ * @param callback - Function to call when event is emitted
35
+ * @returns Unsubscribe function
36
+ */
37
+ once<T extends keyof TEvents>(
38
+ event: T,
39
+ callback: (event: TEvents[T]) => void
40
+ ): () => void {
41
+ const unsubscribe = this.on(event, (eventPayload) => {
42
+ callback(eventPayload)
43
+ unsubscribe()
44
+ })
45
+ return unsubscribe
46
+ }
47
+
48
+ /**
49
+ * Unsubscribe from an event
50
+ * @param event - Event name to stop listening for
51
+ * @param callback - Function to remove
52
+ */
53
+ off<T extends keyof TEvents>(
54
+ event: T,
55
+ callback: (event: TEvents[T]) => void
56
+ ): void {
57
+ this.listeners.get(event)?.delete(callback as (event: any) => void)
58
+ }
59
+
60
+ /**
61
+ * Wait for an event to be emitted
62
+ * @param event - Event name to wait for
63
+ * @param timeout - Optional timeout in milliseconds
64
+ * @returns Promise that resolves with the event payload
65
+ */
66
+ waitFor<T extends keyof TEvents>(
67
+ event: T,
68
+ timeout?: number
69
+ ): Promise<TEvents[T]> {
70
+ return new Promise((resolve, reject) => {
71
+ let timeoutId: NodeJS.Timeout | undefined
72
+ const unsubscribe = this.on(event, (eventPayload) => {
73
+ if (timeoutId) {
74
+ clearTimeout(timeoutId)
75
+ timeoutId = undefined
76
+ }
77
+ resolve(eventPayload)
78
+ unsubscribe()
79
+ })
80
+ if (timeout) {
81
+ timeoutId = setTimeout(() => {
82
+ timeoutId = undefined
83
+ unsubscribe()
84
+ reject(new Error(`Timeout waiting for event ${String(event)}`))
85
+ }, timeout)
86
+ }
87
+ })
88
+ }
89
+
90
+ /**
91
+ * Emit an event to all listeners
92
+ * @param event - Event name to emit
93
+ * @param eventPayload - Event payload
94
+ * @internal For use by subclasses - subclasses should wrap this with a public emit if needed
95
+ */
96
+ protected emitInner<T extends keyof TEvents>(
97
+ event: T,
98
+ eventPayload: TEvents[T]
99
+ ): void {
100
+ this.listeners.get(event)?.forEach((listener) => {
101
+ try {
102
+ listener(eventPayload)
103
+ } catch (error) {
104
+ // Re-throw in a microtask to surface the error
105
+ queueMicrotask(() => {
106
+ throw error
107
+ })
108
+ }
109
+ })
110
+ }
111
+
112
+ /**
113
+ * Clear all listeners
114
+ */
115
+ protected clearListeners(): void {
116
+ this.listeners.clear()
117
+ }
118
+ }
package/src/local-only.ts CHANGED
@@ -1,15 +1,12 @@
1
1
  import type {
2
2
  BaseCollectionConfig,
3
3
  CollectionConfig,
4
- DeleteMutationFn,
5
4
  DeleteMutationFnParams,
6
5
  InferSchemaOutput,
7
- InsertMutationFn,
8
6
  InsertMutationFnParams,
9
7
  OperationType,
10
8
  PendingMutation,
11
9
  SyncConfig,
12
- UpdateMutationFn,
13
10
  UpdateMutationFnParams,
14
11
  UtilsRecord,
15
12
  } from "./types"
@@ -67,13 +64,7 @@ type LocalOnlyCollectionOptionsResult<
67
64
  T extends object,
68
65
  TKey extends string | number,
69
66
  TSchema extends StandardSchemaV1 | never = never,
70
- > = Omit<
71
- CollectionConfig<T, TKey, TSchema>,
72
- `onInsert` | `onUpdate` | `onDelete`
73
- > & {
74
- onInsert?: InsertMutationFn<T, TKey, LocalOnlyCollectionUtils>
75
- onUpdate?: UpdateMutationFn<T, TKey, LocalOnlyCollectionUtils>
76
- onDelete?: DeleteMutationFn<T, TKey, LocalOnlyCollectionUtils>
67
+ > = CollectionConfig<T, TKey, TSchema> & {
77
68
  utils: LocalOnlyCollectionUtils
78
69
  }
79
70
 
@@ -191,7 +182,7 @@ export function localOnlyCollectionOptions<
191
182
  const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config
192
183
 
193
184
  // Create the sync configuration with transaction confirmation capability
194
- const syncResult = createLocalOnlySync(initialData)
185
+ const syncResult = createLocalOnlySync<T, TKey>(initialData)
195
186
 
196
187
  /**
197
188
  * Create wrapper handlers that call user handlers first, then confirm transactions
@@ -279,9 +270,11 @@ export function localOnlyCollectionOptions<
279
270
  onDelete: wrappedOnDelete,
280
271
  utils: {
281
272
  acceptMutations,
282
- } as LocalOnlyCollectionUtils,
273
+ },
283
274
  startSync: true,
284
275
  gcTime: 0,
276
+ } as LocalOnlyCollectionOptionsResult<T, TKey, TSchema> & {
277
+ schema?: StandardSchemaV1
285
278
  }
286
279
  }
287
280
 
@@ -28,7 +28,9 @@ import type {
28
28
  NamespacedAndKeyedStream,
29
29
  ResultStream,
30
30
  } from "../../types.js"
31
- import type { QueryCache, QueryMapping } from "./types.js"
31
+ import type { QueryCache, QueryMapping, WindowOptions } from "./types.js"
32
+
33
+ export type { WindowOptions } from "./types.js"
32
34
 
33
35
  /**
34
36
  * Result of query compilation including both the pipeline and source-specific WHERE clauses
@@ -87,6 +89,7 @@ export function compileQuery(
87
89
  callbacks: Record<string, LazyCollectionCallbacks>,
88
90
  lazySources: Set<string>,
89
91
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
92
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
90
93
  cache: QueryCache = new WeakMap(),
91
94
  queryMapping: QueryMapping = new WeakMap()
92
95
  ): CompilationResult {
@@ -134,6 +137,7 @@ export function compileQuery(
134
137
  callbacks,
135
138
  lazySources,
136
139
  optimizableOrderByCollections,
140
+ setWindowFn,
137
141
  cache,
138
142
  queryMapping,
139
143
  aliasToCollectionId,
@@ -169,6 +173,7 @@ export function compileQuery(
169
173
  callbacks,
170
174
  lazySources,
171
175
  optimizableOrderByCollections,
176
+ setWindowFn,
172
177
  rawQuery,
173
178
  compileQuery,
174
179
  aliasToCollectionId,
@@ -311,6 +316,7 @@ export function compileQuery(
311
316
  query.select || {},
312
317
  collections[mainCollectionId]!,
313
318
  optimizableOrderByCollections,
319
+ setWindowFn,
314
320
  query.limit,
315
321
  query.offset
316
322
  )
@@ -381,6 +387,7 @@ function processFrom(
381
387
  callbacks: Record<string, LazyCollectionCallbacks>,
382
388
  lazySources: Set<string>,
383
389
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
390
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
384
391
  cache: QueryCache,
385
392
  queryMapping: QueryMapping,
386
393
  aliasToCollectionId: Record<string, string>,
@@ -412,6 +419,7 @@ function processFrom(
412
419
  callbacks,
413
420
  lazySources,
414
421
  optimizableOrderByCollections,
422
+ setWindowFn,
415
423
  cache,
416
424
  queryMapping
417
425
  )
@@ -31,7 +31,7 @@ import type {
31
31
  NamespacedAndKeyedStream,
32
32
  NamespacedRow,
33
33
  } from "../../types.js"
34
- import type { QueryCache, QueryMapping } from "./types.js"
34
+ import type { QueryCache, QueryMapping, WindowOptions } from "./types.js"
35
35
  import type { CollectionSubscription } from "../../collection/subscription.js"
36
36
 
37
37
  /** Function type for loading specific keys into a lazy collection */
@@ -61,6 +61,7 @@ export function processJoins(
61
61
  callbacks: Record<string, LazyCollectionCallbacks>,
62
62
  lazySources: Set<string>,
63
63
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
64
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
64
65
  rawQuery: QueryIR,
65
66
  onCompileSubquery: CompileQueryFn,
66
67
  aliasToCollectionId: Record<string, string>,
@@ -83,6 +84,7 @@ export function processJoins(
83
84
  callbacks,
84
85
  lazySources,
85
86
  optimizableOrderByCollections,
87
+ setWindowFn,
86
88
  rawQuery,
87
89
  onCompileSubquery,
88
90
  aliasToCollectionId,
@@ -111,6 +113,7 @@ function processJoin(
111
113
  callbacks: Record<string, LazyCollectionCallbacks>,
112
114
  lazySources: Set<string>,
113
115
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
116
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
114
117
  rawQuery: QueryIR,
115
118
  onCompileSubquery: CompileQueryFn,
116
119
  aliasToCollectionId: Record<string, string>,
@@ -131,6 +134,7 @@ function processJoin(
131
134
  callbacks,
132
135
  lazySources,
133
136
  optimizableOrderByCollections,
137
+ setWindowFn,
134
138
  cache,
135
139
  queryMapping,
136
140
  onCompileSubquery,
@@ -421,6 +425,7 @@ function processJoinSource(
421
425
  callbacks: Record<string, LazyCollectionCallbacks>,
422
426
  lazySources: Set<string>,
423
427
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
428
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
424
429
  cache: QueryCache,
425
430
  queryMapping: QueryMapping,
426
431
  onCompileSubquery: CompileQueryFn,
@@ -453,6 +458,7 @@ function processJoinSource(
453
458
  callbacks,
454
459
  lazySources,
455
460
  optimizableOrderByCollections,
461
+ setWindowFn,
456
462
  cache,
457
463
  queryMapping
458
464
  )
@@ -5,6 +5,7 @@ import { ensureIndexForField } from "../../indexes/auto-index.js"
5
5
  import { findIndexForField } from "../../utils/index-optimization.js"
6
6
  import { compileExpression } from "./evaluators.js"
7
7
  import { replaceAggregatesByRefs } from "./group-by.js"
8
+ import type { WindowOptions } from "./types.js"
8
9
  import type { CompiledSingleRowExpression } from "./evaluators.js"
9
10
  import type { OrderBy, OrderByClause, QueryIR, Select } from "../ir.js"
10
11
  import type { NamespacedAndKeyedStream, NamespacedRow } from "../../types.js"
@@ -38,6 +39,7 @@ export function processOrderBy(
38
39
  selectClause: Select,
39
40
  collection: Collection,
40
41
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
42
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
41
43
  limit?: number,
42
44
  offset?: number
43
45
  ): IStreamBuilder<KeyValue<unknown, [NamespacedRow, string]>> {
@@ -107,6 +109,8 @@ export function processOrderBy(
107
109
 
108
110
  let setSizeCallback: ((getSize: () => number) => void) | undefined
109
111
 
112
+ let orderByOptimizationInfo: OrderByOptimizationInfo | undefined
113
+
110
114
  // Optimize the orderBy operator to lazily load elements
111
115
  // by using the range index of the collection.
112
116
  // Only for orderBy clause on a single column for now (no composite ordering)
@@ -161,7 +165,7 @@ export function processOrderBy(
161
165
  ? String(orderByExpression.path[0])
162
166
  : rawQuery.from.alias
163
167
 
164
- const orderByOptimizationInfo = {
168
+ orderByOptimizationInfo = {
165
169
  alias: orderByAlias,
166
170
  offset: offset ?? 0,
167
171
  limit,
@@ -179,7 +183,7 @@ export function processOrderBy(
179
183
  ...optimizableOrderByCollections[followRefCollection.id]!,
180
184
  dataNeeded: () => {
181
185
  const size = getSize()
182
- return Math.max(0, limit - size)
186
+ return Math.max(0, orderByOptimizationInfo!.limit - size)
183
187
  },
184
188
  }
185
189
  }
@@ -194,6 +198,23 @@ export function processOrderBy(
194
198
  offset,
195
199
  comparator: compare,
196
200
  setSizeCallback,
201
+ setWindowFn: (
202
+ windowFn: (options: { offset?: number; limit?: number }) => void
203
+ ) => {
204
+ setWindowFn(
205
+ // We wrap the move function such that we update the orderByOptimizationInfo
206
+ // because that is used by the `dataNeeded` callback to determine if we need to load more data
207
+ (options) => {
208
+ windowFn(options)
209
+ if (orderByOptimizationInfo) {
210
+ orderByOptimizationInfo.offset =
211
+ options.offset ?? orderByOptimizationInfo.offset
212
+ orderByOptimizationInfo.limit =
213
+ options.limit ?? orderByOptimizationInfo.limit
214
+ }
215
+ }
216
+ )
217
+ },
197
218
  })
198
219
  // orderByWithFractionalIndex returns [key, [value, index]] - we keep this format
199
220
  )
@@ -10,3 +10,8 @@ export type QueryCache = WeakMap<QueryIR, CompilationResult>
10
10
  * Mapping from optimized queries back to their original queries for caching
11
11
  */
12
12
  export type QueryMapping = WeakMap<QueryIR, QueryIR>
13
+
14
+ export type WindowOptions = {
15
+ offset?: number
16
+ limit?: number
17
+ }
@@ -56,3 +56,4 @@ export {
56
56
  } from "./live-query-collection.js"
57
57
 
58
58
  export { type LiveQueryCollectionConfig } from "./live/types.js"
59
+ export { type LiveQueryCollectionUtils } from "./live/collection-config-builder.js"
@@ -1,11 +1,15 @@
1
1
  import { D2, output } from "@tanstack/db-ivm"
2
2
  import { compileQuery } from "../compiler/index.js"
3
3
  import { buildQuery, getQueryIR } from "../builder/index.js"
4
- import { MissingAliasInputsError } from "../../errors.js"
4
+ import {
5
+ MissingAliasInputsError,
6
+ SetWindowRequiresOrderByError,
7
+ } from "../../errors.js"
5
8
  import { transactionScopedScheduler } from "../../scheduler.js"
6
9
  import { getActiveTransaction } from "../../transactions.js"
7
10
  import { CollectionSubscriber } from "./collection-subscriber.js"
8
11
  import { getCollectionBuilder } from "./collection-registry.js"
12
+ import type { WindowOptions } from "../compiler/index.js"
9
13
  import type { SchedulerContextId } from "../../scheduler.js"
10
14
  import type { CollectionSubscription } from "../../collection/subscription.js"
11
15
  import type { RootStreamBuilder } from "@tanstack/db-ivm"
@@ -32,6 +36,19 @@ import type { AllCollectionEvents } from "../../collection/events.js"
32
36
  export type LiveQueryCollectionUtils = UtilsRecord & {
33
37
  getRunCount: () => number
34
38
  getBuilder: () => CollectionConfigBuilder<any, any>
39
+ /**
40
+ * Sets the offset and limit of an ordered query.
41
+ * Is a no-op if the query is not ordered.
42
+ *
43
+ * @returns `true` if no subset loading was triggered, or `Promise<void>` that resolves when the subset has been loaded
44
+ */
45
+ setWindow: (options: WindowOptions) => true | Promise<void>
46
+ /**
47
+ * Gets the current window (offset and limit) for an ordered query.
48
+ *
49
+ * @returns The current window settings, or `undefined` if the query is not windowed
50
+ */
51
+ getWindow: () => { offset: number; limit: number } | undefined
35
52
  }
36
53
 
37
54
  type PendingGraphRun = {
@@ -79,7 +96,12 @@ export class CollectionConfigBuilder<
79
96
  private isInErrorState = false
80
97
 
81
98
  // Reference to the live query collection for error state transitions
82
- private liveQueryCollection?: Collection<TResult, any, any>
99
+ public liveQueryCollection?: Collection<TResult, any, any>
100
+
101
+ private windowFn: ((options: WindowOptions) => void) | undefined
102
+ private currentWindow: WindowOptions | undefined
103
+
104
+ private maybeRunGraphFn: (() => void) | undefined
83
105
 
84
106
  private readonly aliasDependencies: Record<
85
107
  string,
@@ -171,10 +193,52 @@ export class CollectionConfigBuilder<
171
193
  utils: {
172
194
  getRunCount: this.getRunCount.bind(this),
173
195
  getBuilder: () => this,
196
+ setWindow: this.setWindow.bind(this),
197
+ getWindow: this.getWindow.bind(this),
174
198
  },
175
199
  }
176
200
  }
177
201
 
202
+ setWindow(options: WindowOptions): true | Promise<void> {
203
+ if (!this.windowFn) {
204
+ throw new SetWindowRequiresOrderByError()
205
+ }
206
+
207
+ this.currentWindow = options
208
+ this.windowFn(options)
209
+ this.maybeRunGraphFn?.()
210
+
211
+ // Check if loading a subset was triggered
212
+ if (this.liveQueryCollection?.isLoadingSubset) {
213
+ // Loading was triggered, return a promise that resolves when it completes
214
+ return new Promise<void>((resolve) => {
215
+ const unsubscribe = this.liveQueryCollection!.on(
216
+ `loadingSubset:change`,
217
+ (event) => {
218
+ if (!event.isLoadingSubset) {
219
+ unsubscribe()
220
+ resolve()
221
+ }
222
+ }
223
+ )
224
+ })
225
+ }
226
+
227
+ // No loading was triggered
228
+ return true
229
+ }
230
+
231
+ getWindow(): { offset: number; limit: number } | undefined {
232
+ // Only return window if this is a windowed query (has orderBy and windowFn)
233
+ if (!this.windowFn || !this.currentWindow) {
234
+ return undefined
235
+ }
236
+ return {
237
+ offset: this.currentWindow.offset ?? 0,
238
+ limit: this.currentWindow.limit ?? 0,
239
+ }
240
+ }
241
+
178
242
  /**
179
243
  * Resolves a collection alias to its collection ID.
180
244
  *
@@ -452,13 +516,15 @@ export class CollectionConfigBuilder<
452
516
  }
453
517
  )
454
518
 
455
- const loadMoreDataCallbacks = this.subscribeToAllCollections(
519
+ const loadSubsetDataCallbacks = this.subscribeToAllCollections(
456
520
  config,
457
521
  fullSyncState
458
522
  )
459
523
 
524
+ this.maybeRunGraphFn = () => this.scheduleGraphRun(loadSubsetDataCallbacks)
525
+
460
526
  // Initial run with callback to load more data if needed
461
- this.scheduleGraphRun(loadMoreDataCallbacks)
527
+ this.scheduleGraphRun(loadSubsetDataCallbacks)
462
528
 
463
529
  // Return the unsubscribe function
464
530
  return () => {
@@ -517,7 +583,10 @@ export class CollectionConfigBuilder<
517
583
  this.subscriptions,
518
584
  this.lazySourcesCallbacks,
519
585
  this.lazySources,
520
- this.optimizableOrderByCollections
586
+ this.optimizableOrderByCollections,
587
+ (windowFn: (options: WindowOptions) => void) => {
588
+ this.windowFn = windowFn
589
+ }
521
590
  )
522
591
 
523
592
  this.pipelineCache = compilation.pipeline
@@ -764,7 +833,7 @@ export class CollectionConfigBuilder<
764
833
  // Combine all loaders into a single callback that initiates loading more data
765
834
  // from any source that needs it. Returns true once all loaders have been called,
766
835
  // but the actual async loading may still be in progress.
767
- const loadMoreDataCallback = () => {
836
+ const loadSubsetDataCallbacks = () => {
768
837
  loaders.map((loader) => loader())
769
838
  return true
770
839
  }
@@ -776,7 +845,7 @@ export class CollectionConfigBuilder<
776
845
  // Initial status check after all subscriptions are set up
777
846
  this.updateLiveQueryStatus(config)
778
847
 
779
- return loadMoreDataCallback
848
+ return loadSubsetDataCallbacks
780
849
  }
781
850
  }
782
851