@tanstack/db 0.4.9 → 0.4.10

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 +32 -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 +13 -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 +13 -1
  76. package/dist/esm/query/live/collection-config-builder.js +33 -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 +56 -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,3 +1,4 @@
1
+ import { EventEmitter } from "../event-emitter.js"
1
2
  import type { Collection } from "./index.js"
2
3
  import type { CollectionStatus } from "../types.js"
3
4
 
@@ -31,9 +32,21 @@ export interface CollectionSubscribersChangeEvent {
31
32
  subscriberCount: number
32
33
  }
33
34
 
35
+ /**
36
+ * Event emitted when the collection's loading more state changes
37
+ */
38
+ export interface CollectionLoadingSubsetChangeEvent {
39
+ type: `loadingSubset:change`
40
+ collection: Collection<any, any, any, any, any>
41
+ isLoadingSubset: boolean
42
+ previousIsLoadingSubset: boolean
43
+ loadingSubsetTransition: `start` | `end`
44
+ }
45
+
34
46
  export type AllCollectionEvents = {
35
47
  "status:change": CollectionStatusChangeEvent
36
48
  "subscribers:change": CollectionSubscribersChangeEvent
49
+ "loadingSubset:change": CollectionLoadingSubsetChangeEvent
37
50
  } & {
38
51
  [K in CollectionStatus as `status:${K}`]: CollectionStatusEvent<K>
39
52
  }
@@ -42,94 +55,32 @@ export type CollectionEvent =
42
55
  | AllCollectionEvents[keyof AllCollectionEvents]
43
56
  | CollectionStatusChangeEvent
44
57
  | CollectionSubscribersChangeEvent
58
+ | CollectionLoadingSubsetChangeEvent
45
59
 
46
60
  export type CollectionEventHandler<T extends keyof AllCollectionEvents> = (
47
61
  event: AllCollectionEvents[T]
48
62
  ) => void
49
63
 
50
- export class CollectionEventsManager {
64
+ export class CollectionEventsManager extends EventEmitter<AllCollectionEvents> {
51
65
  private collection!: Collection<any, any, any, any, any>
52
- private listeners = new Map<
53
- keyof AllCollectionEvents,
54
- Set<CollectionEventHandler<any>>
55
- >()
56
66
 
57
- constructor() {}
67
+ constructor() {
68
+ super()
69
+ }
58
70
 
59
71
  setDeps(deps: { collection: Collection<any, any, any, any, any> }) {
60
72
  this.collection = deps.collection
61
73
  }
62
74
 
63
- on<T extends keyof AllCollectionEvents>(
64
- event: T,
65
- callback: CollectionEventHandler<T>
66
- ) {
67
- if (!this.listeners.has(event)) {
68
- this.listeners.set(event, new Set())
69
- }
70
- this.listeners.get(event)!.add(callback)
71
-
72
- return () => {
73
- this.listeners.get(event)?.delete(callback)
74
- }
75
- }
76
-
77
- once<T extends keyof AllCollectionEvents>(
78
- event: T,
79
- callback: CollectionEventHandler<T>
80
- ) {
81
- const unsubscribe = this.on(event, (eventPayload) => {
82
- callback(eventPayload)
83
- unsubscribe()
84
- })
85
- return unsubscribe
86
- }
87
-
88
- off<T extends keyof AllCollectionEvents>(
89
- event: T,
90
- callback: CollectionEventHandler<T>
91
- ) {
92
- this.listeners.get(event)?.delete(callback)
93
- }
94
-
95
- waitFor<T extends keyof AllCollectionEvents>(
96
- event: T,
97
- timeout?: number
98
- ): Promise<AllCollectionEvents[T]> {
99
- return new Promise((resolve, reject) => {
100
- let timeoutId: NodeJS.Timeout | undefined
101
- const unsubscribe = this.on(event, (eventPayload) => {
102
- if (timeoutId) {
103
- clearTimeout(timeoutId)
104
- timeoutId = undefined
105
- }
106
- resolve(eventPayload)
107
- unsubscribe()
108
- })
109
- if (timeout) {
110
- timeoutId = setTimeout(() => {
111
- timeoutId = undefined
112
- unsubscribe()
113
- reject(new Error(`Timeout waiting for event ${event}`))
114
- }, timeout)
115
- }
116
- })
117
- }
118
-
75
+ /**
76
+ * Emit an event to all listeners
77
+ * Public API for emitting collection events
78
+ */
119
79
  emit<T extends keyof AllCollectionEvents>(
120
80
  event: T,
121
81
  eventPayload: AllCollectionEvents[T]
122
- ) {
123
- this.listeners.get(event)?.forEach((listener) => {
124
- try {
125
- listener(eventPayload)
126
- } catch (error) {
127
- // Re-throw in a microtask to surface the error
128
- queueMicrotask(() => {
129
- throw error
130
- })
131
- }
132
- })
82
+ ): void {
83
+ this.emitInner(event, eventPayload)
133
84
  }
134
85
 
135
86
  emitStatusChange<T extends CollectionStatus>(
@@ -166,6 +117,6 @@ export class CollectionEventsManager {
166
117
  }
167
118
 
168
119
  cleanup() {
169
- this.listeners.clear()
120
+ this.clearListeners()
170
121
  }
171
122
  }
@@ -25,7 +25,6 @@ import type {
25
25
  InferSchemaOutput,
26
26
  InsertConfig,
27
27
  NonSingleResult,
28
- OnLoadMoreOptions,
29
28
  OperationConfig,
30
29
  SingleResult,
31
30
  SubscribeChangesOptions,
@@ -48,7 +47,7 @@ import type { IndexProxy } from "../indexes/lazy-index.js"
48
47
  export interface Collection<
49
48
  T extends object = Record<string, unknown>,
50
49
  TKey extends string | number = string | number,
51
- TUtils extends UtilsRecord = {},
50
+ TUtils extends UtilsRecord = UtilsRecord,
52
51
  TSchema extends StandardSchemaV1 = StandardSchemaV1,
53
52
  TInsertInput extends object = T,
54
53
  > extends CollectionImpl<T, TKey, TUtils, TSchema, TInsertInput> {
@@ -131,7 +130,7 @@ export interface Collection<
131
130
  export function createCollection<
132
131
  T extends StandardSchemaV1,
133
132
  TKey extends string | number = string | number,
134
- TUtils extends UtilsRecord = {},
133
+ TUtils extends UtilsRecord = UtilsRecord,
135
134
  >(
136
135
  options: CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
137
136
  schema: T
@@ -144,7 +143,7 @@ export function createCollection<
144
143
  export function createCollection<
145
144
  T extends StandardSchemaV1,
146
145
  TKey extends string | number = string | number,
147
- TUtils extends UtilsRecord = {},
146
+ TUtils extends UtilsRecord = UtilsRecord,
148
147
  >(
149
148
  options: CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
150
149
  schema: T
@@ -158,7 +157,7 @@ export function createCollection<
158
157
  export function createCollection<
159
158
  T extends object,
160
159
  TKey extends string | number = string | number,
161
- TUtils extends UtilsRecord = {},
160
+ TUtils extends UtilsRecord = UtilsRecord,
162
161
  >(
163
162
  options: CollectionConfig<T, TKey, never> & {
164
163
  schema?: never // prohibit schema if an explicit type is provided
@@ -171,7 +170,7 @@ export function createCollection<
171
170
  export function createCollection<
172
171
  T extends object,
173
172
  TKey extends string | number = string | number,
174
- TUtils extends UtilsRecord = {},
173
+ TUtils extends UtilsRecord = UtilsRecord,
175
174
  >(
176
175
  options: CollectionConfig<T, TKey, never> & {
177
176
  schema?: never // prohibit schema if an explicit type is provided
@@ -218,7 +217,7 @@ export class CollectionImpl<
218
217
  private _events: CollectionEventsManager
219
218
  private _changes: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
220
219
  public _lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
221
- private _sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
220
+ public _sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
222
221
  private _indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
223
222
  private _mutations: CollectionMutationsManager<
224
223
  TOutput,
@@ -303,6 +302,7 @@ export class CollectionImpl<
303
302
  collection: this, // Required for passing to config.sync callback
304
303
  state: this._state,
305
304
  lifecycle: this._lifecycle,
305
+ events: this._events,
306
306
  })
307
307
 
308
308
  // Only start sync immediately if explicitly enabled
@@ -356,23 +356,19 @@ export class CollectionImpl<
356
356
  }
357
357
 
358
358
  /**
359
- * Start sync immediately - internal method for compiled queries
360
- * This bypasses lazy loading for special cases like live query results
359
+ * Check if the collection is currently loading more data
360
+ * @returns true if the collection has pending load more operations, false otherwise
361
361
  */
362
- public startSyncImmediate(): void {
363
- this._sync.startSync()
362
+ public get isLoadingSubset(): boolean {
363
+ return this._sync.isLoadingSubset
364
364
  }
365
365
 
366
366
  /**
367
- * Requests the sync layer to load more data.
368
- * @param options Options to control what data is being loaded
369
- * @returns If data loading is asynchronous, this method returns a promise that resolves when the data is loaded.
370
- * If data loading is synchronous, the data is loaded when the method returns.
367
+ * Start sync immediately - internal method for compiled queries
368
+ * This bypasses lazy loading for special cases like live query results
371
369
  */
372
- public syncMore(options: OnLoadMoreOptions): void | Promise<void> {
373
- if (this._sync.syncOnLoadMoreFn) {
374
- return this._sync.syncOnLoadMoreFn(options)
375
- }
370
+ public startSyncImmediate(): void {
371
+ this._sync.startSync()
376
372
  }
377
373
 
378
374
  /**
@@ -1,13 +1,20 @@
1
1
  import { ensureIndexForExpression } from "../indexes/auto-index.js"
2
2
  import { and, gt, lt } from "../query/builder/functions.js"
3
3
  import { Value } from "../query/ir.js"
4
+ import { EventEmitter } from "../event-emitter.js"
4
5
  import {
5
6
  createFilterFunctionFromExpression,
6
7
  createFilteredCallback,
7
8
  } from "./change-events.js"
8
9
  import type { BasicExpression, OrderBy } from "../query/ir.js"
9
10
  import type { IndexInterface } from "../indexes/base-index.js"
10
- import type { ChangeMessage } from "../types.js"
11
+ import type {
12
+ ChangeMessage,
13
+ Subscription,
14
+ SubscriptionEvents,
15
+ SubscriptionStatus,
16
+ SubscriptionUnsubscribedEvent,
17
+ } from "../types.js"
11
18
  import type { CollectionImpl } from "./index.js"
12
19
 
13
20
  type RequestSnapshotOptions = {
@@ -22,13 +29,17 @@ type RequestLimitedSnapshotOptions = {
22
29
  }
23
30
 
24
31
  type CollectionSubscriptionOptions = {
32
+ includeInitialState?: boolean
25
33
  /** Pre-compiled expression for filtering changes */
26
34
  whereExpression?: BasicExpression<boolean>
27
35
  /** Callback to call when the subscription is unsubscribed */
28
- onUnsubscribe?: () => void
36
+ onUnsubscribe?: (event: SubscriptionUnsubscribedEvent) => void
29
37
  }
30
38
 
31
- export class CollectionSubscription {
39
+ export class CollectionSubscription
40
+ extends EventEmitter<SubscriptionEvents>
41
+ implements Subscription
42
+ {
32
43
  private loadedInitialState = false
33
44
 
34
45
  // Flag to indicate that we have sent at least 1 snapshot.
@@ -42,11 +53,24 @@ export class CollectionSubscription {
42
53
 
43
54
  private orderByIndex: IndexInterface<string | number> | undefined
44
55
 
56
+ // Status tracking
57
+ private _status: SubscriptionStatus = `ready`
58
+ private pendingLoadSubsetPromises: Set<Promise<void>> = new Set()
59
+
60
+ public get status(): SubscriptionStatus {
61
+ return this._status
62
+ }
63
+
45
64
  constructor(
46
65
  private collection: CollectionImpl<any, any, any, any, any>,
47
66
  private callback: (changes: Array<ChangeMessage<any, any>>) => void,
48
67
  private options: CollectionSubscriptionOptions
49
68
  ) {
69
+ super()
70
+ if (options.onUnsubscribe) {
71
+ this.on(`unsubscribed`, (event) => options.onUnsubscribe!(event))
72
+ }
73
+
50
74
  // Auto-index for where expressions if enabled
51
75
  if (options.whereExpression) {
52
76
  ensureIndexForExpression(options.whereExpression, this.collection)
@@ -71,6 +95,53 @@ export class CollectionSubscription {
71
95
  this.orderByIndex = index
72
96
  }
73
97
 
98
+ /**
99
+ * Set subscription status and emit events if changed
100
+ */
101
+ private setStatus(newStatus: SubscriptionStatus) {
102
+ if (this._status === newStatus) {
103
+ return // No change
104
+ }
105
+
106
+ const previousStatus = this._status
107
+ this._status = newStatus
108
+
109
+ // Emit status:change event
110
+ this.emitInner(`status:change`, {
111
+ type: `status:change`,
112
+ subscription: this,
113
+ previousStatus,
114
+ status: newStatus,
115
+ })
116
+
117
+ // Emit specific status event
118
+ const eventKey: `status:${SubscriptionStatus}` = `status:${newStatus}`
119
+ this.emitInner(eventKey, {
120
+ type: eventKey,
121
+ subscription: this,
122
+ previousStatus,
123
+ status: newStatus,
124
+ } as SubscriptionEvents[typeof eventKey])
125
+ }
126
+
127
+ /**
128
+ * Track a loadSubset promise and manage loading status
129
+ */
130
+ private trackLoadSubsetPromise(syncResult: Promise<void> | true) {
131
+ // Track the promise if it's actually a promise (async work)
132
+ if (syncResult instanceof Promise) {
133
+ this.pendingLoadSubsetPromises.add(syncResult)
134
+ this.setStatus(`loadingSubset`)
135
+
136
+ syncResult.finally(() => {
137
+ this.pendingLoadSubsetPromises.delete(syncResult)
138
+ if (this.pendingLoadSubsetPromises.size === 0) {
139
+ this.setStatus(`ready`)
140
+ }
141
+ })
142
+ }
143
+ }
144
+
74
145
  hasLoadedInitialState() {
75
146
  return this.loadedInitialState
76
147
  }
@@ -121,10 +192,13 @@ export class CollectionSubscription {
121
192
 
122
193
  // Request the sync layer to load more data
123
194
  // don't await it, we will load the data into the collection when it comes in
124
- this.collection.syncMore({
195
+ const syncResult = this.collection._sync.loadSubset({
125
196
  where: stateOpts.where,
197
+ subscription: this,
126
198
  })
127
199
 
200
+ this.trackLoadSubsetPromise(syncResult)
201
+
128
202
  // Also load data immediately from the collection
129
203
  const snapshot = this.collection.currentStateAsChanges(stateOpts)
130
204
 
@@ -215,11 +289,14 @@ export class CollectionSubscription {
215
289
 
216
290
  // Request the sync layer to load more data
217
291
  // don't await it, we will load the data into the collection when it comes in
218
- this.collection.syncMore({
292
+ const syncResult = this.collection._sync.loadSubset({
219
293
  where: whereWithValueFilter,
220
294
  limit,
221
295
  orderBy,
296
+ subscription: this,
222
297
  })
298
+
299
+ this.trackLoadSubsetPromise(syncResult)
223
300
  }
224
301
 
225
302
  /**
@@ -264,6 +341,11 @@ export class CollectionSubscription {
264
341
  }
265
342
 
266
343
  unsubscribe() {
267
- this.options.onUnsubscribe?.()
344
+ this.emitInner(`unsubscribed`, {
345
+ type: `unsubscribed`,
346
+ subscription: this,
347
+ })
348
+ // Clear all event listeners to prevent memory leaks
349
+ this.clearListeners()
268
350
  }
269
351
  }
@@ -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
+ }