@tanstack/db 0.5.17 → 0.5.19
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/collection/changes.cjs +3 -0
- package/dist/cjs/collection/changes.cjs.map +1 -1
- package/dist/cjs/collection/sync.cjs +2 -1
- package/dist/cjs/collection/sync.cjs.map +1 -1
- package/dist/cjs/proxy.cjs +1 -1
- package/dist/cjs/proxy.cjs.map +1 -1
- package/dist/cjs/query/builder/index.cjs +5 -4
- package/dist/cjs/query/builder/index.cjs.map +1 -1
- package/dist/cjs/query/builder/index.d.cts +5 -4
- package/dist/cjs/query/builder/ref-proxy.cjs +63 -0
- package/dist/cjs/query/builder/ref-proxy.cjs.map +1 -1
- package/dist/cjs/query/builder/ref-proxy.d.cts +13 -0
- package/dist/cjs/query/builder/types.d.cts +26 -1
- package/dist/cjs/query/compiler/evaluators.cjs +29 -2
- package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
- package/dist/cjs/query/compiler/group-by.cjs +29 -12
- package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
- package/dist/cjs/query/compiler/group-by.d.cts +18 -2
- package/dist/cjs/query/compiler/index.cjs +5 -5
- package/dist/cjs/query/compiler/index.cjs.map +1 -1
- package/dist/cjs/query/compiler/order-by.cjs +1 -1
- package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
- package/dist/cjs/query/compiler/order-by.d.cts +1 -1
- package/dist/cjs/query/compiler/select.cjs +1 -1
- package/dist/cjs/query/compiler/select.cjs.map +1 -1
- package/dist/cjs/query/compiler/select.d.cts +1 -1
- package/dist/cjs/query/live/collection-config-builder.cjs +26 -10
- package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
- package/dist/cjs/query/live/collection-subscriber.cjs +42 -33
- package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
- package/dist/cjs/query/live/internal.cjs +1 -1
- package/dist/cjs/query/live/internal.cjs.map +1 -1
- package/dist/cjs/query/live/types.d.cts +2 -1
- package/dist/cjs/types.d.cts +6 -0
- package/dist/esm/collection/changes.js +3 -0
- package/dist/esm/collection/changes.js.map +1 -1
- package/dist/esm/collection/sync.js +2 -1
- package/dist/esm/collection/sync.js.map +1 -1
- package/dist/esm/proxy.js +1 -1
- package/dist/esm/proxy.js.map +1 -1
- package/dist/esm/query/builder/index.d.ts +5 -4
- package/dist/esm/query/builder/index.js +6 -5
- package/dist/esm/query/builder/index.js.map +1 -1
- package/dist/esm/query/builder/ref-proxy.d.ts +13 -0
- package/dist/esm/query/builder/ref-proxy.js +63 -0
- package/dist/esm/query/builder/ref-proxy.js.map +1 -1
- package/dist/esm/query/builder/types.d.ts +26 -1
- package/dist/esm/query/compiler/evaluators.js +29 -2
- package/dist/esm/query/compiler/evaluators.js.map +1 -1
- package/dist/esm/query/compiler/group-by.d.ts +18 -2
- package/dist/esm/query/compiler/group-by.js +30 -13
- package/dist/esm/query/compiler/group-by.js.map +1 -1
- package/dist/esm/query/compiler/index.js +5 -5
- package/dist/esm/query/compiler/index.js.map +1 -1
- package/dist/esm/query/compiler/order-by.d.ts +1 -1
- package/dist/esm/query/compiler/order-by.js +1 -1
- package/dist/esm/query/compiler/order-by.js.map +1 -1
- package/dist/esm/query/compiler/select.d.ts +1 -1
- package/dist/esm/query/compiler/select.js +1 -1
- package/dist/esm/query/compiler/select.js.map +1 -1
- package/dist/esm/query/live/collection-config-builder.js +26 -10
- package/dist/esm/query/live/collection-config-builder.js.map +1 -1
- package/dist/esm/query/live/collection-subscriber.js +42 -33
- package/dist/esm/query/live/collection-subscriber.js.map +1 -1
- package/dist/esm/query/live/internal.js +1 -1
- package/dist/esm/query/live/internal.js.map +1 -1
- package/dist/esm/query/live/types.d.ts +2 -1
- package/dist/esm/types.d.ts +6 -0
- package/package.json +2 -2
- package/src/collection/changes.ts +7 -0
- package/src/collection/sync.ts +2 -2
- package/src/query/builder/index.ts +22 -6
- package/src/query/builder/ref-proxy.ts +90 -0
- package/src/query/builder/types.ts +26 -1
- package/src/query/compiler/evaluators.ts +38 -2
- package/src/query/compiler/group-by.ts +76 -22
- package/src/query/compiler/index.ts +13 -13
- package/src/query/compiler/order-by.ts +7 -6
- package/src/query/compiler/select.ts +5 -8
- package/src/query/live/collection-config-builder.ts +66 -16
- package/src/query/live/collection-subscriber.ts +60 -41
- package/src/query/live/types.ts +3 -1
- package/src/types.ts +6 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
filter,
|
|
3
|
+
groupBy,
|
|
4
|
+
groupByOperators,
|
|
5
|
+
map,
|
|
6
|
+
serializeValue,
|
|
7
|
+
} from '@tanstack/db-ivm'
|
|
2
8
|
import { Func, PropRef, getHavingExpression } from '../ir.js'
|
|
3
9
|
import {
|
|
4
10
|
AggregateFunctionNotInSelectError,
|
|
@@ -66,7 +72,7 @@ function validateAndCreateMapping(
|
|
|
66
72
|
|
|
67
73
|
/**
|
|
68
74
|
* Processes the GROUP BY clause with optional HAVING and SELECT
|
|
69
|
-
* Works with the new
|
|
75
|
+
* Works with the new $selected structure from early SELECT processing
|
|
70
76
|
*/
|
|
71
77
|
export function processGroupBy(
|
|
72
78
|
pipeline: NamespacedAndKeyedStream,
|
|
@@ -98,11 +104,11 @@ export function processGroupBy(
|
|
|
98
104
|
groupBy(keyExtractor, aggregates),
|
|
99
105
|
) as NamespacedAndKeyedStream
|
|
100
106
|
|
|
101
|
-
// Update
|
|
107
|
+
// Update $selected to include aggregate values
|
|
102
108
|
pipeline = pipeline.pipe(
|
|
103
109
|
map(([, aggregatedRow]) => {
|
|
104
|
-
// Start with the existing
|
|
105
|
-
const selectResults = (aggregatedRow as any)
|
|
110
|
+
// Start with the existing $selected from early SELECT processing
|
|
111
|
+
const selectResults = (aggregatedRow as any).$selected || {}
|
|
106
112
|
const finalResults: Record<string, any> = { ...selectResults }
|
|
107
113
|
|
|
108
114
|
if (selectClause) {
|
|
@@ -115,12 +121,12 @@ export function processGroupBy(
|
|
|
115
121
|
}
|
|
116
122
|
}
|
|
117
123
|
|
|
118
|
-
// Use a single key for the result and update
|
|
124
|
+
// Use a single key for the result and update $selected
|
|
119
125
|
return [
|
|
120
126
|
`single_group`,
|
|
121
127
|
{
|
|
122
128
|
...aggregatedRow,
|
|
123
|
-
|
|
129
|
+
$selected: finalResults,
|
|
124
130
|
},
|
|
125
131
|
] as [unknown, Record<string, any>]
|
|
126
132
|
}),
|
|
@@ -133,13 +139,14 @@ export function processGroupBy(
|
|
|
133
139
|
const transformedHavingClause = replaceAggregatesByRefs(
|
|
134
140
|
havingExpression,
|
|
135
141
|
selectClause || {},
|
|
142
|
+
`$selected`,
|
|
136
143
|
)
|
|
137
144
|
const compiledHaving = compileExpression(transformedHavingClause)
|
|
138
145
|
|
|
139
146
|
pipeline = pipeline.pipe(
|
|
140
147
|
filter(([, row]) => {
|
|
141
148
|
// Create a namespaced row structure for HAVING evaluation
|
|
142
|
-
const namespacedRow = {
|
|
149
|
+
const namespacedRow = { $selected: (row as any).$selected }
|
|
143
150
|
return toBooleanPredicate(compiledHaving(namespacedRow))
|
|
144
151
|
}),
|
|
145
152
|
)
|
|
@@ -152,7 +159,7 @@ export function processGroupBy(
|
|
|
152
159
|
pipeline = pipeline.pipe(
|
|
153
160
|
filter(([, row]) => {
|
|
154
161
|
// Create a namespaced row structure for functional HAVING evaluation
|
|
155
|
-
const namespacedRow = {
|
|
162
|
+
const namespacedRow = { $selected: (row as any).$selected }
|
|
156
163
|
return toBooleanPredicate(fnHaving(namespacedRow))
|
|
157
164
|
}),
|
|
158
165
|
)
|
|
@@ -174,11 +181,11 @@ export function processGroupBy(
|
|
|
174
181
|
// Create a key extractor function using simple __key_X format
|
|
175
182
|
const keyExtractor = ([, row]: [
|
|
176
183
|
string,
|
|
177
|
-
NamespacedRow & {
|
|
184
|
+
NamespacedRow & { $selected?: any },
|
|
178
185
|
]) => {
|
|
179
|
-
// Use the original namespaced row for GROUP BY expressions, not
|
|
186
|
+
// Use the original namespaced row for GROUP BY expressions, not $selected
|
|
180
187
|
const namespacedRow = { ...row }
|
|
181
|
-
delete (namespacedRow as any)
|
|
188
|
+
delete (namespacedRow as any).$selected
|
|
182
189
|
|
|
183
190
|
const key: Record<string, unknown> = {}
|
|
184
191
|
|
|
@@ -208,11 +215,11 @@ export function processGroupBy(
|
|
|
208
215
|
// Apply the groupBy operator
|
|
209
216
|
pipeline = pipeline.pipe(groupBy(keyExtractor, aggregates))
|
|
210
217
|
|
|
211
|
-
// Update
|
|
218
|
+
// Update $selected to handle GROUP BY results
|
|
212
219
|
pipeline = pipeline.pipe(
|
|
213
220
|
map(([, aggregatedRow]) => {
|
|
214
|
-
// Start with the existing
|
|
215
|
-
const selectResults = (aggregatedRow as any)
|
|
221
|
+
// Start with the existing $selected from early SELECT processing
|
|
222
|
+
const selectResults = (aggregatedRow as any).$selected || {}
|
|
216
223
|
const finalResults: Record<string, any> = {}
|
|
217
224
|
|
|
218
225
|
if (selectClause) {
|
|
@@ -248,14 +255,14 @@ export function processGroupBy(
|
|
|
248
255
|
for (let i = 0; i < groupByClause.length; i++) {
|
|
249
256
|
keyParts.push(aggregatedRow[`__key_${i}`])
|
|
250
257
|
}
|
|
251
|
-
finalKey =
|
|
258
|
+
finalKey = serializeValue(keyParts)
|
|
252
259
|
}
|
|
253
260
|
|
|
254
261
|
return [
|
|
255
262
|
finalKey,
|
|
256
263
|
{
|
|
257
264
|
...aggregatedRow,
|
|
258
|
-
|
|
265
|
+
$selected: finalResults,
|
|
259
266
|
},
|
|
260
267
|
] as [unknown, Record<string, any>]
|
|
261
268
|
}),
|
|
@@ -274,7 +281,7 @@ export function processGroupBy(
|
|
|
274
281
|
pipeline = pipeline.pipe(
|
|
275
282
|
filter(([, row]) => {
|
|
276
283
|
// Create a namespaced row structure for HAVING evaluation
|
|
277
|
-
const namespacedRow = {
|
|
284
|
+
const namespacedRow = { $selected: (row as any).$selected }
|
|
278
285
|
return compiledHaving(namespacedRow)
|
|
279
286
|
}),
|
|
280
287
|
)
|
|
@@ -287,7 +294,7 @@ export function processGroupBy(
|
|
|
287
294
|
pipeline = pipeline.pipe(
|
|
288
295
|
filter(([, row]) => {
|
|
289
296
|
// Create a namespaced row structure for functional HAVING evaluation
|
|
290
|
-
const namespacedRow = {
|
|
297
|
+
const namespacedRow = { $selected: (row as any).$selected }
|
|
291
298
|
return toBooleanPredicate(fnHaving(namespacedRow))
|
|
292
299
|
}),
|
|
293
300
|
)
|
|
@@ -385,12 +392,28 @@ function getAggregateFunction(aggExpr: Aggregate) {
|
|
|
385
392
|
}
|
|
386
393
|
|
|
387
394
|
/**
|
|
388
|
-
* Transforms
|
|
395
|
+
* Transforms expressions to replace aggregate functions with references to computed values.
|
|
396
|
+
*
|
|
397
|
+
* This function is used in both ORDER BY and HAVING clauses to transform expressions that reference:
|
|
398
|
+
* 1. Aggregate functions (e.g., `max()`, `count()`) - replaces with references to computed aggregates in SELECT
|
|
399
|
+
* 2. SELECT field references via $selected namespace (e.g., `$selected.latestActivity`) - validates and passes through unchanged
|
|
400
|
+
*
|
|
401
|
+
* For aggregate expressions, it finds matching aggregates in the SELECT clause and replaces them with
|
|
402
|
+
* PropRef([resultAlias, alias]) to reference the computed aggregate value.
|
|
403
|
+
*
|
|
404
|
+
* For ref expressions using the $selected namespace, it validates that the field exists in the SELECT clause
|
|
405
|
+
* and passes them through unchanged (since $selected is already the correct namespace). All other ref expressions
|
|
406
|
+
* are passed through unchanged (treating them as table column references).
|
|
407
|
+
*
|
|
408
|
+
* @param havingExpr - The expression to transform (can be aggregate, ref, func, or val)
|
|
409
|
+
* @param selectClause - The SELECT clause containing aliases and aggregate definitions
|
|
410
|
+
* @param resultAlias - The namespace alias for SELECT results (default: '$selected', used for aggregate references)
|
|
411
|
+
* @returns A transformed BasicExpression that references computed values instead of raw expressions
|
|
389
412
|
*/
|
|
390
413
|
export function replaceAggregatesByRefs(
|
|
391
414
|
havingExpr: BasicExpression | Aggregate,
|
|
392
415
|
selectClause: Select,
|
|
393
|
-
resultAlias: string =
|
|
416
|
+
resultAlias: string = `$selected`,
|
|
394
417
|
): BasicExpression {
|
|
395
418
|
switch (havingExpr.type) {
|
|
396
419
|
case `agg`: {
|
|
@@ -417,7 +440,38 @@ export function replaceAggregatesByRefs(
|
|
|
417
440
|
}
|
|
418
441
|
|
|
419
442
|
case `ref`: {
|
|
420
|
-
|
|
443
|
+
const refExpr = havingExpr
|
|
444
|
+
const path = refExpr.path
|
|
445
|
+
|
|
446
|
+
if (path.length === 0) {
|
|
447
|
+
// Empty path - pass through
|
|
448
|
+
return havingExpr as BasicExpression
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Check if this is a $selected reference
|
|
452
|
+
if (path.length > 0 && path[0] === `$selected`) {
|
|
453
|
+
// Extract the field path after $selected
|
|
454
|
+
const fieldPath = path.slice(1)
|
|
455
|
+
|
|
456
|
+
if (fieldPath.length === 0) {
|
|
457
|
+
// Just $selected without a field - pass through unchanged
|
|
458
|
+
return havingExpr as BasicExpression
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Verify the field exists in SELECT clause
|
|
462
|
+
const alias = fieldPath.join(`.`)
|
|
463
|
+
if (alias in selectClause) {
|
|
464
|
+
// Pass through unchanged - $selected is already the correct namespace
|
|
465
|
+
return havingExpr as BasicExpression
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Field doesn't exist in SELECT - this is an error, but we'll pass through for now
|
|
469
|
+
// (Could throw an error here in the future)
|
|
470
|
+
return havingExpr as BasicExpression
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Not a $selected reference - this is a table column reference, pass through unchanged
|
|
474
|
+
// SELECT fields should only be accessed via $selected namespace
|
|
421
475
|
return havingExpr as BasicExpression
|
|
422
476
|
}
|
|
423
477
|
|
|
@@ -216,7 +216,7 @@ export function compileQuery(
|
|
|
216
216
|
throw new DistinctRequiresSelectError()
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
// Process the SELECT clause early - always create
|
|
219
|
+
// Process the SELECT clause early - always create $selected
|
|
220
220
|
// This eliminates duplication and allows for DISTINCT implementation
|
|
221
221
|
if (query.fnSelect) {
|
|
222
222
|
// Handle functional select - apply the function to transform the row
|
|
@@ -227,15 +227,15 @@ export function compileQuery(
|
|
|
227
227
|
key,
|
|
228
228
|
{
|
|
229
229
|
...namespacedRow,
|
|
230
|
-
|
|
230
|
+
$selected: selectResults,
|
|
231
231
|
},
|
|
232
|
-
] as [string, typeof namespacedRow & {
|
|
232
|
+
] as [string, typeof namespacedRow & { $selected: any }]
|
|
233
233
|
}),
|
|
234
234
|
)
|
|
235
235
|
} else if (query.select) {
|
|
236
236
|
pipeline = processSelect(pipeline, query.select, allInputs)
|
|
237
237
|
} else {
|
|
238
|
-
// If no SELECT clause, create
|
|
238
|
+
// If no SELECT clause, create $selected with the main table data
|
|
239
239
|
pipeline = pipeline.pipe(
|
|
240
240
|
map(([key, namespacedRow]) => {
|
|
241
241
|
const selectResults =
|
|
@@ -247,9 +247,9 @@ export function compileQuery(
|
|
|
247
247
|
key,
|
|
248
248
|
{
|
|
249
249
|
...namespacedRow,
|
|
250
|
-
|
|
250
|
+
$selected: selectResults,
|
|
251
251
|
},
|
|
252
|
-
] as [string, typeof namespacedRow & {
|
|
252
|
+
] as [string, typeof namespacedRow & { $selected: any }]
|
|
253
253
|
}),
|
|
254
254
|
)
|
|
255
255
|
}
|
|
@@ -310,7 +310,7 @@ export function compileQuery(
|
|
|
310
310
|
|
|
311
311
|
// Process the DISTINCT clause if it exists
|
|
312
312
|
if (query.distinct) {
|
|
313
|
-
pipeline = pipeline.pipe(distinct(([_key, row]) => row
|
|
313
|
+
pipeline = pipeline.pipe(distinct(([_key, row]) => row.$selected))
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
// Process orderBy parameter if it exists
|
|
@@ -327,11 +327,11 @@ export function compileQuery(
|
|
|
327
327
|
query.offset,
|
|
328
328
|
)
|
|
329
329
|
|
|
330
|
-
// Final step: extract the
|
|
330
|
+
// Final step: extract the $selected and include orderBy index
|
|
331
331
|
const resultPipeline = orderedPipeline.pipe(
|
|
332
332
|
map(([key, [row, orderByIndex]]) => {
|
|
333
|
-
// Extract the final results from
|
|
334
|
-
const raw = (row as any)
|
|
333
|
+
// Extract the final results from $selected and include orderBy index
|
|
334
|
+
const raw = (row as any).$selected
|
|
335
335
|
const finalResults = unwrapValue(raw)
|
|
336
336
|
return [key, [finalResults, orderByIndex]] as [unknown, [any, string]]
|
|
337
337
|
}),
|
|
@@ -354,11 +354,11 @@ export function compileQuery(
|
|
|
354
354
|
throw new LimitOffsetRequireOrderByError()
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
-
// Final step: extract the
|
|
357
|
+
// Final step: extract the $selected and return tuple format (no orderBy)
|
|
358
358
|
const resultPipeline: ResultStream = pipeline.pipe(
|
|
359
359
|
map(([key, row]) => {
|
|
360
|
-
// Extract the final results from
|
|
361
|
-
const raw = (row as any)
|
|
360
|
+
// Extract the final results from $selected and return [key, [results, undefined]]
|
|
361
|
+
const raw = (row as any).$selected
|
|
362
362
|
const finalResults = unwrapValue(raw)
|
|
363
363
|
return [key, [finalResults, undefined]] as [
|
|
364
364
|
unknown,
|
|
@@ -38,7 +38,7 @@ export type OrderByOptimizationInfo = {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Processes the ORDER BY clause
|
|
41
|
-
* Works with the new structure that has both namespaced row data and
|
|
41
|
+
* Works with the new structure that has both namespaced row data and $selected
|
|
42
42
|
* Always uses fractional indexing and adds the index as __ordering_index to the result
|
|
43
43
|
*/
|
|
44
44
|
export function processOrderBy(
|
|
@@ -57,7 +57,7 @@ export function processOrderBy(
|
|
|
57
57
|
const clauseWithoutAggregates = replaceAggregatesByRefs(
|
|
58
58
|
clause.expression,
|
|
59
59
|
selectClause,
|
|
60
|
-
|
|
60
|
+
`$selected`,
|
|
61
61
|
)
|
|
62
62
|
|
|
63
63
|
return {
|
|
@@ -67,12 +67,13 @@ export function processOrderBy(
|
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
// Create a value extractor function for the orderBy operator
|
|
70
|
-
const valueExtractor = (row: NamespacedRow & {
|
|
70
|
+
const valueExtractor = (row: NamespacedRow & { $selected?: any }) => {
|
|
71
71
|
// The namespaced row contains:
|
|
72
72
|
// 1. Table aliases as top-level properties (e.g., row["tableName"])
|
|
73
|
-
// 2. SELECT results in
|
|
74
|
-
// The replaceAggregatesByRefs function has already transformed
|
|
75
|
-
// that match SELECT aggregates to use the
|
|
73
|
+
// 2. SELECT results in $selected (e.g., row.$selected["aggregateAlias"])
|
|
74
|
+
// The replaceAggregatesByRefs function has already transformed:
|
|
75
|
+
// - Aggregate expressions that match SELECT aggregates to use the $selected namespace
|
|
76
|
+
// - $selected ref expressions are passed through unchanged (already using the correct namespace)
|
|
76
77
|
const orderByContext = row
|
|
77
78
|
|
|
78
79
|
if (orderByClause.length > 1) {
|
|
@@ -100,7 +100,7 @@ function processNonMergeOp(
|
|
|
100
100
|
function processRow(
|
|
101
101
|
[key, namespacedRow]: [unknown, NamespacedRow],
|
|
102
102
|
ops: Array<SelectOp>,
|
|
103
|
-
): [unknown, typeof namespacedRow & {
|
|
103
|
+
): [unknown, typeof namespacedRow & { $selected: any }] {
|
|
104
104
|
const selectResults: Record<string, any> = {}
|
|
105
105
|
|
|
106
106
|
for (const op of ops) {
|
|
@@ -111,21 +111,18 @@ function processRow(
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// Return the namespaced row with
|
|
114
|
+
// Return the namespaced row with $selected added
|
|
115
115
|
return [
|
|
116
116
|
key,
|
|
117
117
|
{
|
|
118
118
|
...namespacedRow,
|
|
119
|
-
|
|
119
|
+
$selected: selectResults,
|
|
120
120
|
},
|
|
121
|
-
] as [
|
|
122
|
-
unknown,
|
|
123
|
-
typeof namespacedRow & { __select_results: typeof selectResults },
|
|
124
|
-
]
|
|
121
|
+
] as [unknown, typeof namespacedRow & { $selected: typeof selectResults }]
|
|
125
122
|
}
|
|
126
123
|
|
|
127
124
|
/**
|
|
128
|
-
* Processes the SELECT clause and places results in
|
|
125
|
+
* Processes the SELECT clause and places results in $selected
|
|
129
126
|
* while preserving the original namespaced row for ORDER BY access
|
|
130
127
|
*/
|
|
131
128
|
export function processSelect(
|
|
@@ -337,6 +337,10 @@ export class CollectionConfigBuilder<
|
|
|
337
337
|
if (syncState.subscribedToAllCollections) {
|
|
338
338
|
while (syncState.graph.pendingWork()) {
|
|
339
339
|
syncState.graph.run()
|
|
340
|
+
// Flush accumulated changes after each graph step to commit them as one transaction.
|
|
341
|
+
// This ensures intermediate join states (like null on one side) don't cause
|
|
342
|
+
// duplicate key errors when the full join result arrives in the same step.
|
|
343
|
+
syncState.flushPendingChanges?.()
|
|
340
344
|
callback?.()
|
|
341
345
|
}
|
|
342
346
|
|
|
@@ -345,10 +349,14 @@ export class CollectionConfigBuilder<
|
|
|
345
349
|
if (syncState.messagesCount === 0) {
|
|
346
350
|
begin()
|
|
347
351
|
commit()
|
|
348
|
-
// After initial commit, check if we should mark ready
|
|
349
|
-
// (in case all sources were already ready before we subscribed)
|
|
350
|
-
this.updateLiveQueryStatus(this.currentSyncConfig)
|
|
351
352
|
}
|
|
353
|
+
|
|
354
|
+
// After graph processing completes, check if we should mark ready.
|
|
355
|
+
// This is the canonical place to transition to ready state because:
|
|
356
|
+
// 1. All data has been processed through the graph
|
|
357
|
+
// 2. All source collections have had a chance to send their initial data
|
|
358
|
+
// This prevents marking ready before data is processed (fixes isReady=true with empty data)
|
|
359
|
+
this.updateLiveQueryStatus(this.currentSyncConfig)
|
|
352
360
|
}
|
|
353
361
|
} finally {
|
|
354
362
|
this.isGraphRunning = false
|
|
@@ -566,6 +574,21 @@ export class CollectionConfigBuilder<
|
|
|
566
574
|
},
|
|
567
575
|
)
|
|
568
576
|
|
|
577
|
+
// Listen for loadingSubset changes on the live query collection BEFORE subscribing.
|
|
578
|
+
// This ensures we don't miss the event if subset loading completes synchronously.
|
|
579
|
+
// When isLoadingSubset becomes false, we may need to mark the collection as ready
|
|
580
|
+
// (if all source collections are already ready but we were waiting for subset load to complete)
|
|
581
|
+
const loadingSubsetUnsubscribe = config.collection.on(
|
|
582
|
+
`loadingSubset:change`,
|
|
583
|
+
(event) => {
|
|
584
|
+
if (!event.isLoadingSubset) {
|
|
585
|
+
// Subset loading finished, check if we can now mark ready
|
|
586
|
+
this.updateLiveQueryStatus(config)
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
)
|
|
590
|
+
syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe)
|
|
591
|
+
|
|
569
592
|
const loadSubsetDataCallbacks = this.subscribeToAllCollections(
|
|
570
593
|
config,
|
|
571
594
|
fullSyncState,
|
|
@@ -672,22 +695,35 @@ export class CollectionConfigBuilder<
|
|
|
672
695
|
const { begin, commit } = config
|
|
673
696
|
const { graph, inputs, pipeline } = this.maybeCompileBasePipeline()
|
|
674
697
|
|
|
698
|
+
// Accumulator for changes across all output callbacks within a single graph run.
|
|
699
|
+
// This allows us to batch all changes from intermediate join states into a single
|
|
700
|
+
// transaction, avoiding duplicate key errors when joins produce multiple outputs
|
|
701
|
+
// for the same key (e.g., first output with null, then output with joined data).
|
|
702
|
+
let pendingChanges: Map<unknown, Changes<TResult>> = new Map()
|
|
703
|
+
|
|
675
704
|
pipeline.pipe(
|
|
676
705
|
output((data) => {
|
|
677
706
|
const messages = data.getInner()
|
|
678
707
|
syncState.messagesCount += messages.length
|
|
679
708
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
accumulateChanges<TResult>,
|
|
684
|
-
new Map<unknown, Changes<TResult>>(),
|
|
685
|
-
)
|
|
686
|
-
.forEach(this.applyChanges.bind(this, config))
|
|
687
|
-
commit()
|
|
709
|
+
// Accumulate changes from this output callback into the pending changes map.
|
|
710
|
+
// Changes for the same key are merged (inserts/deletes are added together).
|
|
711
|
+
messages.reduce(accumulateChanges<TResult>, pendingChanges)
|
|
688
712
|
}),
|
|
689
713
|
)
|
|
690
714
|
|
|
715
|
+
// Flush pending changes and reset the accumulator.
|
|
716
|
+
// Called at the end of each graph run to commit all accumulated changes.
|
|
717
|
+
syncState.flushPendingChanges = () => {
|
|
718
|
+
if (pendingChanges.size === 0) {
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
begin()
|
|
722
|
+
pendingChanges.forEach(this.applyChanges.bind(this, config))
|
|
723
|
+
commit()
|
|
724
|
+
pendingChanges = new Map()
|
|
725
|
+
}
|
|
726
|
+
|
|
691
727
|
graph.finalize()
|
|
692
728
|
|
|
693
729
|
// Extend the sync state with the graph, inputs, and pipeline
|
|
@@ -793,8 +829,17 @@ export class CollectionConfigBuilder<
|
|
|
793
829
|
return
|
|
794
830
|
}
|
|
795
831
|
|
|
796
|
-
// Mark ready when
|
|
797
|
-
|
|
832
|
+
// Mark ready when:
|
|
833
|
+
// 1. All subscriptions are set up (subscribedToAllCollections)
|
|
834
|
+
// 2. All source collections are ready
|
|
835
|
+
// 3. The live query collection is not loading subset data
|
|
836
|
+
// This prevents marking the live query ready before its data is processed
|
|
837
|
+
// (fixes issue where useLiveQuery returns isReady=true with empty data)
|
|
838
|
+
if (
|
|
839
|
+
this.currentSyncState?.subscribedToAllCollections &&
|
|
840
|
+
this.allCollectionsReady() &&
|
|
841
|
+
!this.liveQueryCollection?.isLoadingSubset
|
|
842
|
+
) {
|
|
798
843
|
markReady()
|
|
799
844
|
}
|
|
800
845
|
}
|
|
@@ -892,8 +937,10 @@ export class CollectionConfigBuilder<
|
|
|
892
937
|
// (graph only runs when all collections are subscribed)
|
|
893
938
|
syncState.subscribedToAllCollections = true
|
|
894
939
|
|
|
895
|
-
//
|
|
896
|
-
|
|
940
|
+
// Note: We intentionally don't call updateLiveQueryStatus() here.
|
|
941
|
+
// The graph hasn't run yet, so marking ready would be premature.
|
|
942
|
+
// The canonical place to mark ready is after the graph processes data
|
|
943
|
+
// in maybeRunGraph(), which ensures data has been processed first.
|
|
897
944
|
|
|
898
945
|
return loadSubsetDataCallbacks
|
|
899
946
|
}
|
|
@@ -1075,8 +1122,11 @@ function accumulateChanges<T>(
|
|
|
1075
1122
|
changes.deletes += Math.abs(multiplicity)
|
|
1076
1123
|
} else if (multiplicity > 0) {
|
|
1077
1124
|
changes.inserts += multiplicity
|
|
1125
|
+
// Update value to the latest version for this key
|
|
1078
1126
|
changes.value = value
|
|
1079
|
-
|
|
1127
|
+
if (orderByIndex !== undefined) {
|
|
1128
|
+
changes.orderByIndex = orderByIndex
|
|
1129
|
+
}
|
|
1080
1130
|
}
|
|
1081
1131
|
acc.set(key, changes)
|
|
1082
1132
|
return acc
|
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
} from '../compiler/expressions.js'
|
|
6
6
|
import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm'
|
|
7
7
|
import type { Collection } from '../../collection/index.js'
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
ChangeMessage,
|
|
10
|
+
SubscriptionStatusChangeEvent,
|
|
11
|
+
} from '../../types.js'
|
|
9
12
|
import type { Context, GetResult } from '../builder/types.js'
|
|
10
13
|
import type { BasicExpression } from '../ir.js'
|
|
11
14
|
import type { OrderByOptimizationInfo } from '../compiler/order-by.js'
|
|
@@ -53,26 +56,10 @@ export class CollectionSubscriber<
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
private subscribeToChanges(whereExpression?: BasicExpression<boolean>) {
|
|
56
|
-
let subscription: CollectionSubscription
|
|
57
59
|
const orderByInfo = this.getOrderByInfo()
|
|
58
|
-
if (orderByInfo) {
|
|
59
|
-
subscription = this.subscribeToOrderedChanges(
|
|
60
|
-
whereExpression,
|
|
61
|
-
orderByInfo,
|
|
62
|
-
)
|
|
63
|
-
} else {
|
|
64
|
-
// If the source alias is lazy then we should not include the initial state
|
|
65
|
-
const includeInitialState = !this.collectionConfigBuilder.isLazyAlias(
|
|
66
|
-
this.alias,
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
subscription = this.subscribeToMatchingChanges(
|
|
70
|
-
whereExpression,
|
|
71
|
-
includeInitialState,
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
60
|
|
|
75
|
-
|
|
61
|
+
// Track load promises using subscription from the event (avoids circular dependency)
|
|
62
|
+
const trackLoadPromise = (subscription: CollectionSubscription) => {
|
|
76
63
|
// Guard against duplicate transitions
|
|
77
64
|
if (!this.subscriptionLoadingPromises.has(subscription)) {
|
|
78
65
|
let resolve: () => void
|
|
@@ -89,16 +76,12 @@ export class CollectionSubscriber<
|
|
|
89
76
|
}
|
|
90
77
|
}
|
|
91
78
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Subscribe to subscription status changes to propagate loading state
|
|
99
|
-
const statusUnsubscribe = subscription.on(`status:change`, (event) => {
|
|
79
|
+
// Status change handler - passed to subscribeChanges so it's registered
|
|
80
|
+
// BEFORE any snapshot is requested, preventing race conditions
|
|
81
|
+
const onStatusChange = (event: SubscriptionStatusChangeEvent) => {
|
|
82
|
+
const subscription = event.subscription as CollectionSubscription
|
|
100
83
|
if (event.status === `loadingSubset`) {
|
|
101
|
-
trackLoadPromise()
|
|
84
|
+
trackLoadPromise(subscription)
|
|
102
85
|
} else {
|
|
103
86
|
// status is 'ready'
|
|
104
87
|
const deferred = this.subscriptionLoadingPromises.get(subscription)
|
|
@@ -108,7 +91,34 @@ export class CollectionSubscriber<
|
|
|
108
91
|
deferred.resolve()
|
|
109
92
|
}
|
|
110
93
|
}
|
|
111
|
-
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Create subscription with onStatusChange - listener is registered before any async work
|
|
97
|
+
let subscription: CollectionSubscription
|
|
98
|
+
if (orderByInfo) {
|
|
99
|
+
subscription = this.subscribeToOrderedChanges(
|
|
100
|
+
whereExpression,
|
|
101
|
+
orderByInfo,
|
|
102
|
+
onStatusChange,
|
|
103
|
+
)
|
|
104
|
+
} else {
|
|
105
|
+
// If the source alias is lazy then we should not include the initial state
|
|
106
|
+
const includeInitialState = !this.collectionConfigBuilder.isLazyAlias(
|
|
107
|
+
this.alias,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
subscription = this.subscribeToMatchingChanges(
|
|
111
|
+
whereExpression,
|
|
112
|
+
includeInitialState,
|
|
113
|
+
onStatusChange,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check current status after subscribing - if status is 'loadingSubset', track it.
|
|
118
|
+
// The onStatusChange listener will catch the transition to 'ready'.
|
|
119
|
+
if (subscription.status === `loadingSubset`) {
|
|
120
|
+
trackLoadPromise(subscription)
|
|
121
|
+
}
|
|
112
122
|
|
|
113
123
|
const unsubscribe = () => {
|
|
114
124
|
// If subscription has a pending promise, resolve it before unsubscribing
|
|
@@ -119,7 +129,6 @@ export class CollectionSubscriber<
|
|
|
119
129
|
deferred.resolve()
|
|
120
130
|
}
|
|
121
131
|
|
|
122
|
-
statusUnsubscribe()
|
|
123
132
|
subscription.unsubscribe()
|
|
124
133
|
}
|
|
125
134
|
// currentSyncState is always defined when subscribe() is called
|
|
@@ -179,22 +188,22 @@ export class CollectionSubscriber<
|
|
|
179
188
|
|
|
180
189
|
private subscribeToMatchingChanges(
|
|
181
190
|
whereExpression: BasicExpression<boolean> | undefined,
|
|
182
|
-
includeInitialState: boolean
|
|
183
|
-
|
|
191
|
+
includeInitialState: boolean,
|
|
192
|
+
onStatusChange: (event: SubscriptionStatusChangeEvent) => void,
|
|
193
|
+
): CollectionSubscription {
|
|
184
194
|
const sendChanges = (
|
|
185
195
|
changes: Array<ChangeMessage<any, string | number>>,
|
|
186
196
|
) => {
|
|
187
197
|
this.sendChangesToPipeline(changes)
|
|
188
198
|
}
|
|
189
199
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// If we pass `false`, changes.ts would call markAllStateAsSeen() which
|
|
194
|
-
// disables filtering - but internal subscriptions still need filtering.
|
|
200
|
+
// Create subscription with onStatusChange - listener is registered before snapshot
|
|
201
|
+
// Note: For non-ordered queries (no limit/offset), we use trackLoadSubsetPromise: false
|
|
202
|
+
// which is the default behavior in subscribeChanges
|
|
195
203
|
const subscription = this.collection.subscribeChanges(sendChanges, {
|
|
196
204
|
...(includeInitialState && { includeInitialState }),
|
|
197
205
|
whereExpression,
|
|
206
|
+
onStatusChange,
|
|
198
207
|
})
|
|
199
208
|
|
|
200
209
|
return subscription
|
|
@@ -203,22 +212,31 @@ export class CollectionSubscriber<
|
|
|
203
212
|
private subscribeToOrderedChanges(
|
|
204
213
|
whereExpression: BasicExpression<boolean> | undefined,
|
|
205
214
|
orderByInfo: OrderByOptimizationInfo,
|
|
206
|
-
|
|
215
|
+
onStatusChange: (event: SubscriptionStatusChangeEvent) => void,
|
|
216
|
+
): CollectionSubscription {
|
|
207
217
|
const { orderBy, offset, limit, index } = orderByInfo
|
|
208
218
|
|
|
219
|
+
// Use a holder to forward-reference subscription in the callback
|
|
220
|
+
const subscriptionHolder: { current?: CollectionSubscription } = {}
|
|
221
|
+
|
|
209
222
|
const sendChangesInRange = (
|
|
210
223
|
changes: Iterable<ChangeMessage<any, string | number>>,
|
|
211
224
|
) => {
|
|
212
225
|
// Split live updates into a delete of the old value and an insert of the new value
|
|
213
226
|
const splittedChanges = splitUpdates(changes)
|
|
214
|
-
this.sendChangesToPipelineWithTracking(
|
|
227
|
+
this.sendChangesToPipelineWithTracking(
|
|
228
|
+
splittedChanges,
|
|
229
|
+
subscriptionHolder.current!,
|
|
230
|
+
)
|
|
215
231
|
}
|
|
216
232
|
|
|
217
|
-
// Subscribe to changes
|
|
218
|
-
// values
|
|
233
|
+
// Subscribe to changes with onStatusChange - listener is registered before any snapshot
|
|
234
|
+
// values bigger than what we've sent don't need to be sent because they can't affect the topK
|
|
219
235
|
const subscription = this.collection.subscribeChanges(sendChangesInRange, {
|
|
220
236
|
whereExpression,
|
|
237
|
+
onStatusChange,
|
|
221
238
|
})
|
|
239
|
+
subscriptionHolder.current = subscription
|
|
222
240
|
|
|
223
241
|
// Listen for truncate events to reset cursor tracking state and sentToD2Keys
|
|
224
242
|
// This ensures that after a must-refetch/truncate, we don't use stale cursor data
|
|
@@ -236,6 +254,7 @@ export class CollectionSubscriber<
|
|
|
236
254
|
// Normalize the orderBy clauses such that the references are relative to the collection
|
|
237
255
|
const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
|
|
238
256
|
|
|
257
|
+
// Trigger the snapshot request - onStatusChange listener is already registered
|
|
239
258
|
if (index) {
|
|
240
259
|
// We have an index on the first orderBy column - use lazy loading optimization
|
|
241
260
|
// This works for both single-column and multi-column orderBy:
|