@tanstack/db 0.5.32 → 0.5.33
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.
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +1 -0
- package/dist/cjs/query/effect.cjs +602 -0
- package/dist/cjs/query/effect.cjs.map +1 -0
- package/dist/cjs/query/effect.d.cts +94 -0
- package/dist/cjs/query/live/collection-config-builder.cjs +5 -74
- package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
- package/dist/cjs/query/live/collection-subscriber.cjs +33 -100
- package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
- package/dist/cjs/query/live/collection-subscriber.d.cts +0 -1
- package/dist/cjs/query/live/utils.cjs +179 -0
- package/dist/cjs/query/live/utils.cjs.map +1 -0
- package/dist/cjs/query/live/utils.d.cts +109 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/query/effect.d.ts +94 -0
- package/dist/esm/query/effect.js +602 -0
- package/dist/esm/query/effect.js.map +1 -0
- package/dist/esm/query/live/collection-config-builder.js +1 -70
- package/dist/esm/query/live/collection-config-builder.js.map +1 -1
- package/dist/esm/query/live/collection-subscriber.d.ts +0 -1
- package/dist/esm/query/live/collection-subscriber.js +31 -98
- package/dist/esm/query/live/collection-subscriber.js.map +1 -1
- package/dist/esm/query/live/utils.d.ts +109 -0
- package/dist/esm/query/live/utils.js +179 -0
- package/dist/esm/query/live/utils.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +11 -0
- package/src/query/effect.ts +1119 -0
- package/src/query/live/collection-config-builder.ts +6 -132
- package/src/query/live/collection-subscriber.ts +40 -156
- package/src/query/live/utils.ts +356 -0
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
import { D2, output } from '@tanstack/db-ivm'
|
|
2
|
+
import { transactionScopedScheduler } from '../scheduler.js'
|
|
3
|
+
import { getActiveTransaction } from '../transactions.js'
|
|
4
|
+
import { compileQuery } from './compiler/index.js'
|
|
5
|
+
import {
|
|
6
|
+
normalizeExpressionPaths,
|
|
7
|
+
normalizeOrderByPaths,
|
|
8
|
+
} from './compiler/expressions.js'
|
|
9
|
+
import { getCollectionBuilder } from './live/collection-registry.js'
|
|
10
|
+
import {
|
|
11
|
+
buildQueryFromConfig,
|
|
12
|
+
computeOrderedLoadCursor,
|
|
13
|
+
computeSubscriptionOrderByHints,
|
|
14
|
+
extractCollectionAliases,
|
|
15
|
+
extractCollectionsFromQuery,
|
|
16
|
+
filterDuplicateInserts,
|
|
17
|
+
sendChangesToInput,
|
|
18
|
+
splitUpdates,
|
|
19
|
+
trackBiggestSentValue,
|
|
20
|
+
} from './live/utils.js'
|
|
21
|
+
import type { RootStreamBuilder } from '@tanstack/db-ivm'
|
|
22
|
+
import type { Collection } from '../collection/index.js'
|
|
23
|
+
import type { CollectionSubscription } from '../collection/subscription.js'
|
|
24
|
+
import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js'
|
|
25
|
+
import type { Context } from './builder/types.js'
|
|
26
|
+
import type { BasicExpression, QueryIR } from './ir.js'
|
|
27
|
+
import type { OrderByOptimizationInfo } from './compiler/order-by.js'
|
|
28
|
+
import type { ChangeMessage, KeyedStream, ResultStream } from '../types.js'
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Public Types
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Event types for query result deltas */
|
|
35
|
+
export type DeltaType = 'enter' | 'exit' | 'update'
|
|
36
|
+
|
|
37
|
+
/** Delta event emitted when a row enters, exits, or updates within a query result */
|
|
38
|
+
export type DeltaEvent<
|
|
39
|
+
TRow extends object = Record<string, unknown>,
|
|
40
|
+
TKey extends string | number = string | number,
|
|
41
|
+
> =
|
|
42
|
+
| {
|
|
43
|
+
type: 'enter'
|
|
44
|
+
key: TKey
|
|
45
|
+
/** Current value for the entering row */
|
|
46
|
+
value: TRow
|
|
47
|
+
metadata?: Record<string, unknown>
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
type: 'exit'
|
|
51
|
+
key: TKey
|
|
52
|
+
/** Current value for the exiting row */
|
|
53
|
+
value: TRow
|
|
54
|
+
metadata?: Record<string, unknown>
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
type: 'update'
|
|
58
|
+
key: TKey
|
|
59
|
+
/** Current value after the update */
|
|
60
|
+
value: TRow
|
|
61
|
+
/** Previous value before the batch */
|
|
62
|
+
previousValue: TRow
|
|
63
|
+
metadata?: Record<string, unknown>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Context passed to effect handlers */
|
|
67
|
+
export interface EffectContext {
|
|
68
|
+
/** ID of this effect (auto-generated if not provided) */
|
|
69
|
+
effectId: string
|
|
70
|
+
/** Aborted when effect.dispose() is called */
|
|
71
|
+
signal: AbortSignal
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Query input - can be a builder function or a prebuilt query */
|
|
75
|
+
export type EffectQueryInput<TContext extends Context> =
|
|
76
|
+
| ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
|
|
77
|
+
| QueryBuilder<TContext>
|
|
78
|
+
|
|
79
|
+
type EffectEventHandler<
|
|
80
|
+
TRow extends object = Record<string, unknown>,
|
|
81
|
+
TKey extends string | number = string | number,
|
|
82
|
+
> = (event: DeltaEvent<TRow, TKey>, ctx: EffectContext) => void | Promise<void>
|
|
83
|
+
|
|
84
|
+
type EffectBatchHandler<
|
|
85
|
+
TRow extends object = Record<string, unknown>,
|
|
86
|
+
TKey extends string | number = string | number,
|
|
87
|
+
> = (
|
|
88
|
+
events: Array<DeltaEvent<TRow, TKey>>,
|
|
89
|
+
ctx: EffectContext,
|
|
90
|
+
) => void | Promise<void>
|
|
91
|
+
|
|
92
|
+
/** Effect configuration */
|
|
93
|
+
export interface EffectConfig<
|
|
94
|
+
TRow extends object = Record<string, unknown>,
|
|
95
|
+
TKey extends string | number = string | number,
|
|
96
|
+
> {
|
|
97
|
+
/** Optional ID for debugging/tracing */
|
|
98
|
+
id?: string
|
|
99
|
+
|
|
100
|
+
/** Query to watch for deltas */
|
|
101
|
+
query: EffectQueryInput<any>
|
|
102
|
+
|
|
103
|
+
/** Called once for each row entering the query result */
|
|
104
|
+
onEnter?: EffectEventHandler<TRow, TKey>
|
|
105
|
+
|
|
106
|
+
/** Called once for each row updating within the query result */
|
|
107
|
+
onUpdate?: EffectEventHandler<TRow, TKey>
|
|
108
|
+
|
|
109
|
+
/** Called once for each row exiting the query result */
|
|
110
|
+
onExit?: EffectEventHandler<TRow, TKey>
|
|
111
|
+
|
|
112
|
+
/** Called once per graph run with all delta events from that batch */
|
|
113
|
+
onBatch?: EffectBatchHandler<TRow, TKey>
|
|
114
|
+
|
|
115
|
+
/** Error handler for exceptions thrown by effect callbacks */
|
|
116
|
+
onError?: (error: Error, event: DeltaEvent<TRow, TKey>) => void
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Called when a source collection enters an error or cleaned-up state.
|
|
120
|
+
* The effect is automatically disposed after this callback fires.
|
|
121
|
+
* If not provided, the error is logged to console.error.
|
|
122
|
+
*/
|
|
123
|
+
onSourceError?: (error: Error) => void
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Skip deltas during initial collection load.
|
|
127
|
+
* Defaults to false (process all deltas including initial sync).
|
|
128
|
+
* Set to true for effects that should only process new changes.
|
|
129
|
+
*/
|
|
130
|
+
skipInitial?: boolean
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Handle returned by createEffect */
|
|
134
|
+
export interface Effect {
|
|
135
|
+
/** Dispose the effect. Returns a promise that resolves when in-flight handlers complete. */
|
|
136
|
+
dispose: () => Promise<void>
|
|
137
|
+
/** Whether this effect has been disposed */
|
|
138
|
+
readonly disposed: boolean
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Internal Types
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/** Accumulated changes for a single key within a graph run */
|
|
146
|
+
interface EffectChanges<T> {
|
|
147
|
+
deletes: number
|
|
148
|
+
inserts: number
|
|
149
|
+
/** Value from the latest insert (the newest/current value) */
|
|
150
|
+
insertValue?: T
|
|
151
|
+
/** Value from the first delete (the oldest/previous value before the batch) */
|
|
152
|
+
deleteValue?: T
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Global Counter
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
let effectCounter = 0
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// createEffect
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Creates a reactive effect that fires handlers when rows enter, exit, or
|
|
167
|
+
* update within a query result. Effects process deltas only — they do not
|
|
168
|
+
* maintain or require the full materialised query result.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```typescript
|
|
172
|
+
* const effect = createEffect({
|
|
173
|
+
* query: (q) => q.from({ msg: messagesCollection })
|
|
174
|
+
* .where(({ msg }) => eq(msg.role, 'user')),
|
|
175
|
+
* onEnter: async (event) => {
|
|
176
|
+
* await generateResponse(event.value)
|
|
177
|
+
* },
|
|
178
|
+
* })
|
|
179
|
+
*
|
|
180
|
+
* // Later: stop the effect
|
|
181
|
+
* await effect.dispose()
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export function createEffect<
|
|
185
|
+
TRow extends object = Record<string, unknown>,
|
|
186
|
+
TKey extends string | number = string | number,
|
|
187
|
+
>(config: EffectConfig<TRow, TKey>): Effect {
|
|
188
|
+
const id = config.id ?? `live-query-effect-${++effectCounter}`
|
|
189
|
+
|
|
190
|
+
// AbortController for signalling disposal to handlers
|
|
191
|
+
const abortController = new AbortController()
|
|
192
|
+
|
|
193
|
+
const ctx: EffectContext = {
|
|
194
|
+
effectId: id,
|
|
195
|
+
signal: abortController.signal,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Track in-flight async handler promises so dispose() can await them
|
|
199
|
+
const inFlightHandlers = new Set<Promise<void>>()
|
|
200
|
+
let disposed = false
|
|
201
|
+
|
|
202
|
+
// Callback invoked by the pipeline runner with each batch of delta events
|
|
203
|
+
const onBatchProcessed = (events: Array<DeltaEvent<TRow, TKey>>) => {
|
|
204
|
+
if (disposed) return
|
|
205
|
+
if (events.length === 0) return
|
|
206
|
+
|
|
207
|
+
// Batch handler
|
|
208
|
+
if (config.onBatch) {
|
|
209
|
+
try {
|
|
210
|
+
const result = config.onBatch(events, ctx)
|
|
211
|
+
if (result instanceof Promise) {
|
|
212
|
+
const tracked = result.catch((error) => {
|
|
213
|
+
reportError(error, events[0]!, config.onError)
|
|
214
|
+
})
|
|
215
|
+
trackPromise(tracked, inFlightHandlers)
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// For batch handler errors, report with first event as context
|
|
219
|
+
reportError(error, events[0]!, config.onError)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const event of events) {
|
|
224
|
+
if (abortController.signal.aborted) break
|
|
225
|
+
|
|
226
|
+
const handler = getHandlerForEvent(event, config)
|
|
227
|
+
if (!handler) continue
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const result = handler(event, ctx)
|
|
231
|
+
if (result instanceof Promise) {
|
|
232
|
+
const tracked = result.catch((error) => {
|
|
233
|
+
reportError(error, event, config.onError)
|
|
234
|
+
})
|
|
235
|
+
trackPromise(tracked, inFlightHandlers)
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
reportError(error, event, config.onError)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The dispose function is referenced by both the returned Effect object
|
|
244
|
+
// and the onSourceError callback, so we define it first.
|
|
245
|
+
const dispose = async () => {
|
|
246
|
+
if (disposed) return
|
|
247
|
+
disposed = true
|
|
248
|
+
|
|
249
|
+
// Abort signal for in-flight handlers
|
|
250
|
+
abortController.abort()
|
|
251
|
+
|
|
252
|
+
// Tear down the pipeline (unsubscribe from sources, etc.)
|
|
253
|
+
runner.dispose()
|
|
254
|
+
|
|
255
|
+
// Wait for any in-flight async handlers to settle
|
|
256
|
+
if (inFlightHandlers.size > 0) {
|
|
257
|
+
await Promise.allSettled([...inFlightHandlers])
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Create and start the pipeline
|
|
262
|
+
const runner = new EffectPipelineRunner<TRow, TKey>({
|
|
263
|
+
query: config.query,
|
|
264
|
+
skipInitial: config.skipInitial ?? false,
|
|
265
|
+
onBatchProcessed,
|
|
266
|
+
onSourceError: (error: Error) => {
|
|
267
|
+
if (disposed) return
|
|
268
|
+
|
|
269
|
+
if (config.onSourceError) {
|
|
270
|
+
try {
|
|
271
|
+
config.onSourceError(error)
|
|
272
|
+
} catch (callbackError) {
|
|
273
|
+
console.error(
|
|
274
|
+
`[Effect '${id}'] onSourceError callback threw:`,
|
|
275
|
+
callbackError,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
console.error(`[Effect '${id}'] ${error.message}. Disposing effect.`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Auto-dispose — the effect can no longer function
|
|
283
|
+
dispose()
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
runner.start()
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
dispose,
|
|
290
|
+
get disposed() {
|
|
291
|
+
return disposed
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// EffectPipelineRunner
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
interface EffectPipelineRunnerConfig<
|
|
301
|
+
TRow extends object,
|
|
302
|
+
TKey extends string | number,
|
|
303
|
+
> {
|
|
304
|
+
query: EffectQueryInput<any>
|
|
305
|
+
skipInitial: boolean
|
|
306
|
+
onBatchProcessed: (events: Array<DeltaEvent<TRow, TKey>>) => void
|
|
307
|
+
/** Called when a source collection enters error or cleaned-up state */
|
|
308
|
+
onSourceError: (error: Error) => void
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Internal class that manages a D2 pipeline for effect delta processing.
|
|
313
|
+
*
|
|
314
|
+
* Sets up the IVM graph, subscribes to source collections, runs the graph
|
|
315
|
+
* when changes arrive, and classifies output multiplicities into DeltaEvents.
|
|
316
|
+
*
|
|
317
|
+
* Unlike CollectionConfigBuilder, this does NOT:
|
|
318
|
+
* - Create or write to a collection (no materialisation)
|
|
319
|
+
* - Manage ordering, windowing, or lazy loading
|
|
320
|
+
*/
|
|
321
|
+
class EffectPipelineRunner<TRow extends object, TKey extends string | number> {
|
|
322
|
+
private readonly query: QueryIR
|
|
323
|
+
private readonly collections: Record<string, Collection<any, any, any>>
|
|
324
|
+
private readonly collectionByAlias: Record<string, Collection<any, any, any>>
|
|
325
|
+
|
|
326
|
+
private graph: D2 | undefined
|
|
327
|
+
private inputs: Record<string, RootStreamBuilder<unknown>> | undefined
|
|
328
|
+
private pipeline: ResultStream | undefined
|
|
329
|
+
private sourceWhereClauses: Map<string, BasicExpression<boolean>> | undefined
|
|
330
|
+
private compiledAliasToCollectionId: Record<string, string> = {}
|
|
331
|
+
|
|
332
|
+
// Mutable objects passed to compileQuery by reference.
|
|
333
|
+
// The join compiler captures these references and reads them later when
|
|
334
|
+
// the graph runs, so they must be populated before the first graph run.
|
|
335
|
+
private readonly subscriptions: Record<string, CollectionSubscription> = {}
|
|
336
|
+
private readonly lazySourcesCallbacks: Record<string, any> = {}
|
|
337
|
+
private readonly lazySources = new Set<string>()
|
|
338
|
+
// OrderBy optimization info populated by the compiler when limit is present
|
|
339
|
+
private readonly optimizableOrderByCollections: Record<
|
|
340
|
+
string,
|
|
341
|
+
OrderByOptimizationInfo
|
|
342
|
+
> = {}
|
|
343
|
+
|
|
344
|
+
// Ordered subscription state for cursor-based loading
|
|
345
|
+
private readonly biggestSentValue = new Map<string, any>()
|
|
346
|
+
private readonly lastLoadRequestKey = new Map<string, string>()
|
|
347
|
+
private pendingOrderedLoadPromise: Promise<void> | undefined
|
|
348
|
+
|
|
349
|
+
// Subscription management
|
|
350
|
+
private readonly unsubscribeCallbacks = new Set<() => void>()
|
|
351
|
+
// Duplicate insert prevention per alias
|
|
352
|
+
private readonly sentToD2KeysByAlias = new Map<string, Set<string | number>>()
|
|
353
|
+
|
|
354
|
+
// Output accumulator
|
|
355
|
+
private pendingChanges: Map<unknown, EffectChanges<TRow>> = new Map()
|
|
356
|
+
|
|
357
|
+
// skipInitial state
|
|
358
|
+
private readonly skipInitial: boolean
|
|
359
|
+
private initialLoadComplete = false
|
|
360
|
+
|
|
361
|
+
// Scheduler integration
|
|
362
|
+
private subscribedToAllCollections = false
|
|
363
|
+
private readonly builderDependencies = new Set<unknown>()
|
|
364
|
+
private readonly aliasDependencies: Record<string, Array<unknown>> = {}
|
|
365
|
+
|
|
366
|
+
// Reentrance guard
|
|
367
|
+
private isGraphRunning = false
|
|
368
|
+
private disposed = false
|
|
369
|
+
// When dispose() is called mid-graph-run, defer heavy cleanup until the run completes
|
|
370
|
+
private deferredCleanup = false
|
|
371
|
+
|
|
372
|
+
private readonly onBatchProcessed: (
|
|
373
|
+
events: Array<DeltaEvent<TRow, TKey>>,
|
|
374
|
+
) => void
|
|
375
|
+
private readonly onSourceError: (error: Error) => void
|
|
376
|
+
|
|
377
|
+
constructor(config: EffectPipelineRunnerConfig<TRow, TKey>) {
|
|
378
|
+
this.skipInitial = config.skipInitial
|
|
379
|
+
this.onBatchProcessed = config.onBatchProcessed
|
|
380
|
+
this.onSourceError = config.onSourceError
|
|
381
|
+
|
|
382
|
+
// Parse query
|
|
383
|
+
this.query = buildQueryFromConfig({ query: config.query })
|
|
384
|
+
|
|
385
|
+
// Extract source collections
|
|
386
|
+
this.collections = extractCollectionsFromQuery(this.query)
|
|
387
|
+
const aliasesById = extractCollectionAliases(this.query)
|
|
388
|
+
|
|
389
|
+
// Build alias → collection map
|
|
390
|
+
this.collectionByAlias = {}
|
|
391
|
+
for (const [collectionId, aliases] of aliasesById.entries()) {
|
|
392
|
+
const collection = this.collections[collectionId]
|
|
393
|
+
if (!collection) continue
|
|
394
|
+
for (const alias of aliases) {
|
|
395
|
+
this.collectionByAlias[alias] = collection
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Compile the pipeline
|
|
400
|
+
this.compilePipeline()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Compile the D2 graph and query pipeline */
|
|
404
|
+
private compilePipeline(): void {
|
|
405
|
+
this.graph = new D2()
|
|
406
|
+
this.inputs = Object.fromEntries(
|
|
407
|
+
Object.keys(this.collectionByAlias).map((alias) => [
|
|
408
|
+
alias,
|
|
409
|
+
this.graph!.newInput<any>(),
|
|
410
|
+
]),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
const compilation = compileQuery(
|
|
414
|
+
this.query,
|
|
415
|
+
this.inputs as Record<string, KeyedStream>,
|
|
416
|
+
this.collections,
|
|
417
|
+
// These mutable objects are captured by reference. The join compiler
|
|
418
|
+
// reads them later when the graph runs, so they must be populated
|
|
419
|
+
// (in start()) before the first graph run.
|
|
420
|
+
this.subscriptions,
|
|
421
|
+
this.lazySourcesCallbacks,
|
|
422
|
+
this.lazySources,
|
|
423
|
+
this.optimizableOrderByCollections,
|
|
424
|
+
() => {}, // setWindowFn (no-op — effects don't paginate)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
this.pipeline = compilation.pipeline
|
|
428
|
+
this.sourceWhereClauses = compilation.sourceWhereClauses
|
|
429
|
+
this.compiledAliasToCollectionId = compilation.aliasToCollectionId
|
|
430
|
+
|
|
431
|
+
// Attach the output operator that accumulates changes
|
|
432
|
+
this.pipeline.pipe(
|
|
433
|
+
output((data) => {
|
|
434
|
+
const messages = data.getInner()
|
|
435
|
+
messages.reduce(accumulateEffectChanges<TRow>, this.pendingChanges)
|
|
436
|
+
}),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
this.graph.finalize()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Subscribe to source collections and start processing */
|
|
443
|
+
start(): void {
|
|
444
|
+
// Use compiled aliases as the source of truth
|
|
445
|
+
const compiledAliases = Object.entries(this.compiledAliasToCollectionId)
|
|
446
|
+
if (compiledAliases.length === 0) {
|
|
447
|
+
// Nothing to subscribe to
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// When not skipping initial, we always process events immediately
|
|
452
|
+
if (!this.skipInitial) {
|
|
453
|
+
this.initialLoadComplete = true
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// We need to defer initial data processing until ALL subscriptions are
|
|
457
|
+
// created, because join pipelines look up subscriptions by alias during
|
|
458
|
+
// the graph run. If we run the graph while some aliases are still missing,
|
|
459
|
+
// the join tap operator will throw.
|
|
460
|
+
//
|
|
461
|
+
// Strategy: subscribe to each collection but buffer incoming changes.
|
|
462
|
+
// After all subscriptions are in place, flush the buffers and switch to
|
|
463
|
+
// direct processing mode.
|
|
464
|
+
|
|
465
|
+
const pendingBuffers = new Map<
|
|
466
|
+
string,
|
|
467
|
+
Array<Array<ChangeMessage<any, string | number>>>
|
|
468
|
+
>()
|
|
469
|
+
|
|
470
|
+
for (const [alias, collectionId] of compiledAliases) {
|
|
471
|
+
const collection =
|
|
472
|
+
this.collectionByAlias[alias] ?? this.collections[collectionId]!
|
|
473
|
+
|
|
474
|
+
// Initialise per-alias duplicate tracking
|
|
475
|
+
this.sentToD2KeysByAlias.set(alias, new Set())
|
|
476
|
+
|
|
477
|
+
// Discover dependencies: if source collection is itself a live query
|
|
478
|
+
// collection, its builder must run first during transaction flushes.
|
|
479
|
+
const dependencyBuilder = getCollectionBuilder(collection)
|
|
480
|
+
if (dependencyBuilder) {
|
|
481
|
+
this.aliasDependencies[alias] = [dependencyBuilder]
|
|
482
|
+
this.builderDependencies.add(dependencyBuilder)
|
|
483
|
+
} else {
|
|
484
|
+
this.aliasDependencies[alias] = []
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Get where clause for this alias (for predicate push-down)
|
|
488
|
+
const whereClause = this.sourceWhereClauses?.get(alias)
|
|
489
|
+
const whereExpression = whereClause
|
|
490
|
+
? normalizeExpressionPaths(whereClause, alias)
|
|
491
|
+
: undefined
|
|
492
|
+
|
|
493
|
+
// Initialise buffer for this alias
|
|
494
|
+
const buffer: Array<Array<ChangeMessage<any, string | number>>> = []
|
|
495
|
+
pendingBuffers.set(alias, buffer)
|
|
496
|
+
|
|
497
|
+
// Lazy aliases (marked by the join compiler) should NOT load initial state
|
|
498
|
+
// eagerly — the join tap operator will load exactly the rows it needs on demand.
|
|
499
|
+
// For on-demand collections, eager loading would trigger a full server fetch
|
|
500
|
+
// for data that should be lazily loaded based on join keys.
|
|
501
|
+
const isLazy = this.lazySources.has(alias)
|
|
502
|
+
|
|
503
|
+
// Check if this alias has orderBy optimization (cursor-based loading)
|
|
504
|
+
const orderByInfo = this.getOrderByInfoForAlias(alias)
|
|
505
|
+
|
|
506
|
+
// Build the change callback — for ordered aliases, split updates into
|
|
507
|
+
// delete+insert and track the biggest sent value for cursor positioning.
|
|
508
|
+
const changeCallback = orderByInfo
|
|
509
|
+
? (changes: Array<ChangeMessage<any, string | number>>) => {
|
|
510
|
+
if (pendingBuffers.has(alias)) {
|
|
511
|
+
pendingBuffers.get(alias)!.push(changes)
|
|
512
|
+
} else {
|
|
513
|
+
this.trackSentValues(alias, changes, orderByInfo.comparator)
|
|
514
|
+
const split = [...splitUpdates(changes)]
|
|
515
|
+
this.handleSourceChanges(alias, split)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
: (changes: Array<ChangeMessage<any, string | number>>) => {
|
|
519
|
+
if (pendingBuffers.has(alias)) {
|
|
520
|
+
pendingBuffers.get(alias)!.push(changes)
|
|
521
|
+
} else {
|
|
522
|
+
this.handleSourceChanges(alias, changes)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Determine subscription options based on ordered vs unordered path
|
|
527
|
+
const subscriptionOptions = this.buildSubscriptionOptions(
|
|
528
|
+
alias,
|
|
529
|
+
isLazy,
|
|
530
|
+
orderByInfo,
|
|
531
|
+
whereExpression,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
// Subscribe to source changes
|
|
535
|
+
const subscription = collection.subscribeChanges(
|
|
536
|
+
changeCallback,
|
|
537
|
+
subscriptionOptions,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
// Store subscription immediately so the join compiler can find it
|
|
541
|
+
this.subscriptions[alias] = subscription
|
|
542
|
+
|
|
543
|
+
// For ordered aliases with an index, trigger the initial limited snapshot.
|
|
544
|
+
// This loads only the top N rows rather than the entire collection.
|
|
545
|
+
if (orderByInfo) {
|
|
546
|
+
this.requestInitialOrderedSnapshot(alias, orderByInfo, subscription)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
this.unsubscribeCallbacks.add(() => {
|
|
550
|
+
subscription.unsubscribe()
|
|
551
|
+
delete this.subscriptions[alias]
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
// Listen for status changes on source collections
|
|
555
|
+
const statusUnsubscribe = collection.on(`status:change`, (event) => {
|
|
556
|
+
if (this.disposed) return
|
|
557
|
+
|
|
558
|
+
const { status } = event
|
|
559
|
+
|
|
560
|
+
// Source entered error state — effect can no longer function
|
|
561
|
+
if (status === `error`) {
|
|
562
|
+
this.onSourceError(
|
|
563
|
+
new Error(
|
|
564
|
+
`Source collection '${collectionId}' entered error state`,
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Source was manually cleaned up — effect can no longer function
|
|
571
|
+
if (status === `cleaned-up`) {
|
|
572
|
+
this.onSourceError(
|
|
573
|
+
new Error(
|
|
574
|
+
`Source collection '${collectionId}' was cleaned up while effect depends on it`,
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Track source readiness for skipInitial
|
|
581
|
+
if (
|
|
582
|
+
this.skipInitial &&
|
|
583
|
+
!this.initialLoadComplete &&
|
|
584
|
+
this.checkAllCollectionsReady()
|
|
585
|
+
) {
|
|
586
|
+
this.initialLoadComplete = true
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
this.unsubscribeCallbacks.add(statusUnsubscribe)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Mark as subscribed so the graph can start running
|
|
593
|
+
this.subscribedToAllCollections = true
|
|
594
|
+
|
|
595
|
+
// All subscriptions are now in place. Flush buffered changes by sending
|
|
596
|
+
// data to D2 inputs first (without running the graph), then run the graph
|
|
597
|
+
// once. This prevents intermediate join states from producing duplicates.
|
|
598
|
+
//
|
|
599
|
+
// We remove each alias from pendingBuffers *before* draining, which
|
|
600
|
+
// switches that alias to direct-processing mode. Any new callbacks that
|
|
601
|
+
// fire during the drain (e.g. from requestLimitedSnapshot) will go
|
|
602
|
+
// through handleSourceChanges directly instead of being lost.
|
|
603
|
+
for (const [alias] of pendingBuffers) {
|
|
604
|
+
const buffer = pendingBuffers.get(alias)!
|
|
605
|
+
pendingBuffers.delete(alias)
|
|
606
|
+
|
|
607
|
+
const orderByInfo = this.getOrderByInfoForAlias(alias)
|
|
608
|
+
|
|
609
|
+
// Drain all buffered batches. Since we deleted the alias from
|
|
610
|
+
// pendingBuffers above, any new changes arriving during drain go
|
|
611
|
+
// through handleSourceChanges directly (not back into this buffer).
|
|
612
|
+
for (const changes of buffer) {
|
|
613
|
+
if (orderByInfo) {
|
|
614
|
+
this.trackSentValues(alias, changes, orderByInfo.comparator)
|
|
615
|
+
const split = [...splitUpdates(changes)]
|
|
616
|
+
this.sendChangesToD2(alias, split)
|
|
617
|
+
} else {
|
|
618
|
+
this.sendChangesToD2(alias, changes)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Initial graph run to process any synchronously-available data.
|
|
624
|
+
// For skipInitial, this run's output is discarded (initialLoadComplete is still false).
|
|
625
|
+
this.runGraph()
|
|
626
|
+
|
|
627
|
+
// After the initial graph run, if all sources are ready,
|
|
628
|
+
// mark initial load as complete so future events are processed.
|
|
629
|
+
if (this.skipInitial && !this.initialLoadComplete) {
|
|
630
|
+
if (this.checkAllCollectionsReady()) {
|
|
631
|
+
this.initialLoadComplete = true
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Handle incoming changes from a source collection */
|
|
637
|
+
private handleSourceChanges(
|
|
638
|
+
alias: string,
|
|
639
|
+
changes: Array<ChangeMessage<any, string | number>>,
|
|
640
|
+
): void {
|
|
641
|
+
this.sendChangesToD2(alias, changes)
|
|
642
|
+
this.scheduleGraphRun(alias)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Schedule a graph run via the transaction-scoped scheduler.
|
|
647
|
+
*
|
|
648
|
+
* When called within a transaction, the run is deferred until the
|
|
649
|
+
* transaction flushes, coalescing multiple changes into a single graph
|
|
650
|
+
* execution. Without a transaction, the graph runs immediately.
|
|
651
|
+
*
|
|
652
|
+
* Dependencies are discovered from source collections that are themselves
|
|
653
|
+
* live query collections, ensuring parent queries run before effects.
|
|
654
|
+
*/
|
|
655
|
+
private scheduleGraphRun(alias?: string): void {
|
|
656
|
+
const contextId = getActiveTransaction()?.id
|
|
657
|
+
|
|
658
|
+
// Collect dependencies for this schedule call
|
|
659
|
+
const deps = new Set(this.builderDependencies)
|
|
660
|
+
if (alias) {
|
|
661
|
+
const aliasDeps = this.aliasDependencies[alias]
|
|
662
|
+
if (aliasDeps) {
|
|
663
|
+
for (const dep of aliasDeps) {
|
|
664
|
+
deps.add(dep)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Ensure dependent builders are scheduled in this context so that
|
|
670
|
+
// dependency edges always point to a real job.
|
|
671
|
+
if (contextId) {
|
|
672
|
+
for (const dep of deps) {
|
|
673
|
+
if (
|
|
674
|
+
typeof dep === `object` &&
|
|
675
|
+
dep !== null &&
|
|
676
|
+
`scheduleGraphRun` in dep &&
|
|
677
|
+
typeof (dep as any).scheduleGraphRun === `function`
|
|
678
|
+
) {
|
|
679
|
+
;(dep as any).scheduleGraphRun(undefined, { contextId })
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
transactionScopedScheduler.schedule({
|
|
685
|
+
contextId,
|
|
686
|
+
jobId: this,
|
|
687
|
+
dependencies: deps,
|
|
688
|
+
run: () => this.executeScheduledGraphRun(),
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Called by the scheduler when dependencies are satisfied.
|
|
694
|
+
* Checks that the effect is still active before running.
|
|
695
|
+
*/
|
|
696
|
+
private executeScheduledGraphRun(): void {
|
|
697
|
+
if (this.disposed || !this.subscribedToAllCollections) return
|
|
698
|
+
this.runGraph()
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Send changes to the D2 input for the given alias.
|
|
703
|
+
* Returns the number of multiset entries sent.
|
|
704
|
+
*/
|
|
705
|
+
private sendChangesToD2(
|
|
706
|
+
alias: string,
|
|
707
|
+
changes: Array<ChangeMessage<any, string | number>>,
|
|
708
|
+
): number {
|
|
709
|
+
if (this.disposed || !this.inputs || !this.graph) return 0
|
|
710
|
+
|
|
711
|
+
const input = this.inputs[alias]
|
|
712
|
+
if (!input) return 0
|
|
713
|
+
|
|
714
|
+
const collection = this.collectionByAlias[alias]
|
|
715
|
+
if (!collection) return 0
|
|
716
|
+
|
|
717
|
+
// Filter duplicates per alias
|
|
718
|
+
const sentKeys = this.sentToD2KeysByAlias.get(alias)!
|
|
719
|
+
const filtered = filterDuplicateInserts(changes, sentKeys)
|
|
720
|
+
|
|
721
|
+
return sendChangesToInput(input, filtered, collection.config.getKey)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Run the D2 graph until quiescence, then emit accumulated events once.
|
|
726
|
+
*
|
|
727
|
+
* All output across the entire while-loop is accumulated into a single
|
|
728
|
+
* batch so that users see one `onBatchProcessed` invocation per scheduler
|
|
729
|
+
* run, even when ordered loading causes multiple graph steps.
|
|
730
|
+
*/
|
|
731
|
+
private runGraph(): void {
|
|
732
|
+
if (this.isGraphRunning || this.disposed || !this.graph) return
|
|
733
|
+
|
|
734
|
+
this.isGraphRunning = true
|
|
735
|
+
try {
|
|
736
|
+
while (this.graph.pendingWork()) {
|
|
737
|
+
this.graph.run()
|
|
738
|
+
// A handler (via onBatchProcessed) or source error callback may have
|
|
739
|
+
// called dispose() during graph.run(). Stop early to avoid operating
|
|
740
|
+
// on stale state. TS narrows disposed to false from the guard above
|
|
741
|
+
// but it can change during graph.run() via callbacks.
|
|
742
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
743
|
+
if (this.disposed) break
|
|
744
|
+
// After each step, check if ordered queries need more data.
|
|
745
|
+
// loadMoreIfNeeded may send data to D2 inputs (via requestLimitedSnapshot),
|
|
746
|
+
// causing pendingWork() to return true for the next iteration.
|
|
747
|
+
this.loadMoreIfNeeded()
|
|
748
|
+
}
|
|
749
|
+
// Emit all accumulated events once the graph reaches quiescence
|
|
750
|
+
this.flushPendingChanges()
|
|
751
|
+
} finally {
|
|
752
|
+
this.isGraphRunning = false
|
|
753
|
+
// If dispose() was called during this graph run, it deferred the heavy
|
|
754
|
+
// cleanup (clearing graph/inputs/pipeline) to avoid nulling references
|
|
755
|
+
// mid-loop. Complete that cleanup now.
|
|
756
|
+
if (this.deferredCleanup) {
|
|
757
|
+
this.deferredCleanup = false
|
|
758
|
+
this.finalCleanup()
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Classify accumulated changes into DeltaEvents and invoke the callback */
|
|
764
|
+
private flushPendingChanges(): void {
|
|
765
|
+
if (this.pendingChanges.size === 0) return
|
|
766
|
+
|
|
767
|
+
// If skipInitial and initial load isn't complete yet, discard
|
|
768
|
+
if (this.skipInitial && !this.initialLoadComplete) {
|
|
769
|
+
this.pendingChanges = new Map()
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const events: Array<DeltaEvent<TRow, TKey>> = []
|
|
774
|
+
|
|
775
|
+
for (const [key, changes] of this.pendingChanges) {
|
|
776
|
+
const event = classifyDelta<TRow, TKey>(key as TKey, changes)
|
|
777
|
+
if (event) {
|
|
778
|
+
events.push(event)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
this.pendingChanges = new Map()
|
|
783
|
+
|
|
784
|
+
if (events.length > 0) {
|
|
785
|
+
this.onBatchProcessed(events)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Check if all source collections are in the ready state */
|
|
790
|
+
private checkAllCollectionsReady(): boolean {
|
|
791
|
+
return Object.values(this.collections).every((collection) =>
|
|
792
|
+
collection.isReady(),
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Build subscription options for an alias based on whether it uses ordered
|
|
798
|
+
* loading, is lazy, or should pass orderBy/limit hints.
|
|
799
|
+
*/
|
|
800
|
+
private buildSubscriptionOptions(
|
|
801
|
+
alias: string,
|
|
802
|
+
isLazy: boolean,
|
|
803
|
+
orderByInfo: OrderByOptimizationInfo | undefined,
|
|
804
|
+
whereExpression: BasicExpression<boolean> | undefined,
|
|
805
|
+
): {
|
|
806
|
+
includeInitialState?: boolean
|
|
807
|
+
whereExpression?: BasicExpression<boolean>
|
|
808
|
+
orderBy?: any
|
|
809
|
+
limit?: number
|
|
810
|
+
} {
|
|
811
|
+
// Ordered aliases explicitly disable initial state — data is loaded
|
|
812
|
+
// via requestLimitedSnapshot/requestSnapshot after subscription setup.
|
|
813
|
+
if (orderByInfo) {
|
|
814
|
+
return { includeInitialState: false, whereExpression }
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const includeInitialState = !isLazy
|
|
818
|
+
|
|
819
|
+
// For unordered subscriptions, pass orderBy/limit hints so on-demand
|
|
820
|
+
// collections can optimise server-side fetching.
|
|
821
|
+
const hints = computeSubscriptionOrderByHints(this.query, alias)
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
includeInitialState,
|
|
825
|
+
whereExpression,
|
|
826
|
+
...(hints.orderBy ? { orderBy: hints.orderBy } : {}),
|
|
827
|
+
...(hints.limit !== undefined ? { limit: hints.limit } : {}),
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Request the initial ordered snapshot for an alias.
|
|
833
|
+
* Uses requestLimitedSnapshot (index-based cursor) or requestSnapshot
|
|
834
|
+
* (full load with limit) depending on whether an index is available.
|
|
835
|
+
*/
|
|
836
|
+
private requestInitialOrderedSnapshot(
|
|
837
|
+
alias: string,
|
|
838
|
+
orderByInfo: OrderByOptimizationInfo,
|
|
839
|
+
subscription: CollectionSubscription,
|
|
840
|
+
): void {
|
|
841
|
+
const { orderBy, offset, limit, index } = orderByInfo
|
|
842
|
+
const normalizedOrderBy = normalizeOrderByPaths(orderBy, alias)
|
|
843
|
+
|
|
844
|
+
if (index) {
|
|
845
|
+
subscription.setOrderByIndex(index)
|
|
846
|
+
subscription.requestLimitedSnapshot({
|
|
847
|
+
limit: offset + limit,
|
|
848
|
+
orderBy: normalizedOrderBy,
|
|
849
|
+
trackLoadSubsetPromise: false,
|
|
850
|
+
})
|
|
851
|
+
} else {
|
|
852
|
+
subscription.requestSnapshot({
|
|
853
|
+
orderBy: normalizedOrderBy,
|
|
854
|
+
limit: offset + limit,
|
|
855
|
+
trackLoadSubsetPromise: false,
|
|
856
|
+
})
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Get orderBy optimization info for a given alias.
|
|
862
|
+
* Returns undefined if no optimization exists for this alias.
|
|
863
|
+
*/
|
|
864
|
+
private getOrderByInfoForAlias(
|
|
865
|
+
alias: string,
|
|
866
|
+
): OrderByOptimizationInfo | undefined {
|
|
867
|
+
// optimizableOrderByCollections is keyed by collection ID
|
|
868
|
+
const collectionId = this.compiledAliasToCollectionId[alias]
|
|
869
|
+
if (!collectionId) return undefined
|
|
870
|
+
|
|
871
|
+
const info = this.optimizableOrderByCollections[collectionId]
|
|
872
|
+
if (info && info.alias === alias) {
|
|
873
|
+
return info
|
|
874
|
+
}
|
|
875
|
+
return undefined
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* After each graph run step, check if any ordered query's topK operator
|
|
880
|
+
* needs more data. If so, load more rows via requestLimitedSnapshot.
|
|
881
|
+
*/
|
|
882
|
+
private loadMoreIfNeeded(): void {
|
|
883
|
+
for (const [, orderByInfo] of Object.entries(
|
|
884
|
+
this.optimizableOrderByCollections,
|
|
885
|
+
)) {
|
|
886
|
+
if (!orderByInfo.dataNeeded) continue
|
|
887
|
+
|
|
888
|
+
if (this.pendingOrderedLoadPromise) {
|
|
889
|
+
// Wait for in-flight loads to complete before requesting more
|
|
890
|
+
continue
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const n = orderByInfo.dataNeeded()
|
|
894
|
+
if (n > 0) {
|
|
895
|
+
this.loadNextItems(orderByInfo, n)
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Load n more items from the source collection, starting from the cursor
|
|
902
|
+
* position (the biggest value sent so far).
|
|
903
|
+
*/
|
|
904
|
+
private loadNextItems(orderByInfo: OrderByOptimizationInfo, n: number): void {
|
|
905
|
+
const { alias } = orderByInfo
|
|
906
|
+
const subscription = this.subscriptions[alias]
|
|
907
|
+
if (!subscription) return
|
|
908
|
+
|
|
909
|
+
const cursor = computeOrderedLoadCursor(
|
|
910
|
+
orderByInfo,
|
|
911
|
+
this.biggestSentValue.get(alias),
|
|
912
|
+
this.lastLoadRequestKey.get(alias),
|
|
913
|
+
alias,
|
|
914
|
+
n,
|
|
915
|
+
)
|
|
916
|
+
if (!cursor) return // Duplicate request — skip
|
|
917
|
+
|
|
918
|
+
this.lastLoadRequestKey.set(alias, cursor.loadRequestKey)
|
|
919
|
+
|
|
920
|
+
subscription.requestLimitedSnapshot({
|
|
921
|
+
orderBy: cursor.normalizedOrderBy,
|
|
922
|
+
limit: n,
|
|
923
|
+
minValues: cursor.minValues,
|
|
924
|
+
trackLoadSubsetPromise: false,
|
|
925
|
+
onLoadSubsetResult: (loadResult: Promise<void> | true) => {
|
|
926
|
+
// Track in-flight load to prevent redundant concurrent requests
|
|
927
|
+
if (loadResult instanceof Promise) {
|
|
928
|
+
this.pendingOrderedLoadPromise = loadResult
|
|
929
|
+
loadResult.finally(() => {
|
|
930
|
+
if (this.pendingOrderedLoadPromise === loadResult) {
|
|
931
|
+
this.pendingOrderedLoadPromise = undefined
|
|
932
|
+
}
|
|
933
|
+
})
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Track the biggest value sent for a given ordered alias.
|
|
941
|
+
* Used for cursor-based pagination in loadNextItems.
|
|
942
|
+
*/
|
|
943
|
+
private trackSentValues(
|
|
944
|
+
alias: string,
|
|
945
|
+
changes: Array<ChangeMessage<any, string | number>>,
|
|
946
|
+
comparator: (a: any, b: any) => number,
|
|
947
|
+
): void {
|
|
948
|
+
const sentKeys = this.sentToD2KeysByAlias.get(alias) ?? new Set()
|
|
949
|
+
const result = trackBiggestSentValue(
|
|
950
|
+
changes,
|
|
951
|
+
this.biggestSentValue.get(alias),
|
|
952
|
+
sentKeys,
|
|
953
|
+
comparator,
|
|
954
|
+
)
|
|
955
|
+
this.biggestSentValue.set(alias, result.biggest)
|
|
956
|
+
if (result.shouldResetLoadKey) {
|
|
957
|
+
this.lastLoadRequestKey.delete(alias)
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Tear down subscriptions and clear state */
|
|
962
|
+
dispose(): void {
|
|
963
|
+
if (this.disposed) return
|
|
964
|
+
this.disposed = true
|
|
965
|
+
this.subscribedToAllCollections = false
|
|
966
|
+
|
|
967
|
+
// Immediately unsubscribe from sources and clear cheap state
|
|
968
|
+
this.unsubscribeCallbacks.forEach((fn) => fn())
|
|
969
|
+
this.unsubscribeCallbacks.clear()
|
|
970
|
+
this.sentToD2KeysByAlias.clear()
|
|
971
|
+
this.pendingChanges.clear()
|
|
972
|
+
this.lazySources.clear()
|
|
973
|
+
this.builderDependencies.clear()
|
|
974
|
+
this.biggestSentValue.clear()
|
|
975
|
+
this.lastLoadRequestKey.clear()
|
|
976
|
+
this.pendingOrderedLoadPromise = undefined
|
|
977
|
+
|
|
978
|
+
// Clear mutable objects
|
|
979
|
+
for (const key of Object.keys(this.lazySourcesCallbacks)) {
|
|
980
|
+
delete this.lazySourcesCallbacks[key]
|
|
981
|
+
}
|
|
982
|
+
for (const key of Object.keys(this.aliasDependencies)) {
|
|
983
|
+
delete this.aliasDependencies[key]
|
|
984
|
+
}
|
|
985
|
+
for (const key of Object.keys(this.optimizableOrderByCollections)) {
|
|
986
|
+
delete this.optimizableOrderByCollections[key]
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// If the graph is currently running, defer clearing graph/inputs/pipeline
|
|
990
|
+
// until runGraph() completes — otherwise we'd null references mid-loop.
|
|
991
|
+
if (this.isGraphRunning) {
|
|
992
|
+
this.deferredCleanup = true
|
|
993
|
+
} else {
|
|
994
|
+
this.finalCleanup()
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Clear graph references — called after graph run completes or immediately from dispose */
|
|
999
|
+
private finalCleanup(): void {
|
|
1000
|
+
this.graph = undefined
|
|
1001
|
+
this.inputs = undefined
|
|
1002
|
+
this.pipeline = undefined
|
|
1003
|
+
this.sourceWhereClauses = undefined
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ---------------------------------------------------------------------------
|
|
1008
|
+
// Helpers
|
|
1009
|
+
// ---------------------------------------------------------------------------
|
|
1010
|
+
|
|
1011
|
+
function getHandlerForEvent<TRow extends object, TKey extends string | number>(
|
|
1012
|
+
event: DeltaEvent<TRow, TKey>,
|
|
1013
|
+
config: EffectConfig<TRow, TKey>,
|
|
1014
|
+
): EffectEventHandler<TRow, TKey> | undefined {
|
|
1015
|
+
switch (event.type) {
|
|
1016
|
+
case `enter`:
|
|
1017
|
+
return config.onEnter
|
|
1018
|
+
case `exit`:
|
|
1019
|
+
return config.onExit
|
|
1020
|
+
case `update`:
|
|
1021
|
+
return config.onUpdate
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Accumulate D2 output multiplicities into per-key effect changes.
|
|
1027
|
+
* Tracks both insert values (new) and delete values (old) separately
|
|
1028
|
+
* so that update and exit events can include previousValue.
|
|
1029
|
+
*/
|
|
1030
|
+
function accumulateEffectChanges<T>(
|
|
1031
|
+
acc: Map<unknown, EffectChanges<T>>,
|
|
1032
|
+
[[key, tupleData], multiplicity]: [
|
|
1033
|
+
[unknown, [any, string | undefined]],
|
|
1034
|
+
number,
|
|
1035
|
+
],
|
|
1036
|
+
): Map<unknown, EffectChanges<T>> {
|
|
1037
|
+
const [value] = tupleData as [T, string | undefined]
|
|
1038
|
+
|
|
1039
|
+
const changes: EffectChanges<T> = acc.get(key) || {
|
|
1040
|
+
deletes: 0,
|
|
1041
|
+
inserts: 0,
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (multiplicity < 0) {
|
|
1045
|
+
changes.deletes += Math.abs(multiplicity)
|
|
1046
|
+
// Keep only the first delete value — this is the pre-batch state
|
|
1047
|
+
changes.deleteValue ??= value
|
|
1048
|
+
} else if (multiplicity > 0) {
|
|
1049
|
+
changes.inserts += multiplicity
|
|
1050
|
+
// Always overwrite with the latest insert — this is the post-batch state
|
|
1051
|
+
changes.insertValue = value
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
acc.set(key, changes)
|
|
1055
|
+
return acc
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Classify accumulated per-key changes into a DeltaEvent */
|
|
1059
|
+
function classifyDelta<TRow extends object, TKey extends string | number>(
|
|
1060
|
+
key: TKey,
|
|
1061
|
+
changes: EffectChanges<TRow>,
|
|
1062
|
+
): DeltaEvent<TRow, TKey> | undefined {
|
|
1063
|
+
const { inserts, deletes, insertValue, deleteValue } = changes
|
|
1064
|
+
|
|
1065
|
+
if (inserts > 0 && deletes === 0) {
|
|
1066
|
+
// Row entered the query result
|
|
1067
|
+
return { type: `enter`, key, value: insertValue! }
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (deletes > 0 && inserts === 0) {
|
|
1071
|
+
// Row exited the query result — value is the exiting value,
|
|
1072
|
+
// previousValue is omitted (it would be identical to value)
|
|
1073
|
+
return { type: `exit`, key, value: deleteValue! }
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (inserts > 0 && deletes > 0) {
|
|
1077
|
+
// Row updated within the query result
|
|
1078
|
+
return {
|
|
1079
|
+
type: `update`,
|
|
1080
|
+
key,
|
|
1081
|
+
value: insertValue!,
|
|
1082
|
+
previousValue: deleteValue!,
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// inserts === 0 && deletes === 0 — no net change (should not happen)
|
|
1087
|
+
return undefined
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Track a promise in the in-flight set, automatically removing on settlement */
|
|
1091
|
+
function trackPromise(
|
|
1092
|
+
promise: Promise<void>,
|
|
1093
|
+
inFlightHandlers: Set<Promise<void>>,
|
|
1094
|
+
): void {
|
|
1095
|
+
inFlightHandlers.add(promise)
|
|
1096
|
+
promise.finally(() => {
|
|
1097
|
+
inFlightHandlers.delete(promise)
|
|
1098
|
+
})
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Report an error to the onError callback or console */
|
|
1102
|
+
function reportError<TRow extends object, TKey extends string | number>(
|
|
1103
|
+
error: unknown,
|
|
1104
|
+
event: DeltaEvent<TRow, TKey>,
|
|
1105
|
+
onError?: (error: Error, event: DeltaEvent<TRow, TKey>) => void,
|
|
1106
|
+
): void {
|
|
1107
|
+
const normalised = error instanceof Error ? error : new Error(String(error))
|
|
1108
|
+
if (onError) {
|
|
1109
|
+
try {
|
|
1110
|
+
onError(normalised, event)
|
|
1111
|
+
} catch (onErrorError) {
|
|
1112
|
+
// Don't let onError errors propagate
|
|
1113
|
+
console.error(`[Effect] Error in onError handler:`, onErrorError)
|
|
1114
|
+
console.error(`[Effect] Original error:`, normalised)
|
|
1115
|
+
}
|
|
1116
|
+
} else {
|
|
1117
|
+
console.error(`[Effect] Unhandled error in handler:`, normalised)
|
|
1118
|
+
}
|
|
1119
|
+
}
|