@tanstack/query-db-collection 1.0.30 → 1.0.31
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/query.cjs +442 -48
- package/dist/cjs/query.cjs.map +1 -1
- package/dist/cjs/query.d.cts +1 -0
- package/dist/esm/query.d.ts +1 -0
- package/dist/esm/query.js +442 -48
- package/dist/esm/query.js.map +1 -1
- package/package.json +4 -3
- package/src/query.ts +654 -72
package/src/query.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
InsertMutationFnParams,
|
|
17
17
|
LoadSubsetOptions,
|
|
18
18
|
SyncConfig,
|
|
19
|
+
SyncMetadataApi,
|
|
19
20
|
UpdateMutationFnParams,
|
|
20
21
|
UtilsRecord,
|
|
21
22
|
} from '@tanstack/db'
|
|
@@ -118,6 +119,7 @@ export interface QueryCollectionConfig<
|
|
|
118
119
|
TQueryData,
|
|
119
120
|
TQueryKey
|
|
120
121
|
>[`staleTime`]
|
|
122
|
+
persistedGcTime?: number
|
|
121
123
|
|
|
122
124
|
/**
|
|
123
125
|
* Metadata to pass to the query.
|
|
@@ -219,6 +221,35 @@ interface QueryCollectionState {
|
|
|
219
221
|
>
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
type PersistedQueryRetentionEntry =
|
|
225
|
+
| {
|
|
226
|
+
queryHash: string
|
|
227
|
+
mode: `ttl`
|
|
228
|
+
expiresAt: number
|
|
229
|
+
}
|
|
230
|
+
| {
|
|
231
|
+
queryHash: string
|
|
232
|
+
mode: `until-revalidated`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const QUERY_COLLECTION_GC_PREFIX = `queryCollection:gc:`
|
|
236
|
+
|
|
237
|
+
type PersistedScannedRowForQuery<TItem extends object> = {
|
|
238
|
+
key: string | number
|
|
239
|
+
value: TItem
|
|
240
|
+
metadata?: unknown
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
type QuerySyncMetadataWithPersistedScan<TItem extends object> = SyncMetadataApi<
|
|
244
|
+
string | number
|
|
245
|
+
> & {
|
|
246
|
+
row: SyncMetadataApi<string | number>[`row`] & {
|
|
247
|
+
scanPersisted?: (options?: {
|
|
248
|
+
metadataOnly?: boolean
|
|
249
|
+
}) => Promise<Array<PersistedScannedRowForQuery<TItem>>>
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
222
253
|
/**
|
|
223
254
|
* Implementation class for QueryCollectionUtils with explicit dependency injection
|
|
224
255
|
* for better testability and architectural clarity
|
|
@@ -547,6 +578,7 @@ export function queryCollectionOptions(
|
|
|
547
578
|
retry,
|
|
548
579
|
retryDelay,
|
|
549
580
|
staleTime,
|
|
581
|
+
persistedGcTime,
|
|
550
582
|
getKey,
|
|
551
583
|
onInsert,
|
|
552
584
|
onUpdate,
|
|
@@ -558,6 +590,33 @@ export function queryCollectionOptions(
|
|
|
558
590
|
// Default to eager sync mode if not provided
|
|
559
591
|
const syncMode = baseCollectionConfig.syncMode ?? `eager`
|
|
560
592
|
|
|
593
|
+
// Compute the base query key once for cache lookups.
|
|
594
|
+
// All derived keys (from on-demand predicates or function-based queryKey) must
|
|
595
|
+
// share this prefix so that queryCache.findAll({ queryKey: baseKey }) can find them.
|
|
596
|
+
const baseKey: QueryKey =
|
|
597
|
+
typeof queryKey === `function`
|
|
598
|
+
? (queryKey({}) as unknown as QueryKey)
|
|
599
|
+
: (queryKey as unknown as QueryKey)
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Validates that a derived query key extends the base key prefix.
|
|
603
|
+
* TanStack Query uses prefix matching in findAll(), so all keys for this collection
|
|
604
|
+
* must start with baseKey for stale cache updates to work correctly.
|
|
605
|
+
*/
|
|
606
|
+
const validateQueryKeyPrefix = (key: QueryKey): void => {
|
|
607
|
+
if (typeof queryKey !== `function`) return
|
|
608
|
+
const isValidPrefix =
|
|
609
|
+
key.length >= baseKey.length &&
|
|
610
|
+
baseKey.every((segment, i) => deepEquals(segment, key[i]))
|
|
611
|
+
if (!isValidPrefix) {
|
|
612
|
+
console.warn(
|
|
613
|
+
`[QueryCollection] queryKey function must return keys that extend the base key prefix. ` +
|
|
614
|
+
`Base: ${JSON.stringify(baseKey)}, Got: ${JSON.stringify(key)}. ` +
|
|
615
|
+
`This can cause stale cache issues.`,
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
561
620
|
// Validate required parameters
|
|
562
621
|
|
|
563
622
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
@@ -645,10 +704,333 @@ export function queryCollectionOptions(
|
|
|
645
704
|
}
|
|
646
705
|
|
|
647
706
|
const internalSync: SyncConfig<any>[`sync`] = (params) => {
|
|
648
|
-
const { begin, write, commit, markReady, collection } = params
|
|
707
|
+
const { begin, write, commit, markReady, collection, metadata } = params
|
|
708
|
+
const persistedMetadata = metadata as
|
|
709
|
+
| QuerySyncMetadataWithPersistedScan<any>
|
|
710
|
+
| undefined
|
|
649
711
|
|
|
650
712
|
// Track whether sync has been started
|
|
651
713
|
let syncStarted = false
|
|
714
|
+
let startupRetentionSettled = false
|
|
715
|
+
const retainedQueriesPendingRevalidation = new Set<string>()
|
|
716
|
+
const effectivePersistedGcTimes = new Map<string, number>()
|
|
717
|
+
const persistedRetentionTimers = new Map<
|
|
718
|
+
string,
|
|
719
|
+
ReturnType<typeof setTimeout>
|
|
720
|
+
>()
|
|
721
|
+
let persistedRetentionMaintenance = Promise.resolve()
|
|
722
|
+
|
|
723
|
+
const getRowMetadata = (rowKey: string | number) => {
|
|
724
|
+
return (metadata?.row.get(rowKey) ??
|
|
725
|
+
collection._state.syncedMetadata.get(rowKey)) as
|
|
726
|
+
| Record<string, unknown>
|
|
727
|
+
| undefined
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const getPersistedOwners = (rowKey: string | number) => {
|
|
731
|
+
const rowMetadata = getRowMetadata(rowKey)
|
|
732
|
+
const queryMetadata = rowMetadata?.queryCollection
|
|
733
|
+
if (!queryMetadata || typeof queryMetadata !== `object`) {
|
|
734
|
+
return new Set<string>()
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const owners = (queryMetadata as Record<string, unknown>).owners
|
|
738
|
+
if (!owners || typeof owners !== `object`) {
|
|
739
|
+
return new Set<string>()
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return new Set(Object.keys(owners as Record<string, true>))
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const setPersistedOwners = (
|
|
746
|
+
rowKey: string | number,
|
|
747
|
+
owners: Set<string>,
|
|
748
|
+
) => {
|
|
749
|
+
if (!metadata) {
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const currentMetadata = { ...(getRowMetadata(rowKey) ?? {}) }
|
|
754
|
+
if (owners.size === 0) {
|
|
755
|
+
delete currentMetadata.queryCollection
|
|
756
|
+
if (Object.keys(currentMetadata).length === 0) {
|
|
757
|
+
metadata.row.delete(rowKey)
|
|
758
|
+
} else {
|
|
759
|
+
metadata.row.set(rowKey, currentMetadata)
|
|
760
|
+
}
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
metadata.row.set(rowKey, {
|
|
765
|
+
...currentMetadata,
|
|
766
|
+
queryCollection: {
|
|
767
|
+
owners: Object.fromEntries(
|
|
768
|
+
Array.from(owners.values()).map((owner) => [owner, true]),
|
|
769
|
+
),
|
|
770
|
+
},
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const parsePersistedQueryRetentionEntry = (
|
|
775
|
+
value: unknown,
|
|
776
|
+
expectedHash: string,
|
|
777
|
+
): PersistedQueryRetentionEntry | undefined => {
|
|
778
|
+
if (!value || typeof value !== `object`) {
|
|
779
|
+
return undefined
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const record = value as Record<string, unknown>
|
|
783
|
+
if (record.queryHash !== expectedHash) {
|
|
784
|
+
return undefined
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (record.mode === `until-revalidated`) {
|
|
788
|
+
return {
|
|
789
|
+
queryHash: expectedHash,
|
|
790
|
+
mode: `until-revalidated`,
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (
|
|
795
|
+
record.mode === `ttl` &&
|
|
796
|
+
typeof record.expiresAt === `number` &&
|
|
797
|
+
Number.isFinite(record.expiresAt)
|
|
798
|
+
) {
|
|
799
|
+
return {
|
|
800
|
+
queryHash: expectedHash,
|
|
801
|
+
mode: `ttl`,
|
|
802
|
+
expiresAt: record.expiresAt,
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return undefined
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const runPersistedRetentionMaintenance = (task: () => Promise<void>) => {
|
|
810
|
+
persistedRetentionMaintenance = persistedRetentionMaintenance.then(
|
|
811
|
+
task,
|
|
812
|
+
task,
|
|
813
|
+
)
|
|
814
|
+
return persistedRetentionMaintenance
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const cancelPersistedRetentionExpiry = (hashedQueryKey: string) => {
|
|
818
|
+
const timer = persistedRetentionTimers.get(hashedQueryKey)
|
|
819
|
+
if (timer) {
|
|
820
|
+
clearTimeout(timer)
|
|
821
|
+
persistedRetentionTimers.delete(hashedQueryKey)
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const getHydratedOwnedRowsForQueryBaseline = (hashedQueryKey: string) => {
|
|
826
|
+
const knownRows = queryToRows.get(hashedQueryKey)
|
|
827
|
+
if (knownRows) {
|
|
828
|
+
return new Set(knownRows)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const ownedRows = new Set<string | number>()
|
|
832
|
+
for (const [rowKey] of collection._state.syncedData.entries()) {
|
|
833
|
+
const owners = getPersistedOwners(rowKey)
|
|
834
|
+
if (owners.size === 0) {
|
|
835
|
+
continue
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
rowToQueries.set(rowKey, new Set(owners))
|
|
839
|
+
owners.forEach((owner) => {
|
|
840
|
+
const queryToRowsSet = queryToRows.get(owner) || new Set()
|
|
841
|
+
queryToRowsSet.add(rowKey)
|
|
842
|
+
queryToRows.set(owner, queryToRowsSet)
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
if (owners.has(hashedQueryKey)) {
|
|
846
|
+
ownedRows.add(rowKey)
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return ownedRows
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const loadPersistedBaselineForQuery = async (
|
|
853
|
+
hashedQueryKey: string,
|
|
854
|
+
): Promise<
|
|
855
|
+
Map<
|
|
856
|
+
string | number,
|
|
857
|
+
{
|
|
858
|
+
value: any
|
|
859
|
+
owners: Set<string>
|
|
860
|
+
}
|
|
861
|
+
>
|
|
862
|
+
> => {
|
|
863
|
+
const knownRows = queryToRows.get(hashedQueryKey)
|
|
864
|
+
if (
|
|
865
|
+
knownRows &&
|
|
866
|
+
Array.from(knownRows).every((rowKey) => collection.has(rowKey))
|
|
867
|
+
) {
|
|
868
|
+
const baseline = new Map<
|
|
869
|
+
string | number,
|
|
870
|
+
{ value: any; owners: Set<string> }
|
|
871
|
+
>()
|
|
872
|
+
knownRows.forEach((rowKey) => {
|
|
873
|
+
const value = collection.get(rowKey)
|
|
874
|
+
const owners = rowToQueries.get(rowKey)
|
|
875
|
+
if (value && owners) {
|
|
876
|
+
baseline.set(rowKey, {
|
|
877
|
+
value,
|
|
878
|
+
owners: new Set(owners),
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
})
|
|
882
|
+
return baseline
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const scanPersisted = persistedMetadata?.row.scanPersisted
|
|
886
|
+
if (!scanPersisted) {
|
|
887
|
+
const baseline = new Map<
|
|
888
|
+
string | number,
|
|
889
|
+
{ value: any; owners: Set<string> }
|
|
890
|
+
>()
|
|
891
|
+
getHydratedOwnedRowsForQueryBaseline(hashedQueryKey).forEach(
|
|
892
|
+
(rowKey) => {
|
|
893
|
+
const value = collection.get(rowKey)
|
|
894
|
+
const owners = rowToQueries.get(rowKey)
|
|
895
|
+
if (value && owners) {
|
|
896
|
+
baseline.set(rowKey, {
|
|
897
|
+
value,
|
|
898
|
+
owners: new Set(owners),
|
|
899
|
+
})
|
|
900
|
+
}
|
|
901
|
+
},
|
|
902
|
+
)
|
|
903
|
+
return baseline
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const baseline = new Map<
|
|
907
|
+
string | number,
|
|
908
|
+
{ value: any; owners: Set<string> }
|
|
909
|
+
>()
|
|
910
|
+
const scannedRows = await scanPersisted()
|
|
911
|
+
|
|
912
|
+
scannedRows.forEach((row) => {
|
|
913
|
+
const rowMetadata = row.metadata as Record<string, unknown> | undefined
|
|
914
|
+
const queryMetadata = rowMetadata?.queryCollection
|
|
915
|
+
if (!queryMetadata || typeof queryMetadata !== `object`) {
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const owners = (queryMetadata as Record<string, unknown>).owners
|
|
920
|
+
if (!owners || typeof owners !== `object`) {
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const ownerSet = new Set(Object.keys(owners as Record<string, true>))
|
|
925
|
+
if (ownerSet.size === 0) {
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
rowToQueries.set(row.key, new Set(ownerSet))
|
|
930
|
+
ownerSet.forEach((owner) => {
|
|
931
|
+
const queryToRowsSet = queryToRows.get(owner) || new Set()
|
|
932
|
+
queryToRowsSet.add(row.key)
|
|
933
|
+
queryToRows.set(owner, queryToRowsSet)
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
if (ownerSet.has(hashedQueryKey)) {
|
|
937
|
+
baseline.set(row.key, {
|
|
938
|
+
value: row.value,
|
|
939
|
+
owners: ownerSet,
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
return baseline
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const cleanupPersistedPlaceholder = async (hashedQueryKey: string) => {
|
|
948
|
+
if (!metadata) {
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const baseline = await loadPersistedBaselineForQuery(hashedQueryKey)
|
|
953
|
+
const rowsToDelete: Array<any> = []
|
|
954
|
+
|
|
955
|
+
begin()
|
|
956
|
+
|
|
957
|
+
baseline.forEach(({ value: oldItem, owners }, rowKey) => {
|
|
958
|
+
owners.delete(hashedQueryKey)
|
|
959
|
+
setPersistedOwners(rowKey, owners)
|
|
960
|
+
const needToRemove = removeRow(rowKey, hashedQueryKey)
|
|
961
|
+
if (needToRemove) {
|
|
962
|
+
rowsToDelete.push(oldItem)
|
|
963
|
+
}
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
rowsToDelete.forEach((row) => {
|
|
967
|
+
write({ type: `delete`, value: row })
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
metadata.collection.delete(
|
|
971
|
+
`${QUERY_COLLECTION_GC_PREFIX}${hashedQueryKey}`,
|
|
972
|
+
)
|
|
973
|
+
commit()
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const schedulePersistedRetentionExpiry = (
|
|
977
|
+
entry: PersistedQueryRetentionEntry,
|
|
978
|
+
) => {
|
|
979
|
+
if (entry.mode !== `ttl`) {
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
cancelPersistedRetentionExpiry(entry.queryHash)
|
|
984
|
+
|
|
985
|
+
const delay = Math.max(0, entry.expiresAt - Date.now())
|
|
986
|
+
const timer = setTimeout(() => {
|
|
987
|
+
persistedRetentionTimers.delete(entry.queryHash)
|
|
988
|
+
void runPersistedRetentionMaintenance(async () => {
|
|
989
|
+
const currentEntry = metadata?.collection.get(
|
|
990
|
+
`${QUERY_COLLECTION_GC_PREFIX}${entry.queryHash}`,
|
|
991
|
+
)
|
|
992
|
+
const parsedCurrentEntry = parsePersistedQueryRetentionEntry(
|
|
993
|
+
currentEntry,
|
|
994
|
+
entry.queryHash,
|
|
995
|
+
)
|
|
996
|
+
if (
|
|
997
|
+
!parsedCurrentEntry ||
|
|
998
|
+
parsedCurrentEntry.mode !== `ttl` ||
|
|
999
|
+
parsedCurrentEntry.expiresAt > Date.now()
|
|
1000
|
+
) {
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
await cleanupPersistedPlaceholder(entry.queryHash)
|
|
1004
|
+
})
|
|
1005
|
+
}, delay)
|
|
1006
|
+
|
|
1007
|
+
persistedRetentionTimers.set(entry.queryHash, timer)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const consumePersistedQueryRetentionAtStartup = async () => {
|
|
1011
|
+
if (!metadata) {
|
|
1012
|
+
return
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const retentionEntries = metadata.collection.list(
|
|
1016
|
+
QUERY_COLLECTION_GC_PREFIX,
|
|
1017
|
+
)
|
|
1018
|
+
const now = Date.now()
|
|
1019
|
+
|
|
1020
|
+
for (const { key, value } of retentionEntries) {
|
|
1021
|
+
const hashedQueryKey = key.slice(QUERY_COLLECTION_GC_PREFIX.length)
|
|
1022
|
+
const parsed = parsePersistedQueryRetentionEntry(value, hashedQueryKey)
|
|
1023
|
+
if (!parsed) {
|
|
1024
|
+
continue
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (parsed.mode === `ttl` && parsed.expiresAt <= now) {
|
|
1028
|
+
await cleanupPersistedPlaceholder(parsed.queryHash)
|
|
1029
|
+
} else if (parsed.mode === `ttl`) {
|
|
1030
|
+
schedulePersistedRetentionExpiry(parsed)
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
652
1034
|
|
|
653
1035
|
/**
|
|
654
1036
|
* Generate a consistent query key from LoadSubsetOptions.
|
|
@@ -671,14 +1053,50 @@ export function queryCollectionOptions(
|
|
|
671
1053
|
}
|
|
672
1054
|
}
|
|
673
1055
|
|
|
1056
|
+
const startupRetentionEntries = metadata?.collection.list(
|
|
1057
|
+
QUERY_COLLECTION_GC_PREFIX,
|
|
1058
|
+
)
|
|
1059
|
+
const startupRetentionMaintenancePromise =
|
|
1060
|
+
!startupRetentionEntries || startupRetentionEntries.length === 0
|
|
1061
|
+
? (() => {
|
|
1062
|
+
startupRetentionSettled = true
|
|
1063
|
+
return Promise.resolve()
|
|
1064
|
+
})()
|
|
1065
|
+
: runPersistedRetentionMaintenance(async () => {
|
|
1066
|
+
try {
|
|
1067
|
+
await consumePersistedQueryRetentionAtStartup()
|
|
1068
|
+
} finally {
|
|
1069
|
+
startupRetentionSettled = true
|
|
1070
|
+
}
|
|
1071
|
+
})
|
|
1072
|
+
|
|
674
1073
|
const createQueryFromOpts = (
|
|
675
1074
|
opts: LoadSubsetOptions = {},
|
|
676
1075
|
queryFunction: typeof queryFn = queryFn,
|
|
677
1076
|
): true | Promise<void> => {
|
|
1077
|
+
if (!startupRetentionSettled) {
|
|
1078
|
+
return startupRetentionMaintenancePromise.then(() => {
|
|
1079
|
+
const resumed = createQueryFromOpts(opts, queryFunction)
|
|
1080
|
+
return resumed === true ? undefined : resumed
|
|
1081
|
+
})
|
|
1082
|
+
}
|
|
1083
|
+
|
|
678
1084
|
// Generate key using common function
|
|
679
1085
|
const key = generateQueryKeyFromOptions(opts)
|
|
680
1086
|
const hashedQueryKey = hashKey(key)
|
|
681
1087
|
const extendedMeta = { ...meta, loadSubsetOptions: opts }
|
|
1088
|
+
const retainedEntry = metadata?.collection.get(
|
|
1089
|
+
`${QUERY_COLLECTION_GC_PREFIX}${hashedQueryKey}`,
|
|
1090
|
+
)
|
|
1091
|
+
if (
|
|
1092
|
+
parsePersistedQueryRetentionEntry(retainedEntry, hashedQueryKey) !==
|
|
1093
|
+
undefined
|
|
1094
|
+
) {
|
|
1095
|
+
retainedQueriesPendingRevalidation.add(hashedQueryKey)
|
|
1096
|
+
}
|
|
1097
|
+
cancelPersistedRetentionExpiry(hashedQueryKey)
|
|
1098
|
+
|
|
1099
|
+
validateQueryKeyPrefix(key)
|
|
682
1100
|
|
|
683
1101
|
if (state.observers.has(hashedQueryKey)) {
|
|
684
1102
|
// We already have a query for this queryKey
|
|
@@ -754,9 +1172,19 @@ export function queryCollectionOptions(
|
|
|
754
1172
|
Array<any>,
|
|
755
1173
|
any
|
|
756
1174
|
>(queryClient, observerOptions)
|
|
1175
|
+
const resolvedQueryGcTime = queryClient.getQueryCache().find({
|
|
1176
|
+
queryKey: key,
|
|
1177
|
+
exact: true,
|
|
1178
|
+
})?.gcTime
|
|
1179
|
+
const effectivePersistedGcTime = persistedGcTime ?? resolvedQueryGcTime
|
|
757
1180
|
|
|
758
1181
|
hashToQueryKey.set(hashedQueryKey, key)
|
|
759
1182
|
state.observers.set(hashedQueryKey, localObserver)
|
|
1183
|
+
if (effectivePersistedGcTime !== undefined) {
|
|
1184
|
+
effectivePersistedGcTimes.set(hashedQueryKey, effectivePersistedGcTime)
|
|
1185
|
+
} else {
|
|
1186
|
+
effectivePersistedGcTimes.delete(hashedQueryKey)
|
|
1187
|
+
}
|
|
760
1188
|
|
|
761
1189
|
// Increment reference count for this query
|
|
762
1190
|
queryRefCounts.set(
|
|
@@ -803,65 +1231,146 @@ export function queryCollectionOptions(
|
|
|
803
1231
|
|
|
804
1232
|
type UpdateHandler = Parameters<QueryObserver[`subscribe`]>[0]
|
|
805
1233
|
|
|
806
|
-
|
|
807
|
-
|
|
1234
|
+
const applySuccessfulResult = (
|
|
1235
|
+
queryKey: QueryKey,
|
|
1236
|
+
result: QueryObserverResult<any, any>,
|
|
1237
|
+
persistedBaseline?: Map<
|
|
1238
|
+
string | number,
|
|
1239
|
+
{
|
|
1240
|
+
value: any
|
|
1241
|
+
owners: Set<string>
|
|
1242
|
+
}
|
|
1243
|
+
>,
|
|
1244
|
+
) => {
|
|
808
1245
|
const hashedQueryKey = hashKey(queryKey)
|
|
809
|
-
const handleQueryResult: UpdateHandler = (result) => {
|
|
810
|
-
if (result.isSuccess) {
|
|
811
|
-
// Clear error state
|
|
812
|
-
state.lastError = undefined
|
|
813
|
-
state.errorCount = 0
|
|
814
1246
|
|
|
815
|
-
|
|
816
|
-
|
|
1247
|
+
if (collection.status === `cleaned-up`) {
|
|
1248
|
+
return
|
|
1249
|
+
}
|
|
817
1250
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
) {
|
|
822
|
-
const errorMessage = select
|
|
823
|
-
? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
|
|
824
|
-
: `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
|
|
1251
|
+
// Clear error state
|
|
1252
|
+
state.lastError = undefined
|
|
1253
|
+
state.errorCount = 0
|
|
825
1254
|
|
|
826
|
-
|
|
827
|
-
|
|
1255
|
+
const rawData = result.data
|
|
1256
|
+
const newItemsArray = select ? select(rawData) : rawData
|
|
1257
|
+
|
|
1258
|
+
if (
|
|
1259
|
+
!Array.isArray(newItemsArray) ||
|
|
1260
|
+
newItemsArray.some((item) => typeof item !== `object`)
|
|
1261
|
+
) {
|
|
1262
|
+
const errorMessage = select
|
|
1263
|
+
? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
|
|
1264
|
+
: `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
|
|
1265
|
+
|
|
1266
|
+
console.error(errorMessage)
|
|
1267
|
+
return
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const currentSyncedItems: Map<string | number, any> = new Map(
|
|
1271
|
+
collection._state.syncedData.entries(),
|
|
1272
|
+
)
|
|
1273
|
+
const shouldUsePersistedBaseline = persistedBaseline !== undefined
|
|
1274
|
+
const previouslyOwnedRows = shouldUsePersistedBaseline
|
|
1275
|
+
? new Set(persistedBaseline.keys())
|
|
1276
|
+
: getHydratedOwnedRowsForQueryBaseline(hashedQueryKey)
|
|
1277
|
+
const newItemsMap = new Map<string | number, any>()
|
|
1278
|
+
newItemsArray.forEach((item) => {
|
|
1279
|
+
const key = getKey(item)
|
|
1280
|
+
newItemsMap.set(key, item)
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
begin()
|
|
1284
|
+
if (metadata) {
|
|
1285
|
+
metadata.collection.delete(
|
|
1286
|
+
`${QUERY_COLLECTION_GC_PREFIX}${hashedQueryKey}`,
|
|
1287
|
+
)
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
previouslyOwnedRows.forEach((key) => {
|
|
1291
|
+
const oldItem = shouldUsePersistedBaseline
|
|
1292
|
+
? persistedBaseline.get(key)?.value
|
|
1293
|
+
: currentSyncedItems.get(key)
|
|
1294
|
+
if (!oldItem) {
|
|
1295
|
+
return
|
|
1296
|
+
}
|
|
1297
|
+
const newItem = newItemsMap.get(key)
|
|
1298
|
+
if (!newItem) {
|
|
1299
|
+
const owners = getPersistedOwners(key)
|
|
1300
|
+
owners.delete(hashedQueryKey)
|
|
1301
|
+
setPersistedOwners(key, owners)
|
|
1302
|
+
const needToRemove = removeRow(key, hashedQueryKey)
|
|
1303
|
+
if (needToRemove) {
|
|
1304
|
+
write({ type: `delete`, value: oldItem })
|
|
828
1305
|
}
|
|
1306
|
+
} else if (!deepEquals(oldItem, newItem)) {
|
|
1307
|
+
write({ type: `update`, value: newItem })
|
|
1308
|
+
}
|
|
1309
|
+
})
|
|
829
1310
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1311
|
+
newItemsMap.forEach((newItem, key) => {
|
|
1312
|
+
const owners = getPersistedOwners(key)
|
|
1313
|
+
if (!owners.has(hashedQueryKey)) {
|
|
1314
|
+
owners.add(hashedQueryKey)
|
|
1315
|
+
setPersistedOwners(key, owners)
|
|
1316
|
+
}
|
|
1317
|
+
addRow(key, hashedQueryKey)
|
|
1318
|
+
if (!currentSyncedItems.has(key)) {
|
|
1319
|
+
write({ type: `insert`, value: newItem })
|
|
1320
|
+
}
|
|
1321
|
+
})
|
|
838
1322
|
|
|
839
|
-
|
|
1323
|
+
commit()
|
|
1324
|
+
retainedQueriesPendingRevalidation.delete(hashedQueryKey)
|
|
1325
|
+
cancelPersistedRetentionExpiry(hashedQueryKey)
|
|
840
1326
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const needToRemove = removeRow(key, hashedQueryKey) // returns true if the row is no longer referenced by any queries
|
|
845
|
-
if (needToRemove) {
|
|
846
|
-
write({ type: `delete`, value: oldItem })
|
|
847
|
-
}
|
|
848
|
-
} else if (!deepEquals(oldItem, newItem)) {
|
|
849
|
-
// Only update if there are actual differences in the properties
|
|
850
|
-
write({ type: `update`, value: newItem })
|
|
851
|
-
}
|
|
852
|
-
})
|
|
1327
|
+
// Mark collection as ready after first successful query result
|
|
1328
|
+
markReady()
|
|
1329
|
+
}
|
|
853
1330
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1331
|
+
const reconcileSuccessfulResult = async (
|
|
1332
|
+
queryKey: QueryKey,
|
|
1333
|
+
result: QueryObserverResult<any, any>,
|
|
1334
|
+
) => {
|
|
1335
|
+
const hashedQueryKey = hashKey(queryKey)
|
|
1336
|
+
const persistedBaseline =
|
|
1337
|
+
await loadPersistedBaselineForQuery(hashedQueryKey)
|
|
1338
|
+
if (collection.status === `cleaned-up`) {
|
|
1339
|
+
return
|
|
1340
|
+
}
|
|
1341
|
+
applySuccessfulResult(queryKey, result, persistedBaseline)
|
|
1342
|
+
}
|
|
860
1343
|
|
|
861
|
-
|
|
1344
|
+
// eslint-disable-next-line no-shadow
|
|
1345
|
+
const makeQueryResultHandler = (queryKey: QueryKey) => {
|
|
1346
|
+
const hashedQueryKey = hashKey(queryKey)
|
|
1347
|
+
const handleQueryResult: UpdateHandler = (result) => {
|
|
1348
|
+
if (result.isSuccess) {
|
|
1349
|
+
// Skip processing this result while data refreshes are deferred.
|
|
1350
|
+
// Optimistic state covers the gap. Once the barrier resolves,
|
|
1351
|
+
// trigger a fresh refetch to get authoritative data.
|
|
1352
|
+
if (collection.deferDataRefresh) {
|
|
1353
|
+
collection.deferDataRefresh.then(() => {
|
|
1354
|
+
const observer = state.observers.get(hashedQueryKey)
|
|
1355
|
+
if (observer) {
|
|
1356
|
+
observer.refetch().catch(() => {
|
|
1357
|
+
// Errors handled by the next handleQueryResult invocation
|
|
1358
|
+
})
|
|
1359
|
+
}
|
|
1360
|
+
})
|
|
1361
|
+
return
|
|
1362
|
+
}
|
|
862
1363
|
|
|
863
|
-
|
|
864
|
-
|
|
1364
|
+
if (retainedQueriesPendingRevalidation.has(hashedQueryKey)) {
|
|
1365
|
+
void reconcileSuccessfulResult(queryKey, result).catch((error) => {
|
|
1366
|
+
console.error(
|
|
1367
|
+
`[QueryCollection] Error reconciling query ${String(queryKey)}:`,
|
|
1368
|
+
error,
|
|
1369
|
+
)
|
|
1370
|
+
})
|
|
1371
|
+
} else {
|
|
1372
|
+
applySuccessfulResult(queryKey, result)
|
|
1373
|
+
}
|
|
865
1374
|
} else if (result.isError) {
|
|
866
1375
|
const isNewError =
|
|
867
1376
|
result.errorUpdatedAt !== state.lastErrorUpdatedAt ||
|
|
@@ -943,8 +1452,15 @@ export function queryCollectionOptions(
|
|
|
943
1452
|
})
|
|
944
1453
|
}
|
|
945
1454
|
} else {
|
|
946
|
-
|
|
947
|
-
|
|
1455
|
+
if (startupRetentionSettled) {
|
|
1456
|
+
markReady()
|
|
1457
|
+
} else {
|
|
1458
|
+
// In on-demand mode, there is no initial query, but retained-placeholder
|
|
1459
|
+
// maintenance still needs to finish before the collection is treated as ready.
|
|
1460
|
+
void startupRetentionMaintenancePromise.then(() => {
|
|
1461
|
+
markReady()
|
|
1462
|
+
})
|
|
1463
|
+
}
|
|
948
1464
|
}
|
|
949
1465
|
|
|
950
1466
|
// Always subscribe when sync starts (this could be from preload(), startSync config, or first subscriber)
|
|
@@ -965,8 +1481,11 @@ export function queryCollectionOptions(
|
|
|
965
1481
|
const cleanupQueryInternal = (hashedQueryKey: string) => {
|
|
966
1482
|
unsubscribes.get(hashedQueryKey)?.()
|
|
967
1483
|
unsubscribes.delete(hashedQueryKey)
|
|
1484
|
+
cancelPersistedRetentionExpiry(hashedQueryKey)
|
|
1485
|
+
retainedQueriesPendingRevalidation.delete(hashedQueryKey)
|
|
968
1486
|
|
|
969
1487
|
const rowKeys = queryToRows.get(hashedQueryKey) ?? new Set()
|
|
1488
|
+
const nextOwnersByRow = new Map<string | number, Set<string>>()
|
|
970
1489
|
const rowsToDelete: Array<any> = []
|
|
971
1490
|
|
|
972
1491
|
rowKeys.forEach((rowKey) => {
|
|
@@ -976,22 +1495,41 @@ export function queryCollectionOptions(
|
|
|
976
1495
|
return
|
|
977
1496
|
}
|
|
978
1497
|
|
|
979
|
-
queries
|
|
1498
|
+
const nextOwners = new Set(queries)
|
|
1499
|
+
nextOwners.delete(hashedQueryKey)
|
|
1500
|
+
nextOwnersByRow.set(rowKey, nextOwners)
|
|
1501
|
+
|
|
1502
|
+
if (nextOwners.size === 0 && collection.has(rowKey)) {
|
|
1503
|
+
rowsToDelete.push(collection.get(rowKey))
|
|
1504
|
+
}
|
|
1505
|
+
})
|
|
980
1506
|
|
|
981
|
-
|
|
1507
|
+
const shouldWriteMetadata =
|
|
1508
|
+
metadata !== undefined && nextOwnersByRow.size > 0
|
|
1509
|
+
const needsTransaction = shouldWriteMetadata || rowsToDelete.length > 0
|
|
1510
|
+
if (needsTransaction) {
|
|
1511
|
+
begin()
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
nextOwnersByRow.forEach((owners, rowKey) => {
|
|
1515
|
+
if (owners.size === 0) {
|
|
982
1516
|
rowToQueries.delete(rowKey)
|
|
1517
|
+
} else {
|
|
1518
|
+
rowToQueries.set(rowKey, owners)
|
|
1519
|
+
}
|
|
983
1520
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
}
|
|
1521
|
+
if (shouldWriteMetadata) {
|
|
1522
|
+
setPersistedOwners(rowKey, owners)
|
|
987
1523
|
}
|
|
988
1524
|
})
|
|
989
1525
|
|
|
990
1526
|
if (rowsToDelete.length > 0) {
|
|
991
|
-
begin()
|
|
992
1527
|
rowsToDelete.forEach((row) => {
|
|
993
1528
|
write({ type: `delete`, value: row })
|
|
994
1529
|
})
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (needsTransaction) {
|
|
995
1533
|
commit()
|
|
996
1534
|
}
|
|
997
1535
|
|
|
@@ -999,6 +1537,7 @@ export function queryCollectionOptions(
|
|
|
999
1537
|
queryToRows.delete(hashedQueryKey)
|
|
1000
1538
|
hashToQueryKey.delete(hashedQueryKey)
|
|
1001
1539
|
queryRefCounts.delete(hashedQueryKey)
|
|
1540
|
+
effectivePersistedGcTimes.delete(hashedQueryKey)
|
|
1002
1541
|
}
|
|
1003
1542
|
|
|
1004
1543
|
/**
|
|
@@ -1008,6 +1547,8 @@ export function queryCollectionOptions(
|
|
|
1008
1547
|
const cleanupQueryIfIdle = (hashedQueryKey: string) => {
|
|
1009
1548
|
const refcount = queryRefCounts.get(hashedQueryKey) || 0
|
|
1010
1549
|
const observer = state.observers.get(hashedQueryKey)
|
|
1550
|
+
const effectivePersistedGcTime =
|
|
1551
|
+
effectivePersistedGcTimes.get(hashedQueryKey)
|
|
1011
1552
|
|
|
1012
1553
|
if (refcount <= 0) {
|
|
1013
1554
|
// Drop our subscription so hasListeners reflects only active consumers
|
|
@@ -1034,6 +1575,41 @@ export function queryCollectionOptions(
|
|
|
1034
1575
|
)
|
|
1035
1576
|
}
|
|
1036
1577
|
|
|
1578
|
+
if (
|
|
1579
|
+
effectivePersistedGcTime !== undefined &&
|
|
1580
|
+
metadata &&
|
|
1581
|
+
persistedMetadata?.row.scanPersisted
|
|
1582
|
+
) {
|
|
1583
|
+
begin()
|
|
1584
|
+
metadata.collection.set(
|
|
1585
|
+
`${QUERY_COLLECTION_GC_PREFIX}${hashedQueryKey}`,
|
|
1586
|
+
{
|
|
1587
|
+
queryHash: hashedQueryKey,
|
|
1588
|
+
mode:
|
|
1589
|
+
effectivePersistedGcTime === Number.POSITIVE_INFINITY
|
|
1590
|
+
? `until-revalidated`
|
|
1591
|
+
: `ttl`,
|
|
1592
|
+
...(effectivePersistedGcTime === Number.POSITIVE_INFINITY
|
|
1593
|
+
? {}
|
|
1594
|
+
: { expiresAt: Date.now() + effectivePersistedGcTime }),
|
|
1595
|
+
},
|
|
1596
|
+
)
|
|
1597
|
+
commit()
|
|
1598
|
+
if (effectivePersistedGcTime !== Number.POSITIVE_INFINITY) {
|
|
1599
|
+
schedulePersistedRetentionExpiry({
|
|
1600
|
+
queryHash: hashedQueryKey,
|
|
1601
|
+
mode: `ttl`,
|
|
1602
|
+
expiresAt: Date.now() + effectivePersistedGcTime,
|
|
1603
|
+
})
|
|
1604
|
+
}
|
|
1605
|
+
unsubscribes.get(hashedQueryKey)?.()
|
|
1606
|
+
unsubscribes.delete(hashedQueryKey)
|
|
1607
|
+
state.observers.delete(hashedQueryKey)
|
|
1608
|
+
hashToQueryKey.delete(hashedQueryKey)
|
|
1609
|
+
queryRefCounts.set(hashedQueryKey, 0)
|
|
1610
|
+
return
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1037
1613
|
cleanupQueryInternal(hashedQueryKey)
|
|
1038
1614
|
}
|
|
1039
1615
|
|
|
@@ -1063,6 +1639,10 @@ export function queryCollectionOptions(
|
|
|
1063
1639
|
const cleanup = async () => {
|
|
1064
1640
|
unsubscribeFromCollectionEvents()
|
|
1065
1641
|
unsubscribeFromQueries()
|
|
1642
|
+
persistedRetentionTimers.forEach((timer) => {
|
|
1643
|
+
clearTimeout(timer)
|
|
1644
|
+
})
|
|
1645
|
+
persistedRetentionTimers.clear()
|
|
1066
1646
|
|
|
1067
1647
|
const allQueryKeys = [...hashToQueryKey.values()]
|
|
1068
1648
|
const allHashedKeys = [...state.observers.keys()]
|
|
@@ -1233,26 +1813,28 @@ export function queryCollectionOptions(
|
|
|
1233
1813
|
}
|
|
1234
1814
|
|
|
1235
1815
|
/**
|
|
1236
|
-
* Updates the query cache with new items for ALL
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1816
|
+
* Updates the query cache with new items for ALL query keys matching this collection,
|
|
1817
|
+
* including stale/inactive cache entries from destroyed observers.
|
|
1818
|
+
*
|
|
1819
|
+
* This prevents ghost items: when an observer is destroyed but gcTime > 0, TanStack Query
|
|
1820
|
+
* keeps the cached data. If syncedData changes (via writeDelete/writeInsert/writeUpdate)
|
|
1821
|
+
* after the observer is destroyed, the stale cache becomes inconsistent. When a new observer
|
|
1822
|
+
* later picks up this stale cache, makeQueryResultHandler would create spurious sync
|
|
1823
|
+
* operations (re-inserting deleted items, reverting updated values, etc).
|
|
1824
|
+
*
|
|
1825
|
+
* By updating all cache entries (active and stale), we ensure the cache always reflects
|
|
1826
|
+
* the current syncedData state.
|
|
1239
1827
|
*/
|
|
1240
1828
|
const updateCacheData = (items: Array<any>): void => {
|
|
1241
|
-
|
|
1242
|
-
const activeQueryKeys = Array.from(hashToQueryKey.values())
|
|
1829
|
+
const allCached = queryClient.getQueryCache().findAll({ queryKey: baseKey })
|
|
1243
1830
|
|
|
1244
|
-
if (
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
updateCacheDataForKey(key, items)
|
|
1831
|
+
if (allCached.length > 0) {
|
|
1832
|
+
for (const query of allCached) {
|
|
1833
|
+
updateCacheDataForKey(query.queryKey, items)
|
|
1248
1834
|
}
|
|
1249
1835
|
} else {
|
|
1250
|
-
// Fallback: no
|
|
1251
|
-
// This handles the case where updateCacheData is called before any queries are created
|
|
1252
|
-
const baseKey =
|
|
1253
|
-
typeof queryKey === `function`
|
|
1254
|
-
? queryKey({})
|
|
1255
|
-
: (queryKey as unknown as QueryKey)
|
|
1836
|
+
// Fallback: no queries in cache yet, seed the base query key.
|
|
1837
|
+
// This handles the case where updateCacheData is called before any queries are created.
|
|
1256
1838
|
updateCacheDataForKey(baseKey, items)
|
|
1257
1839
|
}
|
|
1258
1840
|
}
|