@tanstack/query-db-collection 1.0.29 → 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/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
- // eslint-disable-next-line no-shadow
807
- const makeQueryResultHandler = (queryKey: QueryKey) => {
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
- const rawData = result.data
816
- const newItemsArray = select ? select(rawData) : rawData
1247
+ if (collection.status === `cleaned-up`) {
1248
+ return
1249
+ }
817
1250
 
818
- if (
819
- !Array.isArray(newItemsArray) ||
820
- newItemsArray.some((item) => typeof item !== `object`)
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
- console.error(errorMessage)
827
- return
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
- const currentSyncedItems: Map<string | number, any> = new Map(
831
- collection._state.syncedData.entries(),
832
- )
833
- const newItemsMap = new Map<string | number, any>()
834
- newItemsArray.forEach((item) => {
835
- const key = getKey(item)
836
- newItemsMap.set(key, item)
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
- begin()
1323
+ commit()
1324
+ retainedQueriesPendingRevalidation.delete(hashedQueryKey)
1325
+ cancelPersistedRetentionExpiry(hashedQueryKey)
840
1326
 
841
- currentSyncedItems.forEach((oldItem, key) => {
842
- const newItem = newItemsMap.get(key)
843
- if (!newItem) {
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
- newItemsMap.forEach((newItem, key) => {
855
- addRow(key, hashedQueryKey)
856
- if (!currentSyncedItems.has(key)) {
857
- write({ type: `insert`, value: newItem })
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
- commit()
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
- // Mark collection as ready after first successful query result
864
- markReady()
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
- // In on-demand mode, mark ready immediately since there's no initial query
947
- markReady()
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.delete(hashedQueryKey)
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
- if (queries.size === 0) {
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
- if (collection.has(rowKey)) {
985
- rowsToDelete.push(collection.get(rowKey))
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 active query keys.
1237
- * This is critical for on-demand mode where multiple query keys may exist
1238
- * (each with different predicates).
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
- // Get all active query keys from the hashToQueryKey map
1242
- const activeQueryKeys = Array.from(hashToQueryKey.values())
1829
+ const allCached = queryClient.getQueryCache().findAll({ queryKey: baseKey })
1243
1830
 
1244
- if (activeQueryKeys.length > 0) {
1245
- // Update all active query keys in the cache
1246
- for (const key of activeQueryKeys) {
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 active queries yet, use the base query key
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
  }