@tanstack/query-db-collection 1.0.6 → 1.0.8

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.
@@ -3,9 +3,9 @@ import {
3
3
  DuplicateKeyInBatchError,
4
4
  SyncNotInitializedError,
5
5
  UpdateOperationItemNotFoundError,
6
- } from "./errors"
7
- import type { QueryClient } from "@tanstack/query-core"
8
- import type { ChangeMessage, Collection } from "@tanstack/db"
6
+ } from './errors'
7
+ import type { QueryClient } from '@tanstack/query-core'
8
+ import type { ChangeMessage, Collection } from '@tanstack/db'
9
9
 
10
10
  // Track active batch operations per context to prevent cross-collection contamination
11
11
  const activeBatchContexts = new WeakMap<
@@ -38,6 +38,12 @@ export interface SyncContext<
38
38
  begin: () => void
39
39
  write: (message: Omit<ChangeMessage<TRow>, `key`>) => void
40
40
  commit: () => void
41
+ /**
42
+ * Optional function to update the query cache with the latest synced data.
43
+ * Handles both direct array caches and wrapped response formats (when `select` is used).
44
+ * If not provided, falls back to directly setting the cache with the raw array.
45
+ */
46
+ updateCacheData?: (items: Array<TRow>) => void
41
47
  }
42
48
 
43
49
  interface NormalizedOperation<
@@ -58,7 +64,7 @@ function normalizeOperations<
58
64
  ops:
59
65
  | SyncOperation<TRow, TKey, TInsertInput>
60
66
  | Array<SyncOperation<TRow, TKey, TInsertInput>>,
61
- ctx: SyncContext<TRow, TKey>
67
+ ctx: SyncContext<TRow, TKey>,
62
68
  ): Array<NormalizedOperation<TRow, TKey>> {
63
69
  const operations = Array.isArray(ops) ? ops : [ops]
64
70
  const normalized: Array<NormalizedOperation<TRow, TKey>> = []
@@ -80,7 +86,7 @@ function normalizeOperations<
80
86
  // For insert/upsert, validate and resolve the full item first
81
87
  const resolved = ctx.collection.validateData(
82
88
  item,
83
- op.type === `upsert` ? `insert` : op.type
89
+ op.type === `upsert` ? `insert` : op.type,
84
90
  )
85
91
  key = ctx.getKey(resolved)
86
92
  }
@@ -98,7 +104,7 @@ function validateOperations<
98
104
  TKey extends string | number = string | number,
99
105
  >(
100
106
  operations: Array<NormalizedOperation<TRow, TKey>>,
101
- ctx: SyncContext<TRow, TKey>
107
+ ctx: SyncContext<TRow, TKey>,
102
108
  ): void {
103
109
  const seenKeys = new Set<TKey>()
104
110
 
@@ -133,7 +139,7 @@ export function performWriteOperations<
133
139
  operations:
134
140
  | SyncOperation<TRow, TKey, TInsertInput>
135
141
  | Array<SyncOperation<TRow, TKey, TInsertInput>>,
136
- ctx: SyncContext<TRow, TKey>
142
+ ctx: SyncContext<TRow, TKey>,
137
143
  ): void {
138
144
  const normalized = normalizeOperations(operations, ctx)
139
145
  validateOperations(normalized, ctx)
@@ -160,7 +166,7 @@ export function performWriteOperations<
160
166
  const resolved = ctx.collection.validateData(
161
167
  updatedItem,
162
168
  `update`,
163
- op.key
169
+ op.key,
164
170
  )
165
171
  ctx.write({
166
172
  type: `update`,
@@ -183,7 +189,7 @@ export function performWriteOperations<
183
189
  const resolved = ctx.collection.validateData(
184
190
  op.data,
185
191
  existsInSyncedStore ? `update` : `insert`,
186
- op.key
192
+ op.key,
187
193
  )
188
194
  if (existsInSyncedStore) {
189
195
  ctx.write({
@@ -205,7 +211,12 @@ export function performWriteOperations<
205
211
 
206
212
  // Update query cache after successful commit
207
213
  const updatedData = Array.from(ctx.collection._state.syncedData.values())
208
- ctx.queryClient.setQueryData(ctx.queryKey, updatedData)
214
+ if (ctx.updateCacheData) {
215
+ ctx.updateCacheData(updatedData)
216
+ } else {
217
+ // Fallback: directly set the cache with raw array (for non-Query Collection consumers)
218
+ ctx.queryClient.setQueryData(ctx.queryKey, updatedData)
219
+ }
209
220
  }
210
221
 
211
222
  // Factory function to create write utils
@@ -300,7 +311,7 @@ export function createWriteUtils<
300
311
  const existingBatch = activeBatchContexts.get(ctx)
301
312
  if (existingBatch?.isActive) {
302
313
  throw new Error(
303
- `Cannot nest writeBatch calls. Complete the current batch before starting a new one.`
314
+ `Cannot nest writeBatch calls. Complete the current batch before starting a new one.`,
304
315
  )
305
316
  }
306
317
 
@@ -326,7 +337,7 @@ export function createWriteUtils<
326
337
  typeof result.then === `function`
327
338
  ) {
328
339
  throw new Error(
329
- `writeBatch does not support async callbacks. The callback must be synchronous.`
340
+ `writeBatch does not support async callbacks. The callback must be synchronous.`,
330
341
  )
331
342
  }
332
343
 
package/src/query.ts CHANGED
@@ -1,13 +1,13 @@
1
- import { QueryObserver, hashKey } from "@tanstack/query-core"
2
- import { deepEquals } from "@tanstack/db"
1
+ import { QueryObserver, hashKey } from '@tanstack/query-core'
2
+ import { deepEquals } from '@tanstack/db'
3
3
  import {
4
4
  GetKeyRequiredError,
5
5
  QueryClientRequiredError,
6
6
  QueryFnRequiredError,
7
7
  QueryKeyRequiredError,
8
- } from "./errors"
9
- import { createWriteUtils } from "./manual-sync"
10
- import { serializeLoadSubsetOptions } from "./serialization"
8
+ } from './errors'
9
+ import { createWriteUtils } from './manual-sync'
10
+ import { serializeLoadSubsetOptions } from './serialization'
11
11
  import type {
12
12
  BaseCollectionConfig,
13
13
  ChangeMessage,
@@ -18,7 +18,7 @@ import type {
18
18
  SyncConfig,
19
19
  UpdateMutationFnParams,
20
20
  UtilsRecord,
21
- } from "@tanstack/db"
21
+ } from '@tanstack/db'
22
22
  import type {
23
23
  FetchStatus,
24
24
  QueryClient,
@@ -26,11 +26,11 @@ import type {
26
26
  QueryKey,
27
27
  QueryObserverOptions,
28
28
  QueryObserverResult,
29
- } from "@tanstack/query-core"
30
- import type { StandardSchemaV1 } from "@standard-schema/spec"
29
+ } from '@tanstack/query-core'
30
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
31
31
 
32
32
  // Re-export for external use
33
- export type { SyncOperation } from "./manual-sync"
33
+ export type { SyncOperation } from './manual-sync'
34
34
 
35
35
  // Schema output type inference helper (matches electric.ts pattern)
36
36
  type InferSchemaOutput<T> = T extends StandardSchemaV1
@@ -60,7 +60,7 @@ type TQueryKeyBuilder<TQueryKey> = (opts: LoadSubsetOptions) => TQueryKey
60
60
  export interface QueryCollectionConfig<
61
61
  T extends object = object,
62
62
  TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any> = (
63
- context: QueryFunctionContext<any>
63
+ context: QueryFunctionContext<any>,
64
64
  ) => Promise<any>,
65
65
  TError = unknown,
66
66
  TQueryKey extends QueryKey = QueryKey,
@@ -72,7 +72,7 @@ export interface QueryCollectionConfig<
72
72
  queryKey: TQueryKey | TQueryKeyBuilder<TQueryKey>
73
73
  /** Function that fetches data from the server. Must return the complete collection state */
74
74
  queryFn: TQueryFn extends (
75
- context: QueryFunctionContext<TQueryKey>
75
+ context: QueryFunctionContext<TQueryKey>,
76
76
  ) => Promise<Array<any>>
77
77
  ? (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>>
78
78
  : TQueryFn
@@ -232,7 +232,7 @@ class QueryCollectionUtilsImpl {
232
232
  constructor(
233
233
  state: QueryCollectionState,
234
234
  refetch: RefetchFn,
235
- writeUtils: ReturnType<typeof createWriteUtils>
235
+ writeUtils: ReturnType<typeof createWriteUtils>,
236
236
  ) {
237
237
  this.state = state
238
238
  this.refetchFn = refetch
@@ -270,21 +270,21 @@ class QueryCollectionUtilsImpl {
270
270
  public get isFetching() {
271
271
  // check if any observer is fetching
272
272
  return Array.from(this.state.observers.values()).some(
273
- (observer) => observer.getCurrentResult().isFetching
273
+ (observer) => observer.getCurrentResult().isFetching,
274
274
  )
275
275
  }
276
276
 
277
277
  public get isRefetching() {
278
278
  // check if any observer is refetching
279
279
  return Array.from(this.state.observers.values()).some(
280
- (observer) => observer.getCurrentResult().isRefetching
280
+ (observer) => observer.getCurrentResult().isRefetching,
281
281
  )
282
282
  }
283
283
 
284
284
  public get isLoading() {
285
285
  // check if any observer is loading
286
286
  return Array.from(this.state.observers.values()).some(
287
- (observer) => observer.getCurrentResult().isLoading
287
+ (observer) => observer.getCurrentResult().isLoading,
288
288
  )
289
289
  }
290
290
 
@@ -293,14 +293,14 @@ class QueryCollectionUtilsImpl {
293
293
  return Math.max(
294
294
  0,
295
295
  ...Array.from(this.state.observers.values()).map(
296
- (observer) => observer.getCurrentResult().dataUpdatedAt
297
- )
296
+ (observer) => observer.getCurrentResult().dataUpdatedAt,
297
+ ),
298
298
  )
299
299
  }
300
300
 
301
301
  public get fetchStatus(): Array<FetchStatus> {
302
302
  return Array.from(this.state.observers.values()).map(
303
- (observer) => observer.getCurrentResult().fetchStatus
303
+ (observer) => observer.getCurrentResult().fetchStatus,
304
304
  )
305
305
  }
306
306
  }
@@ -409,7 +409,7 @@ export function queryCollectionOptions<
409
409
  > & {
410
410
  schema: T
411
411
  select: (data: TQueryData) => Array<InferSchemaInput<T>>
412
- }
412
+ },
413
413
  ): CollectionConfig<
414
414
  InferSchemaOutput<T>,
415
415
  TKey,
@@ -429,7 +429,7 @@ export function queryCollectionOptions<
429
429
  export function queryCollectionOptions<
430
430
  T extends object,
431
431
  TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any> = (
432
- context: QueryFunctionContext<any>
432
+ context: QueryFunctionContext<any>,
433
433
  ) => Promise<any>,
434
434
  TError = unknown,
435
435
  TQueryKey extends QueryKey = QueryKey,
@@ -447,7 +447,7 @@ export function queryCollectionOptions<
447
447
  > & {
448
448
  schema?: never // prohibit schema
449
449
  select: (data: TQueryData) => Array<T>
450
- }
450
+ },
451
451
  ): CollectionConfig<
452
452
  T,
453
453
  TKey,
@@ -468,7 +468,7 @@ export function queryCollectionOptions<
468
468
  config: QueryCollectionConfig<
469
469
  InferSchemaOutput<T>,
470
470
  (
471
- context: QueryFunctionContext<any>
471
+ context: QueryFunctionContext<any>,
472
472
  ) => Promise<Array<InferSchemaOutput<T>>>,
473
473
  TError,
474
474
  TQueryKey,
@@ -476,7 +476,7 @@ export function queryCollectionOptions<
476
476
  T
477
477
  > & {
478
478
  schema: T
479
- }
479
+ },
480
480
  ): CollectionConfig<
481
481
  InferSchemaOutput<T>,
482
482
  TKey,
@@ -507,7 +507,7 @@ export function queryCollectionOptions<
507
507
  TKey
508
508
  > & {
509
509
  schema?: never // prohibit schema
510
- }
510
+ },
511
511
  ): CollectionConfig<
512
512
  T,
513
513
  TKey,
@@ -519,7 +519,7 @@ export function queryCollectionOptions<
519
519
  }
520
520
 
521
521
  export function queryCollectionOptions(
522
- config: QueryCollectionConfig<Record<string, unknown>>
522
+ config: QueryCollectionConfig<Record<string, unknown>>,
523
523
  ): CollectionConfig<
524
524
  Record<string, unknown>,
525
525
  string | number,
@@ -663,7 +663,7 @@ export function queryCollectionOptions(
663
663
 
664
664
  const createQueryFromOpts = (
665
665
  opts: LoadSubsetOptions = {},
666
- queryFunction: typeof queryFn = queryFn
666
+ queryFunction: typeof queryFn = queryFn,
667
667
  ): true | Promise<void> => {
668
668
  // Generate key using common function
669
669
  const key = generateQueryKeyFromOptions(opts)
@@ -675,7 +675,7 @@ export function queryCollectionOptions(
675
675
  // Increment reference count since another consumer is using this observer
676
676
  queryRefCounts.set(
677
677
  hashedQueryKey,
678
- (queryRefCounts.get(hashedQueryKey) || 0) + 1
678
+ (queryRefCounts.get(hashedQueryKey) || 0) + 1,
679
679
  )
680
680
 
681
681
  // Get the current result and return based on its state
@@ -739,7 +739,7 @@ export function queryCollectionOptions(
739
739
  // Increment reference count for this query
740
740
  queryRefCounts.set(
741
741
  hashedQueryKey,
742
- (queryRefCounts.get(hashedQueryKey) || 0) + 1
742
+ (queryRefCounts.get(hashedQueryKey) || 0) + 1,
743
743
  )
744
744
 
745
745
  // Create a promise that resolves when the query result is first available
@@ -790,7 +790,7 @@ export function queryCollectionOptions(
790
790
  }
791
791
 
792
792
  const currentSyncedItems: Map<string | number, any> = new Map(
793
- collection._state.syncedData.entries()
793
+ collection._state.syncedData.entries(),
794
794
  )
795
795
  const newItemsMap = new Map<string | number, any>()
796
796
  newItemsArray.forEach((item) => {
@@ -833,7 +833,7 @@ export function queryCollectionOptions(
833
833
 
834
834
  console.error(
835
835
  `[QueryCollection] Error observing query ${String(queryKey)}:`,
836
- result.error
836
+ result.error,
837
837
  )
838
838
 
839
839
  // Mark collection as ready even on error to avoid blocking apps
@@ -849,7 +849,7 @@ export function queryCollectionOptions(
849
849
 
850
850
  const subscribeToQuery = (
851
851
  observer: QueryObserver<Array<any>, any, Array<any>, Array<any>, any>,
852
- hashedQueryKey: string
852
+ hashedQueryKey: string,
853
853
  ) => {
854
854
  if (!isSubscribed(hashedQueryKey)) {
855
855
  const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
@@ -889,7 +889,7 @@ export function queryCollectionOptions(
889
889
  } else if (subscriberCount === 0) {
890
890
  unsubscribeFromQueries()
891
891
  }
892
- }
892
+ },
893
893
  )
894
894
 
895
895
  // If syncMode is eager, create the initial query without any predicates
@@ -989,7 +989,7 @@ export function queryCollectionOptions(
989
989
  if (refcount > 0) {
990
990
  console.warn(
991
991
  `[cleanupQueryIfIdle] Invariant violation: refcount=${refcount} but no listeners. Cleaning up to prevent leak.`,
992
- { hashedQueryKey }
992
+ { hashedQueryKey },
993
993
  )
994
994
  }
995
995
 
@@ -1040,7 +1040,7 @@ export function queryCollectionOptions(
1040
1040
  allQueryKeys.map(async (qKey) => {
1041
1041
  await queryClient.cancelQueries({ queryKey: qKey, exact: true })
1042
1042
  queryClient.removeQueries({ queryKey: qKey, exact: true })
1043
- })
1043
+ }),
1044
1044
  )
1045
1045
  }
1046
1046
 
@@ -1130,6 +1130,73 @@ export function queryCollectionOptions(
1130
1130
  await Promise.all(refetchPromises)
1131
1131
  }
1132
1132
 
1133
+ /**
1134
+ * Updates the query cache with new items, handling both direct arrays
1135
+ * and wrapped response formats (when `select` is used).
1136
+ */
1137
+ const updateCacheData = (items: Array<any>): void => {
1138
+ // Get the base query key (handle both static and function-based keys)
1139
+ const key =
1140
+ typeof queryKey === `function`
1141
+ ? queryKey({})
1142
+ : (queryKey as unknown as QueryKey)
1143
+
1144
+ if (select) {
1145
+ // When `select` is used, the cache contains a wrapped response (e.g., { data: [...], meta: {...} })
1146
+ // We need to update the cache while preserving the wrapper structure
1147
+ queryClient.setQueryData(key, (oldData: any) => {
1148
+ if (!oldData || typeof oldData !== `object`) {
1149
+ // No existing cache or not an object - don't corrupt the cache
1150
+ return oldData
1151
+ }
1152
+
1153
+ if (Array.isArray(oldData)) {
1154
+ // Cache is already a raw array (shouldn't happen with select, but handle it)
1155
+ return items
1156
+ }
1157
+
1158
+ // Use the select function to identify which property contains the items array.
1159
+ // This is more robust than guessing based on property order.
1160
+ const selectedArray = select(oldData)
1161
+
1162
+ if (Array.isArray(selectedArray)) {
1163
+ // Find the property that matches the selected array by reference equality
1164
+ for (const propKey of Object.keys(oldData)) {
1165
+ if (oldData[propKey] === selectedArray) {
1166
+ // Found the exact property - create a shallow copy with updated items
1167
+ return { ...oldData, [propKey]: items }
1168
+ }
1169
+ }
1170
+ }
1171
+
1172
+ // Fallback: check common property names used for data arrays
1173
+ if (Array.isArray(oldData.data)) {
1174
+ return { ...oldData, data: items }
1175
+ }
1176
+ if (Array.isArray(oldData.items)) {
1177
+ return { ...oldData, items: items }
1178
+ }
1179
+ if (Array.isArray(oldData.results)) {
1180
+ return { ...oldData, results: items }
1181
+ }
1182
+
1183
+ // Last resort: find first array property
1184
+ for (const propKey of Object.keys(oldData)) {
1185
+ if (Array.isArray(oldData[propKey])) {
1186
+ return { ...oldData, [propKey]: items }
1187
+ }
1188
+ }
1189
+
1190
+ // Couldn't safely identify the array property - don't corrupt the cache
1191
+ // Return oldData unchanged to avoid breaking select
1192
+ return oldData
1193
+ })
1194
+ } else {
1195
+ // No select - cache contains raw array, just set it directly
1196
+ queryClient.setQueryData(key, items)
1197
+ }
1198
+ }
1199
+
1133
1200
  // Create write context for manual write operations
1134
1201
  let writeContext: {
1135
1202
  collection: any
@@ -1139,21 +1206,29 @@ export function queryCollectionOptions(
1139
1206
  begin: () => void
1140
1207
  write: (message: Omit<ChangeMessage<any>, `key`>) => void
1141
1208
  commit: () => void
1209
+ updateCacheData?: (items: Array<any>) => void
1142
1210
  } | null = null
1143
1211
 
1144
1212
  // Enhanced internalSync that captures write functions for manual use
1145
1213
  const enhancedInternalSync: SyncConfig<any>[`sync`] = (params) => {
1146
1214
  const { begin, write, commit, collection } = params
1147
1215
 
1216
+ // Get the base query key for the context (handle both static and function-based keys)
1217
+ const contextQueryKey =
1218
+ typeof queryKey === `function`
1219
+ ? (queryKey({}) as unknown as Array<unknown>)
1220
+ : (queryKey as unknown as Array<unknown>)
1221
+
1148
1222
  // Store references for manual write operations
1149
1223
  writeContext = {
1150
1224
  collection,
1151
1225
  queryClient,
1152
- queryKey: queryKey as unknown as Array<unknown>,
1226
+ queryKey: contextQueryKey,
1153
1227
  getKey: getKey as (item: any) => string | number,
1154
1228
  begin,
1155
1229
  write,
1156
1230
  commit,
1231
+ updateCacheData,
1157
1232
  }
1158
1233
 
1159
1234
  // Call the original internalSync logic
@@ -1162,7 +1237,7 @@ export function queryCollectionOptions(
1162
1237
 
1163
1238
  // Create write utils using the manual-sync module
1164
1239
  const writeUtils = createWriteUtils<any, string | number, any>(
1165
- () => writeContext
1240
+ () => writeContext,
1166
1241
  )
1167
1242
 
1168
1243
  // Create wrapper handlers for direct persistence operations that handle refetching
@@ -1,11 +1,13 @@
1
- import type { IR, LoadSubsetOptions } from "@tanstack/db"
1
+ import type { IR, LoadSubsetOptions } from '@tanstack/db'
2
2
 
3
3
  /**
4
- * Serializes LoadSubsetOptions into a stable, hashable format for query keys
4
+ * Serializes LoadSubsetOptions into a stable, hashable format for query keys.
5
+ * Includes where, orderBy, limit, and offset for pagination support.
6
+ * Note: cursor expressions are not serialized as they are backend-specific.
5
7
  * @internal
6
8
  */
7
9
  export function serializeLoadSubsetOptions(
8
- options: LoadSubsetOptions | undefined
10
+ options: LoadSubsetOptions | undefined,
9
11
  ): string | undefined {
10
12
  if (!options) {
11
13
  return undefined
@@ -43,6 +45,11 @@ export function serializeLoadSubsetOptions(
43
45
  result.limit = options.limit
44
46
  }
45
47
 
48
+ // Include offset for pagination support
49
+ if (options.offset !== undefined) {
50
+ result.offset = options.offset
51
+ }
52
+
46
53
  return Object.keys(result).length === 0 ? undefined : JSON.stringify(result)
47
54
  }
48
55
 
@@ -120,7 +127,7 @@ function serializeValue(value: unknown): unknown {
120
127
  Object.entries(value as Record<string, unknown>).map(([key, val]) => [
121
128
  key,
122
129
  serializeValue(val),
123
- ])
130
+ ]),
124
131
  )
125
132
  }
126
133