@tanstack/db 0.4.8 → 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 (134) 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 +59 -17
  14. package/dist/cjs/errors.cjs.map +1 -1
  15. package/dist/cjs/errors.d.cts +44 -8
  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 +9 -4
  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/builder/types.d.cts +1 -1
  24. package/dist/cjs/query/compiler/index.cjs +46 -19
  25. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  26. package/dist/cjs/query/compiler/index.d.cts +35 -9
  27. package/dist/cjs/query/compiler/joins.cjs +91 -66
  28. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  29. package/dist/cjs/query/compiler/joins.d.cts +6 -3
  30. package/dist/cjs/query/compiler/order-by.cjs +20 -4
  31. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  32. package/dist/cjs/query/compiler/order-by.d.cts +3 -1
  33. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  34. package/dist/cjs/query/compiler/types.d.cts +4 -0
  35. package/dist/cjs/query/index.d.cts +1 -0
  36. package/dist/cjs/query/live/collection-config-builder.cjs +306 -46
  37. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  38. package/dist/cjs/query/live/collection-config-builder.d.cts +97 -9
  39. package/dist/cjs/query/live/collection-registry.cjs +16 -0
  40. package/dist/cjs/query/live/collection-registry.cjs.map +1 -0
  41. package/dist/cjs/query/live/collection-registry.d.cts +26 -0
  42. package/dist/cjs/query/live/collection-subscriber.cjs +86 -58
  43. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  44. package/dist/cjs/query/live/collection-subscriber.d.cts +5 -7
  45. package/dist/cjs/query/live-query-collection.cjs +11 -5
  46. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  47. package/dist/cjs/query/live-query-collection.d.cts +12 -5
  48. package/dist/cjs/query/optimizer.cjs +44 -7
  49. package/dist/cjs/query/optimizer.cjs.map +1 -1
  50. package/dist/cjs/query/optimizer.d.cts +4 -4
  51. package/dist/cjs/scheduler.cjs +137 -0
  52. package/dist/cjs/scheduler.cjs.map +1 -0
  53. package/dist/cjs/scheduler.d.cts +56 -0
  54. package/dist/cjs/transactions.cjs +7 -1
  55. package/dist/cjs/transactions.cjs.map +1 -1
  56. package/dist/cjs/types.d.cts +82 -11
  57. package/dist/esm/collection/events.d.ts +18 -7
  58. package/dist/esm/collection/events.js +9 -51
  59. package/dist/esm/collection/events.js.map +1 -1
  60. package/dist/esm/collection/index.d.ts +13 -14
  61. package/dist/esm/collection/index.js +9 -12
  62. package/dist/esm/collection/index.js.map +1 -1
  63. package/dist/esm/collection/subscription.d.ts +16 -3
  64. package/dist/esm/collection/subscription.js +62 -6
  65. package/dist/esm/collection/subscription.js.map +1 -1
  66. package/dist/esm/collection/sync.d.ts +18 -4
  67. package/dist/esm/collection/sync.js +59 -7
  68. package/dist/esm/collection/sync.js.map +1 -1
  69. package/dist/esm/errors.d.ts +44 -8
  70. package/dist/esm/errors.js +60 -18
  71. package/dist/esm/errors.js.map +1 -1
  72. package/dist/esm/event-emitter.d.ts +45 -0
  73. package/dist/esm/event-emitter.js +94 -0
  74. package/dist/esm/event-emitter.js.map +1 -0
  75. package/dist/esm/index.js +10 -5
  76. package/dist/esm/local-only.d.ts +2 -5
  77. package/dist/esm/local-only.js.map +1 -1
  78. package/dist/esm/query/builder/types.d.ts +1 -1
  79. package/dist/esm/query/compiler/index.d.ts +35 -9
  80. package/dist/esm/query/compiler/index.js +46 -19
  81. package/dist/esm/query/compiler/index.js.map +1 -1
  82. package/dist/esm/query/compiler/joins.d.ts +6 -3
  83. package/dist/esm/query/compiler/joins.js +93 -68
  84. package/dist/esm/query/compiler/joins.js.map +1 -1
  85. package/dist/esm/query/compiler/order-by.d.ts +3 -1
  86. package/dist/esm/query/compiler/order-by.js +20 -4
  87. package/dist/esm/query/compiler/order-by.js.map +1 -1
  88. package/dist/esm/query/compiler/select.js.map +1 -1
  89. package/dist/esm/query/compiler/types.d.ts +4 -0
  90. package/dist/esm/query/index.d.ts +1 -0
  91. package/dist/esm/query/live/collection-config-builder.d.ts +97 -9
  92. package/dist/esm/query/live/collection-config-builder.js +306 -46
  93. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  94. package/dist/esm/query/live/collection-registry.d.ts +26 -0
  95. package/dist/esm/query/live/collection-registry.js +16 -0
  96. package/dist/esm/query/live/collection-registry.js.map +1 -0
  97. package/dist/esm/query/live/collection-subscriber.d.ts +5 -7
  98. package/dist/esm/query/live/collection-subscriber.js +86 -58
  99. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  100. package/dist/esm/query/live-query-collection.d.ts +12 -5
  101. package/dist/esm/query/live-query-collection.js +11 -5
  102. package/dist/esm/query/live-query-collection.js.map +1 -1
  103. package/dist/esm/query/optimizer.d.ts +4 -4
  104. package/dist/esm/query/optimizer.js +44 -7
  105. package/dist/esm/query/optimizer.js.map +1 -1
  106. package/dist/esm/scheduler.d.ts +56 -0
  107. package/dist/esm/scheduler.js +137 -0
  108. package/dist/esm/scheduler.js.map +1 -0
  109. package/dist/esm/transactions.js +7 -1
  110. package/dist/esm/transactions.js.map +1 -1
  111. package/dist/esm/types.d.ts +82 -11
  112. package/package.json +2 -2
  113. package/src/collection/events.ts +25 -74
  114. package/src/collection/index.ts +15 -19
  115. package/src/collection/subscription.ts +88 -6
  116. package/src/collection/sync.ts +81 -9
  117. package/src/errors.ts +91 -13
  118. package/src/event-emitter.ts +118 -0
  119. package/src/local-only.ts +5 -12
  120. package/src/query/builder/types.ts +1 -1
  121. package/src/query/compiler/index.ts +124 -33
  122. package/src/query/compiler/joins.ts +187 -128
  123. package/src/query/compiler/order-by.ts +30 -2
  124. package/src/query/compiler/select.ts +2 -3
  125. package/src/query/compiler/types.ts +5 -0
  126. package/src/query/index.ts +1 -0
  127. package/src/query/live/collection-config-builder.ts +501 -60
  128. package/src/query/live/collection-registry.ts +47 -0
  129. package/src/query/live/collection-subscriber.ts +137 -105
  130. package/src/query/live-query-collection.ts +47 -18
  131. package/src/query/optimizer.ts +85 -15
  132. package/src/scheduler.ts +198 -0
  133. package/src/transactions.ts +12 -1
  134. package/src/types.ts +93 -11
@@ -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
@@ -349,9 +349,23 @@ export class LimitOffsetRequireOrderByError extends QueryCompilationError {
349
349
  }
350
350
  }
351
351
 
352
+ /**
353
+ * Error thrown when a collection input stream is not found during query compilation.
354
+ * In self-joins, each alias (e.g., 'employee', 'manager') requires its own input stream.
355
+ */
352
356
  export class CollectionInputNotFoundError extends QueryCompilationError {
353
- constructor(collectionId: string) {
354
- super(`Input for collection "${collectionId}" not found in inputs map`)
357
+ constructor(
358
+ alias: string,
359
+ collectionId?: string,
360
+ availableKeys?: Array<string>
361
+ ) {
362
+ const details = collectionId
363
+ ? `alias "${alias}" (collection "${collectionId}")`
364
+ : `collection "${alias}"`
365
+ const availableKeysMsg = availableKeys?.length
366
+ ? `. Available keys: ${availableKeys.join(`, `)}`
367
+ : ``
368
+ super(`Input for ${details} not found in inputs map${availableKeysMsg}`)
355
369
  }
356
370
  }
357
371
 
@@ -399,32 +413,32 @@ export class UnsupportedJoinTypeError extends JoinError {
399
413
  }
400
414
  }
401
415
 
402
- export class InvalidJoinConditionSameTableError extends JoinError {
403
- constructor(tableAlias: string) {
416
+ export class InvalidJoinConditionSameSourceError extends JoinError {
417
+ constructor(sourceAlias: string) {
404
418
  super(
405
- `Invalid join condition: both expressions refer to the same table "${tableAlias}"`
419
+ `Invalid join condition: both expressions refer to the same source "${sourceAlias}"`
406
420
  )
407
421
  }
408
422
  }
409
423
 
410
- export class InvalidJoinConditionTableMismatchError extends JoinError {
424
+ export class InvalidJoinConditionSourceMismatchError extends JoinError {
411
425
  constructor() {
412
- super(`Invalid join condition: expressions must reference table aliases`)
426
+ super(`Invalid join condition: expressions must reference source aliases`)
413
427
  }
414
428
  }
415
429
 
416
- export class InvalidJoinConditionLeftTableError extends JoinError {
417
- constructor(tableAlias: string) {
430
+ export class InvalidJoinConditionLeftSourceError extends JoinError {
431
+ constructor(sourceAlias: string) {
418
432
  super(
419
- `Invalid join condition: left expression refers to an unavailable table "${tableAlias}"`
433
+ `Invalid join condition: left expression refers to an unavailable source "${sourceAlias}"`
420
434
  )
421
435
  }
422
436
  }
423
437
 
424
- export class InvalidJoinConditionRightTableError extends JoinError {
425
- constructor(tableAlias: string) {
438
+ export class InvalidJoinConditionRightSourceError extends JoinError {
439
+ constructor(sourceAlias: string) {
426
440
  super(
427
- `Invalid join condition: right expression does not refer to the joined table "${tableAlias}"`
441
+ `Invalid join condition: right expression does not refer to the joined source "${sourceAlias}"`
428
442
  )
429
443
  }
430
444
  }
@@ -563,3 +577,67 @@ export class CannotCombineEmptyExpressionListError extends QueryOptimizerError {
563
577
  super(`Cannot combine empty expression list`)
564
578
  }
565
579
  }
580
+
581
+ /**
582
+ * Internal error when the query optimizer fails to convert a WHERE clause to a collection filter.
583
+ */
584
+ export class WhereClauseConversionError extends QueryOptimizerError {
585
+ constructor(collectionId: string, alias: string) {
586
+ super(
587
+ `Failed to convert WHERE clause to collection filter for collection '${collectionId}' alias '${alias}'. This indicates a bug in the query optimization logic.`
588
+ )
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Error when a subscription cannot be found during lazy join processing.
594
+ * For subqueries, aliases may be remapped (e.g., 'activeUser' → 'user').
595
+ */
596
+ export class SubscriptionNotFoundError extends QueryCompilationError {
597
+ constructor(
598
+ resolvedAlias: string,
599
+ originalAlias: string,
600
+ collectionId: string,
601
+ availableAliases: Array<string>
602
+ ) {
603
+ super(
604
+ `Internal error: subscription for alias '${resolvedAlias}' (remapped from '${originalAlias}', collection '${collectionId}') is missing in join pipeline. Available aliases: ${availableAliases.join(`, `)}. This indicates a bug in alias tracking.`
605
+ )
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Error thrown when aggregate expressions are used outside of a GROUP BY context.
611
+ */
612
+ export class AggregateNotSupportedError extends QueryCompilationError {
613
+ constructor() {
614
+ super(
615
+ `Aggregate expressions are not supported in this context. Use GROUP BY clause for aggregates.`
616
+ )
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Internal error when the compiler returns aliases that don't have corresponding input streams.
622
+ * This should never happen since all aliases come from user declarations.
623
+ */
624
+ export class MissingAliasInputsError extends QueryCompilationError {
625
+ constructor(missingAliases: Array<string>) {
626
+ super(
627
+ `Internal error: compiler returned aliases without inputs: ${missingAliases.join(`, `)}. ` +
628
+ `This indicates a bug in query compilation. Please report this issue.`
629
+ )
630
+ }
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
 
@@ -107,7 +107,7 @@ export type SchemaFromSource<T extends Source> = Prettify<{
107
107
  * GetAliases - Extracts all table aliases available in a query context
108
108
  *
109
109
  * Simple utility type that returns the keys of the schema, representing
110
- * all table/collection aliases that can be referenced in the current query.
110
+ * all table/source aliases that can be referenced in the current query.
111
111
  */
112
112
  export type GetAliases<TContext extends Context> = keyof TContext[`schema`]
113
113