@tanstack/db 0.5.24 → 0.5.25
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/change-events.cjs +1 -1
- package/dist/cjs/collection/change-events.cjs.map +1 -1
- package/dist/cjs/collection/changes.cjs +6 -1
- package/dist/cjs/collection/changes.cjs.map +1 -1
- package/dist/cjs/collection/lifecycle.cjs +11 -0
- package/dist/cjs/collection/lifecycle.cjs.map +1 -1
- package/dist/cjs/collection/subscription.cjs +18 -5
- package/dist/cjs/collection/subscription.cjs.map +1 -1
- package/dist/cjs/collection/subscription.d.cts +7 -1
- package/dist/cjs/indexes/base-index.cjs.map +1 -1
- package/dist/cjs/indexes/base-index.d.cts +10 -6
- package/dist/cjs/indexes/btree-index.cjs +64 -24
- package/dist/cjs/indexes/btree-index.cjs.map +1 -1
- package/dist/cjs/indexes/btree-index.d.cts +31 -9
- package/dist/cjs/indexes/reverse-index.cjs +6 -0
- package/dist/cjs/indexes/reverse-index.cjs.map +1 -1
- package/dist/cjs/indexes/reverse-index.d.cts +4 -2
- package/dist/cjs/query/live/collection-config-builder.cjs +4 -1
- package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
- package/dist/cjs/query/live/collection-subscriber.cjs +111 -30
- package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
- package/dist/cjs/query/live/collection-subscriber.d.cts +5 -0
- package/dist/cjs/types.d.cts +16 -0
- package/dist/cjs/utils/comparison.cjs +16 -0
- package/dist/cjs/utils/comparison.cjs.map +1 -1
- package/dist/cjs/utils/comparison.d.cts +21 -0
- package/dist/esm/collection/change-events.js +1 -1
- package/dist/esm/collection/change-events.js.map +1 -1
- package/dist/esm/collection/changes.js +6 -1
- package/dist/esm/collection/changes.js.map +1 -1
- package/dist/esm/collection/lifecycle.js +11 -0
- package/dist/esm/collection/lifecycle.js.map +1 -1
- package/dist/esm/collection/subscription.d.ts +7 -1
- package/dist/esm/collection/subscription.js +18 -5
- package/dist/esm/collection/subscription.js.map +1 -1
- package/dist/esm/indexes/base-index.d.ts +10 -6
- package/dist/esm/indexes/base-index.js.map +1 -1
- package/dist/esm/indexes/btree-index.d.ts +31 -9
- package/dist/esm/indexes/btree-index.js +65 -25
- package/dist/esm/indexes/btree-index.js.map +1 -1
- package/dist/esm/indexes/reverse-index.d.ts +4 -2
- package/dist/esm/indexes/reverse-index.js +6 -0
- package/dist/esm/indexes/reverse-index.js.map +1 -1
- package/dist/esm/query/live/collection-config-builder.js +4 -1
- package/dist/esm/query/live/collection-config-builder.js.map +1 -1
- package/dist/esm/query/live/collection-subscriber.d.ts +5 -0
- package/dist/esm/query/live/collection-subscriber.js +112 -31
- package/dist/esm/query/live/collection-subscriber.js.map +1 -1
- package/dist/esm/types.d.ts +16 -0
- package/dist/esm/utils/comparison.d.ts +21 -0
- package/dist/esm/utils/comparison.js +16 -0
- package/dist/esm/utils/comparison.js.map +1 -1
- package/package.json +1 -1
- package/src/collection/change-events.ts +1 -1
- package/src/collection/changes.ts +6 -1
- package/src/collection/lifecycle.ts +14 -0
- package/src/collection/subscription.ts +38 -10
- package/src/indexes/base-index.ts +19 -6
- package/src/indexes/btree-index.ts +101 -30
- package/src/indexes/reverse-index.ts +13 -2
- package/src/query/live/collection-config-builder.ts +4 -5
- package/src/query/live/collection-subscriber.ts +173 -50
- package/src/types.ts +16 -0
- package/src/utils/comparison.ts +34 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { compareKeys } from '@tanstack/db-ivm'
|
|
2
2
|
import { BTree } from '../utils/btree.js'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
defaultComparator,
|
|
5
|
+
denormalizeUndefined,
|
|
6
|
+
normalizeForBTree,
|
|
7
|
+
} from '../utils/comparison.js'
|
|
4
8
|
import { BaseIndex } from './base-index.js'
|
|
5
9
|
import type { CompareOptions } from '../query/builder/types.js'
|
|
6
10
|
import type { BasicExpression } from '../query/ir.js'
|
|
@@ -29,7 +33,7 @@ export interface RangeQueryOptions {
|
|
|
29
33
|
* This maintains items in sorted order and provides efficient range operations
|
|
30
34
|
*/
|
|
31
35
|
export class BTreeIndex<
|
|
32
|
-
TKey extends string | number = string | number,
|
|
36
|
+
TKey extends string | number | undefined = string | number | undefined,
|
|
33
37
|
> extends BaseIndex<TKey> {
|
|
34
38
|
public readonly supportedOperations = new Set<IndexOperation>([
|
|
35
39
|
`eq`,
|
|
@@ -55,7 +59,16 @@ export class BTreeIndex<
|
|
|
55
59
|
options?: any,
|
|
56
60
|
) {
|
|
57
61
|
super(id, expression, name, options)
|
|
58
|
-
|
|
62
|
+
|
|
63
|
+
// Get the base compare function
|
|
64
|
+
const baseCompareFn = options?.compareFn ?? defaultComparator
|
|
65
|
+
|
|
66
|
+
// Wrap it to denormalize sentinels before comparison
|
|
67
|
+
// This ensures UNDEFINED_SENTINEL is converted back to undefined
|
|
68
|
+
// before being passed to the baseCompareFn (which can be user-provided and is unaware of the UNDEFINED_SENTINEL)
|
|
69
|
+
this.compareFn = (a: any, b: any) =>
|
|
70
|
+
baseCompareFn(denormalizeUndefined(a), denormalizeUndefined(b))
|
|
71
|
+
|
|
59
72
|
if (options?.compareOptions) {
|
|
60
73
|
this.compareOptions = options!.compareOptions
|
|
61
74
|
}
|
|
@@ -78,7 +91,7 @@ export class BTreeIndex<
|
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
// Normalize the value for Map key usage
|
|
81
|
-
const normalizedValue =
|
|
94
|
+
const normalizedValue = normalizeForBTree(indexedValue)
|
|
82
95
|
|
|
83
96
|
// Check if this value already exists
|
|
84
97
|
if (this.valueMap.has(normalizedValue)) {
|
|
@@ -111,7 +124,7 @@ export class BTreeIndex<
|
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
// Normalize the value for Map key usage
|
|
114
|
-
const normalizedValue =
|
|
127
|
+
const normalizedValue = normalizeForBTree(indexedValue)
|
|
115
128
|
|
|
116
129
|
if (this.valueMap.has(normalizedValue)) {
|
|
117
130
|
const keySet = this.valueMap.get(normalizedValue)!
|
|
@@ -207,7 +220,7 @@ export class BTreeIndex<
|
|
|
207
220
|
* Performs an equality lookup
|
|
208
221
|
*/
|
|
209
222
|
equalityLookup(value: any): Set<TKey> {
|
|
210
|
-
const normalizedValue =
|
|
223
|
+
const normalizedValue = normalizeForBTree(value)
|
|
211
224
|
return new Set(this.valueMap.get(normalizedValue) ?? [])
|
|
212
225
|
}
|
|
213
226
|
|
|
@@ -219,10 +232,15 @@ export class BTreeIndex<
|
|
|
219
232
|
const { from, to, fromInclusive = true, toInclusive = true } = options
|
|
220
233
|
const result = new Set<TKey>()
|
|
221
234
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
const
|
|
235
|
+
// Check if from/to were explicitly provided (even if undefined)
|
|
236
|
+
// vs not provided at all (should use min/max key)
|
|
237
|
+
const hasFrom = `from` in options
|
|
238
|
+
const hasTo = `to` in options
|
|
239
|
+
|
|
240
|
+
const fromKey = hasFrom
|
|
241
|
+
? normalizeForBTree(from)
|
|
242
|
+
: this.orderedEntries.minKey()
|
|
243
|
+
const toKey = hasTo ? normalizeForBTree(to) : this.orderedEntries.maxKey()
|
|
226
244
|
|
|
227
245
|
this.orderedEntries.forRange(
|
|
228
246
|
fromKey,
|
|
@@ -250,29 +268,43 @@ export class BTreeIndex<
|
|
|
250
268
|
*/
|
|
251
269
|
rangeQueryReversed(options: RangeQueryOptions = {}): Set<TKey> {
|
|
252
270
|
const { from, to, fromInclusive = true, toInclusive = true } = options
|
|
271
|
+
const hasFrom = `from` in options
|
|
272
|
+
const hasTo = `to` in options
|
|
273
|
+
|
|
274
|
+
// Swap from/to for reversed query, respecting explicit undefined values
|
|
253
275
|
return this.rangeQuery({
|
|
254
|
-
from: to
|
|
255
|
-
to: from
|
|
276
|
+
from: hasTo ? to : this.orderedEntries.maxKey(),
|
|
277
|
+
to: hasFrom ? from : this.orderedEntries.minKey(),
|
|
256
278
|
fromInclusive: toInclusive,
|
|
257
279
|
toInclusive: fromInclusive,
|
|
258
280
|
})
|
|
259
281
|
}
|
|
260
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Internal method for taking items from the index.
|
|
285
|
+
* @param n - The number of items to return
|
|
286
|
+
* @param nextPair - Function to get the next pair from the BTree
|
|
287
|
+
* @param from - Already normalized! undefined means "start from beginning/end", sentinel means "start from the key undefined"
|
|
288
|
+
* @param filterFn - Optional filter function
|
|
289
|
+
* @param reversed - Whether to reverse the order of keys within each value
|
|
290
|
+
*/
|
|
261
291
|
private takeInternal(
|
|
262
292
|
n: number,
|
|
263
293
|
nextPair: (k?: any) => [any, any] | undefined,
|
|
264
|
-
from
|
|
294
|
+
from: any,
|
|
265
295
|
filterFn?: (key: TKey) => boolean,
|
|
266
296
|
reversed: boolean = false,
|
|
267
297
|
): Array<TKey> {
|
|
268
298
|
const keysInResult: Set<TKey> = new Set()
|
|
269
299
|
const result: Array<TKey> = []
|
|
270
300
|
let pair: [any, any] | undefined
|
|
271
|
-
let key =
|
|
301
|
+
let key = from // Use as-is - it's already normalized by the caller
|
|
272
302
|
|
|
273
303
|
while ((pair = nextPair(key)) !== undefined && result.length < n) {
|
|
274
304
|
key = pair[0]
|
|
275
|
-
const keys = this.valueMap.get(key)
|
|
305
|
+
const keys = this.valueMap.get(key) as
|
|
306
|
+
| Set<Exclude<TKey, undefined>>
|
|
307
|
+
| undefined
|
|
276
308
|
if (keys && keys.size > 0) {
|
|
277
309
|
// Sort keys for deterministic order, reverse if needed
|
|
278
310
|
const sorted = Array.from(keys).sort(compareKeys)
|
|
@@ -291,29 +323,60 @@ export class BTreeIndex<
|
|
|
291
323
|
}
|
|
292
324
|
|
|
293
325
|
/**
|
|
294
|
-
* Returns the next n items after the provided item
|
|
326
|
+
* Returns the next n items after the provided item.
|
|
295
327
|
* @param n - The number of items to return
|
|
296
|
-
* @param from - The item to start from (exclusive).
|
|
297
|
-
* @returns The next n items after the provided key.
|
|
328
|
+
* @param from - The item to start from (exclusive).
|
|
329
|
+
* @returns The next n items after the provided key.
|
|
298
330
|
*/
|
|
299
|
-
take(n: number, from
|
|
331
|
+
take(n: number, from: any, filterFn?: (key: TKey) => boolean): Array<TKey> {
|
|
300
332
|
const nextPair = (k?: any) => this.orderedEntries.nextHigherPair(k)
|
|
301
|
-
|
|
333
|
+
// Normalize the from value
|
|
334
|
+
const normalizedFrom = normalizeForBTree(from)
|
|
335
|
+
return this.takeInternal(n, nextPair, normalizedFrom, filterFn)
|
|
302
336
|
}
|
|
303
337
|
|
|
304
338
|
/**
|
|
305
|
-
* Returns the
|
|
339
|
+
* Returns the first n items from the beginning.
|
|
306
340
|
* @param n - The number of items to return
|
|
307
|
-
* @param
|
|
308
|
-
* @returns The
|
|
341
|
+
* @param filterFn - Optional filter function
|
|
342
|
+
* @returns The first n items
|
|
343
|
+
*/
|
|
344
|
+
takeFromStart(n: number, filterFn?: (key: TKey) => boolean): Array<TKey> {
|
|
345
|
+
const nextPair = (k?: any) => this.orderedEntries.nextHigherPair(k)
|
|
346
|
+
// Pass undefined to mean "start from beginning" (BTree's native behavior)
|
|
347
|
+
return this.takeInternal(n, nextPair, undefined, filterFn)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Returns the next n items **before** the provided item (in descending order).
|
|
352
|
+
* @param n - The number of items to return
|
|
353
|
+
* @param from - The item to start from (exclusive). Required.
|
|
354
|
+
* @returns The next n items **before** the provided key.
|
|
309
355
|
*/
|
|
310
356
|
takeReversed(
|
|
311
357
|
n: number,
|
|
312
|
-
from
|
|
358
|
+
from: any,
|
|
359
|
+
filterFn?: (key: TKey) => boolean,
|
|
360
|
+
): Array<TKey> {
|
|
361
|
+
const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k)
|
|
362
|
+
// Normalize the from value
|
|
363
|
+
const normalizedFrom = normalizeForBTree(from)
|
|
364
|
+
return this.takeInternal(n, nextPair, normalizedFrom, filterFn, true)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Returns the last n items from the end.
|
|
369
|
+
* @param n - The number of items to return
|
|
370
|
+
* @param filterFn - Optional filter function
|
|
371
|
+
* @returns The last n items
|
|
372
|
+
*/
|
|
373
|
+
takeReversedFromEnd(
|
|
374
|
+
n: number,
|
|
313
375
|
filterFn?: (key: TKey) => boolean,
|
|
314
376
|
): Array<TKey> {
|
|
315
377
|
const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k)
|
|
316
|
-
|
|
378
|
+
// Pass undefined to mean "start from end" (BTree's native behavior)
|
|
379
|
+
return this.takeInternal(n, nextPair, undefined, filterFn, true)
|
|
317
380
|
}
|
|
318
381
|
|
|
319
382
|
/**
|
|
@@ -323,7 +386,7 @@ export class BTreeIndex<
|
|
|
323
386
|
const result = new Set<TKey>()
|
|
324
387
|
|
|
325
388
|
for (const value of values) {
|
|
326
|
-
const normalizedValue =
|
|
389
|
+
const normalizedValue = normalizeForBTree(value)
|
|
327
390
|
const keys = this.valueMap.get(normalizedValue)
|
|
328
391
|
if (keys) {
|
|
329
392
|
keys.forEach((key) => result.add(key))
|
|
@@ -341,17 +404,25 @@ export class BTreeIndex<
|
|
|
341
404
|
get orderedEntriesArray(): Array<[any, Set<TKey>]> {
|
|
342
405
|
return this.orderedEntries
|
|
343
406
|
.keysArray()
|
|
344
|
-
.map((key) => [
|
|
407
|
+
.map((key) => [
|
|
408
|
+
denormalizeUndefined(key),
|
|
409
|
+
this.valueMap.get(key) ?? new Set(),
|
|
410
|
+
])
|
|
345
411
|
}
|
|
346
412
|
|
|
347
413
|
get orderedEntriesArrayReversed(): Array<[any, Set<TKey>]> {
|
|
348
|
-
return this.
|
|
349
|
-
key,
|
|
414
|
+
return this.takeReversedFromEnd(this.orderedEntries.size).map((key) => [
|
|
415
|
+
denormalizeUndefined(key),
|
|
350
416
|
this.valueMap.get(key) ?? new Set(),
|
|
351
417
|
])
|
|
352
418
|
}
|
|
353
419
|
|
|
354
420
|
get valueMapData(): Map<any, Set<TKey>> {
|
|
355
|
-
|
|
421
|
+
// Return a new Map with denormalized keys
|
|
422
|
+
const result = new Map<any, Set<TKey>>()
|
|
423
|
+
for (const [key, value] of this.valueMap) {
|
|
424
|
+
result.set(denormalizeUndefined(key), value)
|
|
425
|
+
}
|
|
426
|
+
return result
|
|
356
427
|
}
|
|
357
428
|
}
|
|
@@ -36,18 +36,29 @@ export class ReverseIndex<
|
|
|
36
36
|
return this.originalIndex.rangeQuery(options)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
take(n: number, from
|
|
39
|
+
take(n: number, from: any, filterFn?: (key: TKey) => boolean): Array<TKey> {
|
|
40
40
|
return this.originalIndex.takeReversed(n, from, filterFn)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
takeFromStart(n: number, filterFn?: (key: TKey) => boolean): Array<TKey> {
|
|
44
|
+
return this.originalIndex.takeReversedFromEnd(n, filterFn)
|
|
45
|
+
}
|
|
46
|
+
|
|
43
47
|
takeReversed(
|
|
44
48
|
n: number,
|
|
45
|
-
from
|
|
49
|
+
from: any,
|
|
46
50
|
filterFn?: (key: TKey) => boolean,
|
|
47
51
|
): Array<TKey> {
|
|
48
52
|
return this.originalIndex.take(n, from, filterFn)
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
takeReversedFromEnd(
|
|
56
|
+
n: number,
|
|
57
|
+
filterFn?: (key: TKey) => boolean,
|
|
58
|
+
): Array<TKey> {
|
|
59
|
+
return this.originalIndex.takeFromStart(n, filterFn)
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
get orderedEntriesArray(): Array<[any, Set<TKey>]> {
|
|
52
63
|
return this.originalIndex.orderedEntriesArrayReversed
|
|
53
64
|
}
|
|
@@ -830,17 +830,16 @@ export class CollectionConfigBuilder<
|
|
|
830
830
|
return
|
|
831
831
|
}
|
|
832
832
|
|
|
833
|
+
const subscribedToAll = this.currentSyncState?.subscribedToAllCollections
|
|
834
|
+
const allReady = this.allCollectionsReady()
|
|
835
|
+
const isLoading = this.liveQueryCollection?.isLoadingSubset
|
|
833
836
|
// Mark ready when:
|
|
834
837
|
// 1. All subscriptions are set up (subscribedToAllCollections)
|
|
835
838
|
// 2. All source collections are ready
|
|
836
839
|
// 3. The live query collection is not loading subset data
|
|
837
840
|
// This prevents marking the live query ready before its data is processed
|
|
838
841
|
// (fixes issue where useLiveQuery returns isReady=true with empty data)
|
|
839
|
-
if (
|
|
840
|
-
this.currentSyncState?.subscribedToAllCollections &&
|
|
841
|
-
this.allCollectionsReady() &&
|
|
842
|
-
!this.liveQueryCollection?.isLoadingSubset
|
|
843
|
-
) {
|
|
842
|
+
if (subscribedToAll && allReady && !isLoading) {
|
|
844
843
|
markReady()
|
|
845
844
|
}
|
|
846
845
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MultiSet } from '@tanstack/db-ivm'
|
|
1
|
+
import { MultiSet, serializeValue } from '@tanstack/db-ivm'
|
|
2
2
|
import {
|
|
3
3
|
normalizeExpressionPaths,
|
|
4
4
|
normalizeOrderByPaths,
|
|
@@ -26,6 +26,11 @@ export class CollectionSubscriber<
|
|
|
26
26
|
// Keep track of the biggest value we've sent so far (needed for orderBy optimization)
|
|
27
27
|
private biggest: any = undefined
|
|
28
28
|
|
|
29
|
+
// Track the most recent ordered load request key (cursor + window).
|
|
30
|
+
// This avoids infinite loops from cached data re-writes while still allowing
|
|
31
|
+
// window moves or new keys at the same cursor value to trigger new requests.
|
|
32
|
+
private lastLoadRequestKey: string | undefined
|
|
33
|
+
|
|
29
34
|
// Track deferred promises for subscription loading states
|
|
30
35
|
private subscriptionLoadingPromises = new Map<
|
|
31
36
|
CollectionSubscription,
|
|
@@ -37,6 +42,11 @@ export class CollectionSubscriber<
|
|
|
37
42
|
// can potentially send the same item to D2 multiple times.
|
|
38
43
|
private sentToD2Keys = new Set<string | number>()
|
|
39
44
|
|
|
45
|
+
// Direct load tracking callback for ordered path (set during subscribeToOrderedChanges,
|
|
46
|
+
// used by loadNextItems for subsequent requestLimitedSnapshot calls)
|
|
47
|
+
private orderedLoadSubsetResult?: (result: Promise<void> | true) => void
|
|
48
|
+
private pendingOrderedLoadPromise: Promise<void> | undefined
|
|
49
|
+
|
|
40
50
|
constructor(
|
|
41
51
|
private alias: string,
|
|
42
52
|
private collectionId: string,
|
|
@@ -58,35 +68,29 @@ export class CollectionSubscriber<
|
|
|
58
68
|
private subscribeToChanges(whereExpression?: BasicExpression<boolean>) {
|
|
59
69
|
const orderByInfo = this.getOrderByInfo()
|
|
60
70
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const promise = new Promise<void>((res) => {
|
|
67
|
-
resolve = res
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
this.subscriptionLoadingPromises.set(subscription, {
|
|
71
|
-
resolve: resolve!,
|
|
72
|
-
})
|
|
71
|
+
// Direct load promise tracking: pipes loadSubset results straight to the
|
|
72
|
+
// live query collection, avoiding the multi-hop deferred promise chain that
|
|
73
|
+
// can break under microtask timing (e.g., queueMicrotask in TanStack Query).
|
|
74
|
+
const trackLoadResult = (result: Promise<void> | true) => {
|
|
75
|
+
if (result instanceof Promise) {
|
|
73
76
|
this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise(
|
|
74
|
-
|
|
77
|
+
result,
|
|
75
78
|
)
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
// Status change handler - passed to subscribeChanges so it's registered
|
|
80
|
-
// BEFORE any snapshot is requested, preventing race conditions
|
|
83
|
+
// BEFORE any snapshot is requested, preventing race conditions.
|
|
84
|
+
// Used as a fallback for status transitions not covered by direct tracking
|
|
85
|
+
// (e.g., truncate-triggered reloads that call trackLoadSubsetPromise directly).
|
|
81
86
|
const onStatusChange = (event: SubscriptionStatusChangeEvent) => {
|
|
82
87
|
const subscription = event.subscription as CollectionSubscription
|
|
83
88
|
if (event.status === `loadingSubset`) {
|
|
84
|
-
|
|
89
|
+
this.ensureLoadingPromise(subscription)
|
|
85
90
|
} else {
|
|
86
91
|
// status is 'ready'
|
|
87
92
|
const deferred = this.subscriptionLoadingPromises.get(subscription)
|
|
88
93
|
if (deferred) {
|
|
89
|
-
// Clear the map entry FIRST (before resolving)
|
|
90
94
|
this.subscriptionLoadingPromises.delete(subscription)
|
|
91
95
|
deferred.resolve()
|
|
92
96
|
}
|
|
@@ -100,6 +104,7 @@ export class CollectionSubscriber<
|
|
|
100
104
|
whereExpression,
|
|
101
105
|
orderByInfo,
|
|
102
106
|
onStatusChange,
|
|
107
|
+
trackLoadResult,
|
|
103
108
|
)
|
|
104
109
|
} else {
|
|
105
110
|
// If the source alias is lazy then we should not include the initial state
|
|
@@ -117,14 +122,13 @@ export class CollectionSubscriber<
|
|
|
117
122
|
// Check current status after subscribing - if status is 'loadingSubset', track it.
|
|
118
123
|
// The onStatusChange listener will catch the transition to 'ready'.
|
|
119
124
|
if (subscription.status === `loadingSubset`) {
|
|
120
|
-
|
|
125
|
+
this.ensureLoadingPromise(subscription)
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
const unsubscribe = () => {
|
|
124
129
|
// If subscription has a pending promise, resolve it before unsubscribing
|
|
125
130
|
const deferred = this.subscriptionLoadingPromises.get(subscription)
|
|
126
131
|
if (deferred) {
|
|
127
|
-
// Clear the map entry FIRST (before resolving)
|
|
128
132
|
this.subscriptionLoadingPromises.delete(subscription)
|
|
129
133
|
deferred.resolve()
|
|
130
134
|
}
|
|
@@ -197,13 +201,49 @@ export class CollectionSubscriber<
|
|
|
197
201
|
this.sendChangesToPipeline(changes)
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
204
|
+
// Get the query's orderBy and limit to pass to loadSubset.
|
|
205
|
+
// Only include orderBy when it is scoped to this alias and uses simple refs,
|
|
206
|
+
// to avoid leaking cross-collection paths into backend-specific compilers.
|
|
207
|
+
const { orderBy, limit, offset } = this.collectionConfigBuilder.query
|
|
208
|
+
const effectiveLimit =
|
|
209
|
+
limit !== undefined && offset !== undefined ? limit + offset : limit
|
|
210
|
+
const normalizedOrderBy = orderBy
|
|
211
|
+
? normalizeOrderByPaths(orderBy, this.alias)
|
|
212
|
+
: undefined
|
|
213
|
+
const canPassOrderBy =
|
|
214
|
+
normalizedOrderBy?.every((clause) => {
|
|
215
|
+
const exp = clause.expression
|
|
216
|
+
if (exp.type !== `ref`) {
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
const path = exp.path
|
|
220
|
+
return Array.isArray(path) && path.length === 1
|
|
221
|
+
}) ?? false
|
|
222
|
+
const orderByForSubscription = canPassOrderBy
|
|
223
|
+
? normalizedOrderBy
|
|
224
|
+
: undefined
|
|
225
|
+
const limitForSubscription = canPassOrderBy ? effectiveLimit : undefined
|
|
226
|
+
|
|
227
|
+
// Track loading via the loadSubset promise directly.
|
|
228
|
+
// requestSnapshot uses trackLoadSubsetPromise: false (needed for truncate handling),
|
|
229
|
+
// so we use onLoadSubsetResult to get the promise and track it ourselves.
|
|
230
|
+
const onLoadSubsetResult = includeInitialState
|
|
231
|
+
? (result: Promise<void> | true) => {
|
|
232
|
+
if (result instanceof Promise) {
|
|
233
|
+
this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise(
|
|
234
|
+
result,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
: undefined
|
|
239
|
+
|
|
203
240
|
const subscription = this.collection.subscribeChanges(sendChanges, {
|
|
204
241
|
...(includeInitialState && { includeInitialState }),
|
|
205
242
|
whereExpression,
|
|
206
243
|
onStatusChange,
|
|
244
|
+
orderBy: orderByForSubscription,
|
|
245
|
+
limit: limitForSubscription,
|
|
246
|
+
onLoadSubsetResult,
|
|
207
247
|
})
|
|
208
248
|
|
|
209
249
|
return subscription
|
|
@@ -213,17 +253,39 @@ export class CollectionSubscriber<
|
|
|
213
253
|
whereExpression: BasicExpression<boolean> | undefined,
|
|
214
254
|
orderByInfo: OrderByOptimizationInfo,
|
|
215
255
|
onStatusChange: (event: SubscriptionStatusChangeEvent) => void,
|
|
256
|
+
onLoadSubsetResult: (result: Promise<void> | true) => void,
|
|
216
257
|
): CollectionSubscription {
|
|
217
258
|
const { orderBy, offset, limit, index } = orderByInfo
|
|
218
259
|
|
|
260
|
+
// Store the callback so loadNextItems can also use direct tracking.
|
|
261
|
+
// Track in-flight ordered loads to avoid issuing redundant requests while
|
|
262
|
+
// a previous snapshot is still pending.
|
|
263
|
+
const handleLoadSubsetResult = (result: Promise<void> | true) => {
|
|
264
|
+
if (result instanceof Promise) {
|
|
265
|
+
this.pendingOrderedLoadPromise = result
|
|
266
|
+
result.finally(() => {
|
|
267
|
+
if (this.pendingOrderedLoadPromise === result) {
|
|
268
|
+
this.pendingOrderedLoadPromise = undefined
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
onLoadSubsetResult(result)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.orderedLoadSubsetResult = handleLoadSubsetResult
|
|
276
|
+
|
|
219
277
|
// Use a holder to forward-reference subscription in the callback
|
|
220
278
|
const subscriptionHolder: { current?: CollectionSubscription } = {}
|
|
221
279
|
|
|
222
280
|
const sendChangesInRange = (
|
|
223
281
|
changes: Iterable<ChangeMessage<any, string | number>>,
|
|
224
282
|
) => {
|
|
283
|
+
const changesArray = Array.isArray(changes) ? changes : [...changes]
|
|
284
|
+
|
|
285
|
+
this.trackSentValues(changesArray, orderByInfo.comparator)
|
|
286
|
+
|
|
225
287
|
// Split live updates into a delete of the old value and an insert of the new value
|
|
226
|
-
const splittedChanges = splitUpdates(
|
|
288
|
+
const splittedChanges = splitUpdates(changesArray)
|
|
227
289
|
this.sendChangesToPipelineWithTracking(
|
|
228
290
|
splittedChanges,
|
|
229
291
|
subscriptionHolder.current!,
|
|
@@ -243,6 +305,8 @@ export class CollectionSubscriber<
|
|
|
243
305
|
// and allow re-inserts of previously sent keys
|
|
244
306
|
const truncateUnsubscribe = this.collection.on(`truncate`, () => {
|
|
245
307
|
this.biggest = undefined
|
|
308
|
+
this.lastLoadRequestKey = undefined
|
|
309
|
+
this.pendingOrderedLoadPromise = undefined
|
|
246
310
|
this.sentToD2Keys.clear()
|
|
247
311
|
})
|
|
248
312
|
|
|
@@ -254,26 +318,27 @@ export class CollectionSubscriber<
|
|
|
254
318
|
// Normalize the orderBy clauses such that the references are relative to the collection
|
|
255
319
|
const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
|
|
256
320
|
|
|
257
|
-
// Trigger the snapshot request
|
|
321
|
+
// Trigger the snapshot request — use direct load tracking (trackLoadSubsetPromise: false)
|
|
322
|
+
// to pipe the loadSubset result straight to the live query collection. This bypasses
|
|
323
|
+
// the subscription status → onStatusChange → deferred promise chain which is fragile
|
|
324
|
+
// under microtask timing (e.g., queueMicrotask delays in TanStack Query observers).
|
|
258
325
|
if (index) {
|
|
259
326
|
// We have an index on the first orderBy column - use lazy loading optimization
|
|
260
|
-
// This works for both single-column and multi-column orderBy:
|
|
261
|
-
// - Single-column: index provides exact ordering
|
|
262
|
-
// - Multi-column: index provides ordering on first column, secondary sort in memory
|
|
263
327
|
subscription.setOrderByIndex(index)
|
|
264
328
|
|
|
265
|
-
// Load the first `offset + limit` values from the index
|
|
266
|
-
// i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[
|
|
267
329
|
subscription.requestLimitedSnapshot({
|
|
268
330
|
limit: offset + limit,
|
|
269
331
|
orderBy: normalizedOrderBy,
|
|
332
|
+
trackLoadSubsetPromise: false,
|
|
333
|
+
onLoadSubsetResult: handleLoadSubsetResult,
|
|
270
334
|
})
|
|
271
335
|
} else {
|
|
272
336
|
// No index available (e.g., non-ref expression): pass orderBy/limit to loadSubset
|
|
273
|
-
// so the sync layer can optimize if the backend supports it
|
|
274
337
|
subscription.requestSnapshot({
|
|
275
338
|
orderBy: normalizedOrderBy,
|
|
276
339
|
limit: offset + limit,
|
|
340
|
+
trackLoadSubsetPromise: false,
|
|
341
|
+
onLoadSubsetResult: handleLoadSubsetResult,
|
|
277
342
|
})
|
|
278
343
|
}
|
|
279
344
|
|
|
@@ -301,6 +366,11 @@ export class CollectionSubscriber<
|
|
|
301
366
|
return true
|
|
302
367
|
}
|
|
303
368
|
|
|
369
|
+
if (this.pendingOrderedLoadPromise) {
|
|
370
|
+
// Wait for in-flight ordered loads to resolve before issuing another request.
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
|
|
304
374
|
// `dataNeeded` probes the orderBy operator to see if it needs more data
|
|
305
375
|
// if it needs more data, it returns the number of items it needs
|
|
306
376
|
const n = dataNeeded()
|
|
@@ -320,8 +390,6 @@ export class CollectionSubscriber<
|
|
|
320
390
|
return
|
|
321
391
|
}
|
|
322
392
|
|
|
323
|
-
const trackedChanges = this.trackSentValues(changes, orderByInfo.comparator)
|
|
324
|
-
|
|
325
393
|
// Cache the loadMoreIfNeeded callback on the subscription using a symbol property.
|
|
326
394
|
// This ensures we pass the same function instance to the scheduler each time,
|
|
327
395
|
// allowing it to deduplicate callbacks when multiple changes arrive during a transaction.
|
|
@@ -335,7 +403,7 @@ export class CollectionSubscriber<
|
|
|
335
403
|
this.loadMoreIfNeeded.bind(this, subscription)
|
|
336
404
|
|
|
337
405
|
this.sendChangesToPipeline(
|
|
338
|
-
|
|
406
|
+
changes,
|
|
339
407
|
subscriptionWithLoader[loadMoreCallbackSymbol],
|
|
340
408
|
)
|
|
341
409
|
}
|
|
@@ -357,12 +425,25 @@ export class CollectionSubscriber<
|
|
|
357
425
|
: undefined
|
|
358
426
|
|
|
359
427
|
// Normalize to array format for minValues
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
428
|
+
let minValues: Array<unknown> | undefined
|
|
429
|
+
if (extractedValues !== undefined) {
|
|
430
|
+
minValues = Array.isArray(extractedValues)
|
|
431
|
+
? extractedValues
|
|
432
|
+
: [extractedValues]
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const loadRequestKey = this.getLoadRequestKey({
|
|
436
|
+
minValues,
|
|
437
|
+
offset,
|
|
438
|
+
limit: n,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// Skip if we already requested a load for this cursor+window.
|
|
442
|
+
// This prevents infinite loops from cached data re-writes while still allowing
|
|
443
|
+
// window moves (offset/limit changes) to trigger new requests.
|
|
444
|
+
if (this.lastLoadRequestKey === loadRequestKey) {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
366
447
|
|
|
367
448
|
// Normalize the orderBy clauses such that the references are relative to the collection
|
|
368
449
|
const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
|
|
@@ -373,8 +454,13 @@ export class CollectionSubscriber<
|
|
|
373
454
|
orderBy: normalizedOrderBy,
|
|
374
455
|
limit: n,
|
|
375
456
|
minValues,
|
|
376
|
-
offset
|
|
457
|
+
// Omit offset so requestLimitedSnapshot can advance the offset based on
|
|
458
|
+
// the number of rows already loaded (supports offset-based backends).
|
|
459
|
+
trackLoadSubsetPromise: false,
|
|
460
|
+
onLoadSubsetResult: this.orderedLoadSubsetResult,
|
|
377
461
|
})
|
|
462
|
+
|
|
463
|
+
this.lastLoadRequestKey = loadRequestKey
|
|
378
464
|
}
|
|
379
465
|
|
|
380
466
|
private getWhereClauseForAlias(): BasicExpression<boolean> | undefined {
|
|
@@ -397,22 +483,59 @@ export class CollectionSubscriber<
|
|
|
397
483
|
return undefined
|
|
398
484
|
}
|
|
399
485
|
|
|
400
|
-
private
|
|
401
|
-
changes:
|
|
486
|
+
private trackSentValues(
|
|
487
|
+
changes: Array<ChangeMessage<any, string | number>>,
|
|
402
488
|
comparator: (a: any, b: any) => number,
|
|
403
|
-
) {
|
|
489
|
+
): void {
|
|
404
490
|
for (const change of changes) {
|
|
491
|
+
if (change.type === `delete`) {
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const isNewKey = !this.sentToD2Keys.has(change.key)
|
|
496
|
+
|
|
405
497
|
// Only track inserts/updates for cursor positioning, not deletes
|
|
406
|
-
if (
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
498
|
+
if (!this.biggest) {
|
|
499
|
+
this.biggest = change.value
|
|
500
|
+
this.lastLoadRequestKey = undefined
|
|
501
|
+
} else if (comparator(this.biggest, change.value) < 0) {
|
|
502
|
+
this.biggest = change.value
|
|
503
|
+
this.lastLoadRequestKey = undefined
|
|
504
|
+
} else if (isNewKey) {
|
|
505
|
+
// New key with same orderBy value - allow another load if needed
|
|
506
|
+
this.lastLoadRequestKey = undefined
|
|
412
507
|
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
413
510
|
|
|
414
|
-
|
|
511
|
+
private ensureLoadingPromise(subscription: CollectionSubscription) {
|
|
512
|
+
if (this.subscriptionLoadingPromises.has(subscription)) {
|
|
513
|
+
return
|
|
415
514
|
}
|
|
515
|
+
|
|
516
|
+
let resolve: () => void
|
|
517
|
+
const promise = new Promise<void>((res) => {
|
|
518
|
+
resolve = res
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
this.subscriptionLoadingPromises.set(subscription, {
|
|
522
|
+
resolve: resolve!,
|
|
523
|
+
})
|
|
524
|
+
this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise(
|
|
525
|
+
promise,
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private getLoadRequestKey(options: {
|
|
530
|
+
minValues: Array<unknown> | undefined
|
|
531
|
+
offset: number
|
|
532
|
+
limit: number
|
|
533
|
+
}): string {
|
|
534
|
+
return serializeValue({
|
|
535
|
+
minValues: options.minValues ?? null,
|
|
536
|
+
offset: options.offset,
|
|
537
|
+
limit: options.limit,
|
|
538
|
+
})
|
|
416
539
|
}
|
|
417
540
|
}
|
|
418
541
|
|