@tanstack/query-db-collection 0.3.0 → 1.0.1
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 +29 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +1 -0
- package/dist/cjs/query.cjs +255 -100
- package/dist/cjs/query.cjs.map +1 -1
- package/dist/cjs/query.d.cts +3 -2
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +9 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/query.d.ts +3 -2
- package/dist/esm/query.js +256 -101
- package/dist/esm/query.js.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +15 -0
- package/src/query.ts +401 -162
package/src/query.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { QueryObserver } from "@tanstack/query-core"
|
|
1
|
+
import { QueryObserver, hashKey } from "@tanstack/query-core"
|
|
2
2
|
import {
|
|
3
3
|
GetKeyRequiredError,
|
|
4
4
|
QueryClientRequiredError,
|
|
@@ -6,23 +6,25 @@ import {
|
|
|
6
6
|
QueryKeyRequiredError,
|
|
7
7
|
} from "./errors"
|
|
8
8
|
import { createWriteUtils } from "./manual-sync"
|
|
9
|
-
import type {
|
|
10
|
-
QueryClient,
|
|
11
|
-
QueryFunctionContext,
|
|
12
|
-
QueryKey,
|
|
13
|
-
QueryObserverOptions,
|
|
14
|
-
QueryObserverResult,
|
|
15
|
-
} from "@tanstack/query-core"
|
|
16
9
|
import type {
|
|
17
10
|
BaseCollectionConfig,
|
|
18
11
|
ChangeMessage,
|
|
19
12
|
CollectionConfig,
|
|
20
13
|
DeleteMutationFnParams,
|
|
21
14
|
InsertMutationFnParams,
|
|
15
|
+
LoadSubsetOptions,
|
|
22
16
|
SyncConfig,
|
|
23
17
|
UpdateMutationFnParams,
|
|
24
18
|
UtilsRecord,
|
|
25
19
|
} from "@tanstack/db"
|
|
20
|
+
import type {
|
|
21
|
+
FetchStatus,
|
|
22
|
+
QueryClient,
|
|
23
|
+
QueryFunctionContext,
|
|
24
|
+
QueryKey,
|
|
25
|
+
QueryObserverOptions,
|
|
26
|
+
QueryObserverResult,
|
|
27
|
+
} from "@tanstack/query-core"
|
|
26
28
|
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
|
27
29
|
|
|
28
30
|
// Re-export for external use
|
|
@@ -42,6 +44,8 @@ type InferSchemaInput<T> = T extends StandardSchemaV1
|
|
|
42
44
|
: Record<string, unknown>
|
|
43
45
|
: Record<string, unknown>
|
|
44
46
|
|
|
47
|
+
type TQueryKeyBuilder<TQueryKey> = (opts: LoadSubsetOptions) => TQueryKey
|
|
48
|
+
|
|
45
49
|
/**
|
|
46
50
|
* Configuration options for creating a Query Collection
|
|
47
51
|
* @template T - The explicit type of items stored in the collection
|
|
@@ -63,7 +67,7 @@ export interface QueryCollectionConfig<
|
|
|
63
67
|
TQueryData = Awaited<ReturnType<TQueryFn>>,
|
|
64
68
|
> extends BaseCollectionConfig<T, TKey, TSchema> {
|
|
65
69
|
/** The query key used by TanStack Query to identify this query */
|
|
66
|
-
queryKey: TQueryKey
|
|
70
|
+
queryKey: TQueryKey | TQueryKeyBuilder<TQueryKey>
|
|
67
71
|
/** Function that fetches data from the server. Must return the complete collection state */
|
|
68
72
|
queryFn: TQueryFn extends (
|
|
69
73
|
context: QueryFunctionContext<TQueryKey>
|
|
@@ -201,9 +205,10 @@ interface QueryCollectionState {
|
|
|
201
205
|
lastError: any
|
|
202
206
|
errorCount: number
|
|
203
207
|
lastErrorUpdatedAt: number
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
208
|
+
observers: Map<
|
|
209
|
+
string,
|
|
210
|
+
QueryObserver<Array<any>, any, Array<any>, Array<any>, any>
|
|
211
|
+
>
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
/**
|
|
@@ -261,23 +266,40 @@ class QueryCollectionUtilsImpl {
|
|
|
261
266
|
|
|
262
267
|
// Getters for QueryObserver state
|
|
263
268
|
public get isFetching() {
|
|
264
|
-
|
|
269
|
+
// check if any observer is fetching
|
|
270
|
+
return Array.from(this.state.observers.values()).some(
|
|
271
|
+
(observer) => observer.getCurrentResult().isFetching
|
|
272
|
+
)
|
|
265
273
|
}
|
|
266
274
|
|
|
267
275
|
public get isRefetching() {
|
|
268
|
-
|
|
276
|
+
// check if any observer is refetching
|
|
277
|
+
return Array.from(this.state.observers.values()).some(
|
|
278
|
+
(observer) => observer.getCurrentResult().isRefetching
|
|
279
|
+
)
|
|
269
280
|
}
|
|
270
281
|
|
|
271
282
|
public get isLoading() {
|
|
272
|
-
|
|
283
|
+
// check if any observer is loading
|
|
284
|
+
return Array.from(this.state.observers.values()).some(
|
|
285
|
+
(observer) => observer.getCurrentResult().isLoading
|
|
286
|
+
)
|
|
273
287
|
}
|
|
274
288
|
|
|
275
289
|
public get dataUpdatedAt() {
|
|
276
|
-
|
|
290
|
+
// compute the max dataUpdatedAt of all observers
|
|
291
|
+
return Math.max(
|
|
292
|
+
0,
|
|
293
|
+
...Array.from(this.state.observers.values()).map(
|
|
294
|
+
(observer) => observer.getCurrentResult().dataUpdatedAt
|
|
295
|
+
)
|
|
296
|
+
)
|
|
277
297
|
}
|
|
278
298
|
|
|
279
|
-
public get fetchStatus() {
|
|
280
|
-
return this.state.
|
|
299
|
+
public get fetchStatus(): Array<FetchStatus> {
|
|
300
|
+
return Array.from(this.state.observers.values()).map(
|
|
301
|
+
(observer) => observer.getCurrentResult().fetchStatus
|
|
302
|
+
)
|
|
281
303
|
}
|
|
282
304
|
}
|
|
283
305
|
|
|
@@ -522,6 +544,9 @@ export function queryCollectionOptions(
|
|
|
522
544
|
...baseCollectionConfig
|
|
523
545
|
} = config
|
|
524
546
|
|
|
547
|
+
// Default to eager sync mode if not provided
|
|
548
|
+
const syncMode = baseCollectionConfig.syncMode ?? `eager`
|
|
549
|
+
|
|
525
550
|
// Validate required parameters
|
|
526
551
|
|
|
527
552
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
@@ -548,181 +573,394 @@ export function queryCollectionOptions(
|
|
|
548
573
|
lastError: undefined as any,
|
|
549
574
|
errorCount: 0,
|
|
550
575
|
lastErrorUpdatedAt: 0,
|
|
551
|
-
|
|
576
|
+
observers: new Map<
|
|
577
|
+
string,
|
|
578
|
+
QueryObserver<Array<any>, any, Array<any>, Array<any>, any>
|
|
579
|
+
>(),
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// hashedQueryKey → queryKey
|
|
583
|
+
const hashToQueryKey = new Map<string, QueryKey>()
|
|
584
|
+
|
|
585
|
+
// queryKey → Set<RowKey>
|
|
586
|
+
const queryToRows = new Map<string, Set<string | number>>()
|
|
587
|
+
|
|
588
|
+
// RowKey → Set<queryKey>
|
|
589
|
+
const rowToQueries = new Map<string | number, Set<string>>()
|
|
590
|
+
|
|
591
|
+
// queryKey → QueryObserver's unsubscribe function
|
|
592
|
+
const unsubscribes = new Map<string, () => void>()
|
|
593
|
+
|
|
594
|
+
// Helper function to add a row to the internal state
|
|
595
|
+
const addRow = (rowKey: string | number, hashedQueryKey: string) => {
|
|
596
|
+
const rowToQueriesSet = rowToQueries.get(rowKey) || new Set()
|
|
597
|
+
rowToQueriesSet.add(hashedQueryKey)
|
|
598
|
+
rowToQueries.set(rowKey, rowToQueriesSet)
|
|
599
|
+
|
|
600
|
+
const queryToRowsSet = queryToRows.get(hashedQueryKey) || new Set()
|
|
601
|
+
queryToRowsSet.add(rowKey)
|
|
602
|
+
queryToRows.set(hashedQueryKey, queryToRowsSet)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Helper function to remove a row from the internal state
|
|
606
|
+
const removeRow = (rowKey: string | number, hashedQuerKey: string) => {
|
|
607
|
+
const rowToQueriesSet = rowToQueries.get(rowKey) || new Set()
|
|
608
|
+
rowToQueriesSet.delete(hashedQuerKey)
|
|
609
|
+
rowToQueries.set(rowKey, rowToQueriesSet)
|
|
610
|
+
|
|
611
|
+
const queryToRowsSet = queryToRows.get(hashedQuerKey) || new Set()
|
|
612
|
+
queryToRowsSet.delete(rowKey)
|
|
613
|
+
queryToRows.set(hashedQuerKey, queryToRowsSet)
|
|
614
|
+
|
|
615
|
+
return rowToQueriesSet.size === 0
|
|
552
616
|
}
|
|
553
617
|
|
|
554
618
|
const internalSync: SyncConfig<any>[`sync`] = (params) => {
|
|
555
619
|
const { begin, write, commit, markReady, collection } = params
|
|
556
620
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
>
|
|
564
|
-
queryKey
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
// Clear error state
|
|
595
|
-
state.lastError = undefined
|
|
596
|
-
state.errorCount = 0
|
|
597
|
-
|
|
598
|
-
const rawData = result.data
|
|
599
|
-
const newItemsArray = select ? select(rawData) : rawData
|
|
600
|
-
|
|
601
|
-
if (
|
|
602
|
-
!Array.isArray(newItemsArray) ||
|
|
603
|
-
newItemsArray.some((item) => typeof item !== `object`)
|
|
604
|
-
) {
|
|
605
|
-
const errorMessage = select
|
|
606
|
-
? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
|
|
607
|
-
: `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
|
|
608
|
-
|
|
609
|
-
console.error(errorMessage)
|
|
610
|
-
return
|
|
621
|
+
// Track whether sync has been started
|
|
622
|
+
let syncStarted = false
|
|
623
|
+
|
|
624
|
+
const createQueryFromOpts = (
|
|
625
|
+
opts: LoadSubsetOptions = {},
|
|
626
|
+
queryFunction: typeof queryFn = queryFn
|
|
627
|
+
): true | Promise<void> => {
|
|
628
|
+
// Push the predicates down to the queryKey and queryFn
|
|
629
|
+
const key = typeof queryKey === `function` ? queryKey(opts) : queryKey
|
|
630
|
+
const hashedQueryKey = hashKey(key)
|
|
631
|
+
const extendedMeta = { ...meta, loadSubsetOptions: opts }
|
|
632
|
+
|
|
633
|
+
if (state.observers.has(hashedQueryKey)) {
|
|
634
|
+
// We already have a query for this queryKey
|
|
635
|
+
// Get the current result and return based on its state
|
|
636
|
+
const observer = state.observers.get(hashedQueryKey)!
|
|
637
|
+
const currentResult = observer.getCurrentResult()
|
|
638
|
+
|
|
639
|
+
if (currentResult.isSuccess) {
|
|
640
|
+
// Data is already available, return true synchronously
|
|
641
|
+
return true
|
|
642
|
+
} else if (currentResult.isError) {
|
|
643
|
+
// Error already occurred, reject immediately
|
|
644
|
+
return Promise.reject(currentResult.error)
|
|
645
|
+
} else {
|
|
646
|
+
// Query is still loading, wait for the first result
|
|
647
|
+
return new Promise<void>((resolve, reject) => {
|
|
648
|
+
const unsubscribe = observer.subscribe((result) => {
|
|
649
|
+
if (result.isSuccess) {
|
|
650
|
+
unsubscribe()
|
|
651
|
+
resolve()
|
|
652
|
+
} else if (result.isError) {
|
|
653
|
+
unsubscribe()
|
|
654
|
+
reject(result.error)
|
|
655
|
+
}
|
|
656
|
+
})
|
|
657
|
+
})
|
|
611
658
|
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const observerOptions: QueryObserverOptions<
|
|
662
|
+
Array<any>,
|
|
663
|
+
any,
|
|
664
|
+
Array<any>,
|
|
665
|
+
Array<any>,
|
|
666
|
+
any
|
|
667
|
+
> = {
|
|
668
|
+
queryKey: key,
|
|
669
|
+
queryFn: queryFunction,
|
|
670
|
+
meta: extendedMeta,
|
|
671
|
+
structuralSharing: true,
|
|
672
|
+
notifyOnChangeProps: `all`,
|
|
673
|
+
|
|
674
|
+
// Only include options that are explicitly defined to allow QueryClient defaultOptions to be used
|
|
675
|
+
...(enabled !== undefined && { enabled }),
|
|
676
|
+
...(refetchInterval !== undefined && { refetchInterval }),
|
|
677
|
+
...(retry !== undefined && { retry }),
|
|
678
|
+
...(retryDelay !== undefined && { retryDelay }),
|
|
679
|
+
...(staleTime !== undefined && { staleTime }),
|
|
680
|
+
}
|
|
612
681
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
682
|
+
const localObserver = new QueryObserver<
|
|
683
|
+
Array<any>,
|
|
684
|
+
any,
|
|
685
|
+
Array<any>,
|
|
686
|
+
Array<any>,
|
|
687
|
+
any
|
|
688
|
+
>(queryClient, observerOptions)
|
|
689
|
+
|
|
690
|
+
hashToQueryKey.set(hashedQueryKey, key)
|
|
691
|
+
state.observers.set(hashedQueryKey, localObserver)
|
|
692
|
+
|
|
693
|
+
// Create a promise that resolves when the query result is first available
|
|
694
|
+
const readyPromise = new Promise<void>((resolve, reject) => {
|
|
695
|
+
const unsubscribe = localObserver.subscribe((result) => {
|
|
696
|
+
if (result.isSuccess) {
|
|
697
|
+
unsubscribe()
|
|
698
|
+
resolve()
|
|
699
|
+
} else if (result.isError) {
|
|
700
|
+
unsubscribe()
|
|
701
|
+
reject(result.error)
|
|
702
|
+
}
|
|
620
703
|
})
|
|
704
|
+
})
|
|
621
705
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
return keys1.every((key) => {
|
|
638
|
-
// Skip comparing functions and complex objects deeply
|
|
639
|
-
if (typeof obj1[key] === `function`) return true
|
|
640
|
-
return obj1[key] === obj2[key]
|
|
641
|
-
})
|
|
642
|
-
}
|
|
706
|
+
// If sync has started or there are subscribers to the collection, subscribe to the query straight away
|
|
707
|
+
// This creates the main subscription that handles data updates
|
|
708
|
+
if (syncStarted || collection.subscriberCount > 0) {
|
|
709
|
+
subscribeToQuery(localObserver, hashedQueryKey)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Tell tanstack query to GC the query when the subscription is unsubscribed
|
|
713
|
+
// The subscription is unsubscribed when the live query is GCed.
|
|
714
|
+
const subscription = opts.subscription
|
|
715
|
+
subscription?.once(`unsubscribed`, () => {
|
|
716
|
+
queryClient.removeQueries({ queryKey: key, exact: true })
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
return readyPromise
|
|
720
|
+
}
|
|
643
721
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
722
|
+
type UpdateHandler = Parameters<QueryObserver[`subscribe`]>[0]
|
|
723
|
+
|
|
724
|
+
const makeQueryResultHandler = (queryKey: QueryKey) => {
|
|
725
|
+
const hashedQueryKey = hashKey(queryKey)
|
|
726
|
+
const handleQueryResult: UpdateHandler = (result) => {
|
|
727
|
+
if (result.isSuccess) {
|
|
728
|
+
// Clear error state
|
|
729
|
+
state.lastError = undefined
|
|
730
|
+
state.errorCount = 0
|
|
731
|
+
|
|
732
|
+
const rawData = result.data
|
|
733
|
+
const newItemsArray = select ? select(rawData) : rawData
|
|
734
|
+
|
|
735
|
+
if (
|
|
736
|
+
!Array.isArray(newItemsArray) ||
|
|
737
|
+
newItemsArray.some((item) => typeof item !== `object`)
|
|
653
738
|
) {
|
|
654
|
-
|
|
655
|
-
|
|
739
|
+
const errorMessage = select
|
|
740
|
+
? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
|
|
741
|
+
: `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
|
|
742
|
+
|
|
743
|
+
console.error(errorMessage)
|
|
744
|
+
return
|
|
656
745
|
}
|
|
657
|
-
})
|
|
658
746
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
747
|
+
const currentSyncedItems: Map<string | number, any> = new Map(
|
|
748
|
+
collection._state.syncedData.entries()
|
|
749
|
+
)
|
|
750
|
+
const newItemsMap = new Map<string | number, any>()
|
|
751
|
+
newItemsArray.forEach((item) => {
|
|
752
|
+
const key = getKey(item)
|
|
753
|
+
newItemsMap.set(key, item)
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
begin()
|
|
757
|
+
|
|
758
|
+
// Helper function for shallow equality check of objects
|
|
759
|
+
const shallowEqual = (
|
|
760
|
+
obj1: Record<string, any>,
|
|
761
|
+
obj2: Record<string, any>
|
|
762
|
+
): boolean => {
|
|
763
|
+
// Get all keys from both objects
|
|
764
|
+
const keys1 = Object.keys(obj1)
|
|
765
|
+
const keys2 = Object.keys(obj2)
|
|
766
|
+
|
|
767
|
+
// If number of keys is different, objects are not equal
|
|
768
|
+
if (keys1.length !== keys2.length) return false
|
|
769
|
+
|
|
770
|
+
// Check if all keys in obj1 have the same values in obj2
|
|
771
|
+
return keys1.every((key) => {
|
|
772
|
+
// Skip comparing functions and complex objects deeply
|
|
773
|
+
if (typeof obj1[key] === `function`) return true
|
|
774
|
+
return obj1[key] === obj2[key]
|
|
775
|
+
})
|
|
662
776
|
}
|
|
663
|
-
})
|
|
664
777
|
|
|
665
|
-
|
|
778
|
+
currentSyncedItems.forEach((oldItem, key) => {
|
|
779
|
+
const newItem = newItemsMap.get(key)
|
|
780
|
+
if (!newItem) {
|
|
781
|
+
const needToRemove = removeRow(key, hashedQueryKey) // returns true if the row is no longer referenced by any queries
|
|
782
|
+
if (needToRemove) {
|
|
783
|
+
write({ type: `delete`, value: oldItem })
|
|
784
|
+
}
|
|
785
|
+
} else if (
|
|
786
|
+
!shallowEqual(
|
|
787
|
+
oldItem as Record<string, any>,
|
|
788
|
+
newItem as Record<string, any>
|
|
789
|
+
)
|
|
790
|
+
) {
|
|
791
|
+
// Only update if there are actual differences in the properties
|
|
792
|
+
write({ type: `update`, value: newItem })
|
|
793
|
+
}
|
|
794
|
+
})
|
|
666
795
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
state.lastErrorUpdatedAt = result.errorUpdatedAt
|
|
674
|
-
}
|
|
796
|
+
newItemsMap.forEach((newItem, key) => {
|
|
797
|
+
addRow(key, hashedQueryKey)
|
|
798
|
+
if (!currentSyncedItems.has(key)) {
|
|
799
|
+
write({ type: `insert`, value: newItem })
|
|
800
|
+
}
|
|
801
|
+
})
|
|
675
802
|
|
|
676
|
-
|
|
677
|
-
`[QueryCollection] Error observing query ${String(queryKey)}:`,
|
|
678
|
-
result.error
|
|
679
|
-
)
|
|
803
|
+
commit()
|
|
680
804
|
|
|
681
|
-
|
|
682
|
-
|
|
805
|
+
// Mark collection as ready after first successful query result
|
|
806
|
+
markReady()
|
|
807
|
+
} else if (result.isError) {
|
|
808
|
+
if (result.errorUpdatedAt !== state.lastErrorUpdatedAt) {
|
|
809
|
+
state.lastError = result.error
|
|
810
|
+
state.errorCount++
|
|
811
|
+
state.lastErrorUpdatedAt = result.errorUpdatedAt
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
console.error(
|
|
815
|
+
`[QueryCollection] Error observing query ${String(queryKey)}:`,
|
|
816
|
+
result.error
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
// Mark collection as ready even on error to avoid blocking apps
|
|
820
|
+
markReady()
|
|
821
|
+
}
|
|
683
822
|
}
|
|
823
|
+
return handleQueryResult
|
|
684
824
|
}
|
|
685
825
|
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
actualUnsubscribeFn = localObserver.subscribe(handleQueryResult)
|
|
689
|
-
isSubscribed = true
|
|
690
|
-
}
|
|
826
|
+
const isSubscribed = (hashedQueryKey: string) => {
|
|
827
|
+
return unsubscribes.has(hashedQueryKey)
|
|
691
828
|
}
|
|
692
829
|
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
830
|
+
const subscribeToQuery = (
|
|
831
|
+
observer: QueryObserver<Array<any>, any, Array<any>, Array<any>, any>,
|
|
832
|
+
hashedQueryKey: string
|
|
833
|
+
) => {
|
|
834
|
+
if (!isSubscribed(hashedQueryKey)) {
|
|
835
|
+
const queryKey = hashToQueryKey.get(hashedQueryKey)!
|
|
836
|
+
const handleQueryResult = makeQueryResultHandler(queryKey)
|
|
837
|
+
const unsubscribeFn = observer.subscribe(handleQueryResult)
|
|
838
|
+
unsubscribes.set(hashedQueryKey, unsubscribeFn)
|
|
698
839
|
}
|
|
699
840
|
}
|
|
700
841
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
842
|
+
const subscribeToQueries = () => {
|
|
843
|
+
state.observers.forEach(subscribeToQuery)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const unsubscribeFromQueries = () => {
|
|
847
|
+
unsubscribes.forEach((unsubscribeFn) => {
|
|
848
|
+
unsubscribeFn()
|
|
849
|
+
})
|
|
850
|
+
unsubscribes.clear()
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Mark that sync has started
|
|
854
|
+
syncStarted = true
|
|
704
855
|
|
|
705
856
|
// Set up event listener for subscriber changes
|
|
706
857
|
const unsubscribeFromCollectionEvents = collection.on(
|
|
707
858
|
`subscribers:change`,
|
|
708
859
|
({ subscriberCount }) => {
|
|
709
860
|
if (subscriberCount > 0) {
|
|
710
|
-
|
|
861
|
+
subscribeToQueries()
|
|
711
862
|
} else if (subscriberCount === 0) {
|
|
712
|
-
|
|
863
|
+
unsubscribeFromQueries()
|
|
713
864
|
}
|
|
714
865
|
}
|
|
715
866
|
)
|
|
716
867
|
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
868
|
+
// If syncMode is eager, create the initial query without any predicates
|
|
869
|
+
if (syncMode === `eager`) {
|
|
870
|
+
// Catch any errors to prevent unhandled rejections
|
|
871
|
+
const initialResult = createQueryFromOpts({})
|
|
872
|
+
if (initialResult instanceof Promise) {
|
|
873
|
+
initialResult.catch(() => {
|
|
874
|
+
// Errors are already handled by the query result handler
|
|
875
|
+
})
|
|
876
|
+
}
|
|
877
|
+
} else {
|
|
878
|
+
// In on-demand mode, mark ready immediately since there's no initial query
|
|
879
|
+
markReady()
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Always subscribe when sync starts (this could be from preload(), startSync config, or first subscriber)
|
|
883
|
+
// We'll dynamically unsubscribe/resubscribe based on subscriber count to maintain staleTime behavior
|
|
884
|
+
subscribeToQueries()
|
|
885
|
+
|
|
886
|
+
// Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial state)
|
|
887
|
+
state.observers.forEach((observer, hashedQueryKey) => {
|
|
888
|
+
const queryKey = hashToQueryKey.get(hashedQueryKey)!
|
|
889
|
+
const handleQueryResult = makeQueryResultHandler(queryKey)
|
|
890
|
+
handleQueryResult(observer.getCurrentResult())
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
// Subscribe to the query client's cache to handle queries that are GCed by tanstack query
|
|
894
|
+
const unsubscribeQueryCache = queryClient
|
|
895
|
+
.getQueryCache()
|
|
896
|
+
.subscribe((event) => {
|
|
897
|
+
const hashedKey = event.query.queryHash
|
|
898
|
+
if (event.type === `removed`) {
|
|
899
|
+
cleanupQuery(hashedKey)
|
|
900
|
+
}
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
function cleanupQuery(hashedQueryKey: string) {
|
|
904
|
+
// Unsubscribe from the query's observer
|
|
905
|
+
unsubscribes.get(hashedQueryKey)?.()
|
|
906
|
+
|
|
907
|
+
// Get all the rows that are in the result of this query
|
|
908
|
+
const rowKeys = queryToRows.get(hashedQueryKey) ?? new Set()
|
|
909
|
+
|
|
910
|
+
// Remove the query from these rows
|
|
911
|
+
rowKeys.forEach((rowKey) => {
|
|
912
|
+
const queries = rowToQueries.get(rowKey) // set of queries that reference this row
|
|
913
|
+
if (queries && queries.size > 0) {
|
|
914
|
+
queries.delete(hashedQueryKey)
|
|
915
|
+
if (queries.size === 0) {
|
|
916
|
+
// Reference count dropped to 0, we can GC the row
|
|
917
|
+
rowToQueries.delete(rowKey)
|
|
918
|
+
|
|
919
|
+
if (collection.has(rowKey)) {
|
|
920
|
+
begin()
|
|
921
|
+
write({ type: `delete`, value: collection.get(rowKey) })
|
|
922
|
+
commit()
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
})
|
|
720
927
|
|
|
721
|
-
|
|
928
|
+
// Remove the query from the internal state
|
|
929
|
+
unsubscribes.delete(hashedQueryKey)
|
|
930
|
+
state.observers.delete(hashedQueryKey)
|
|
931
|
+
queryToRows.delete(hashedQueryKey)
|
|
932
|
+
hashToQueryKey.delete(hashedQueryKey)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const cleanup = async () => {
|
|
722
936
|
unsubscribeFromCollectionEvents()
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
937
|
+
unsubscribeFromQueries()
|
|
938
|
+
|
|
939
|
+
const queryKeys = [...hashToQueryKey.values()]
|
|
940
|
+
|
|
941
|
+
hashToQueryKey.clear()
|
|
942
|
+
queryToRows.clear()
|
|
943
|
+
rowToQueries.clear()
|
|
944
|
+
state.observers.clear()
|
|
945
|
+
unsubscribeQueryCache()
|
|
946
|
+
|
|
947
|
+
await Promise.all(
|
|
948
|
+
queryKeys.map(async (queryKey) => {
|
|
949
|
+
await queryClient.cancelQueries({ queryKey })
|
|
950
|
+
queryClient.removeQueries({ queryKey })
|
|
951
|
+
})
|
|
952
|
+
)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Create deduplicated loadSubset wrapper for non-eager modes
|
|
956
|
+
// This prevents redundant snapshot requests when multiple concurrent
|
|
957
|
+
// live queries request overlapping or subset predicates
|
|
958
|
+
const loadSubsetDedupe =
|
|
959
|
+
syncMode === `eager` ? undefined : createQueryFromOpts
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
loadSubset: loadSubsetDedupe,
|
|
963
|
+
cleanup,
|
|
726
964
|
}
|
|
727
965
|
}
|
|
728
966
|
|
|
@@ -745,15 +983,15 @@ export function queryCollectionOptions(
|
|
|
745
983
|
* @returns Promise that resolves when the refetch is complete, with QueryObserverResult
|
|
746
984
|
*/
|
|
747
985
|
const refetch: RefetchFn = async (opts) => {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
return
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
return state.queryObserver.refetch({
|
|
755
|
-
throwOnError: opts?.throwOnError,
|
|
986
|
+
const queryKeys = [...hashToQueryKey.values()]
|
|
987
|
+
const refetchPromises = queryKeys.map((queryKey) => {
|
|
988
|
+
const queryObserver = state.observers.get(hashKey(queryKey))!
|
|
989
|
+
return queryObserver.refetch({
|
|
990
|
+
throwOnError: opts?.throwOnError,
|
|
991
|
+
})
|
|
756
992
|
})
|
|
993
|
+
|
|
994
|
+
await Promise.all(refetchPromises)
|
|
757
995
|
}
|
|
758
996
|
|
|
759
997
|
// Create write context for manual write operations
|
|
@@ -840,6 +1078,7 @@ export function queryCollectionOptions(
|
|
|
840
1078
|
return {
|
|
841
1079
|
...baseCollectionConfig,
|
|
842
1080
|
getKey,
|
|
1081
|
+
syncMode,
|
|
843
1082
|
sync: { sync: enhancedInternalSync },
|
|
844
1083
|
onInsert: wrappedOnInsert,
|
|
845
1084
|
onUpdate: wrappedOnUpdate,
|