@tanstack/query-db-collection 0.0.2

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/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@tanstack/query-db-collection",
3
+ "description": "TanStack Query collection for TanStack DB",
4
+ "version": "0.0.2",
5
+ "dependencies": {
6
+ "@tanstack/db": "workspace:*",
7
+ "@tanstack/query-core": "^5.75.7"
8
+ },
9
+ "devDependencies": {
10
+ "@vitest/coverage-istanbul": "^3.0.9"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "import": {
15
+ "types": "./dist/esm/index.d.ts",
16
+ "default": "./dist/esm/index.js"
17
+ },
18
+ "require": {
19
+ "types": "./dist/cjs/index.d.cts",
20
+ "default": "./dist/cjs/index.cjs"
21
+ }
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "main": "dist/cjs/index.cjs",
30
+ "module": "dist/esm/index.js",
31
+ "packageManager": "pnpm@10.6.3",
32
+ "peerDependencies": {
33
+ "typescript": ">=4.7"
34
+ },
35
+ "author": "Kyle Mathews",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/TanStack/db.git",
40
+ "directory": "packages/query-db-collection"
41
+ },
42
+ "homepage": "https://tanstack.com/db",
43
+ "keywords": [
44
+ "query",
45
+ "tanstack",
46
+ "optimistic",
47
+ "typescript"
48
+ ],
49
+ "scripts": {
50
+ "build": "vite build",
51
+ "dev": "vite build --watch",
52
+ "lint": "eslint . --fix",
53
+ "test": "npx vitest --run"
54
+ },
55
+ "sideEffects": false,
56
+ "type": "module",
57
+ "types": "dist/esm/index.d.ts"
58
+ }
59
+
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {
2
+ queryCollectionOptions,
3
+ type QueryCollectionConfig,
4
+ type QueryCollectionUtils,
5
+ } from "./query"
package/src/query.ts ADDED
@@ -0,0 +1,453 @@
1
+ import { QueryObserver } from "@tanstack/query-core"
2
+ import type {
3
+ QueryClient,
4
+ QueryFunctionContext,
5
+ QueryKey,
6
+ QueryObserverOptions,
7
+ } from "@tanstack/query-core"
8
+ import type {
9
+ CollectionConfig,
10
+ DeleteMutationFn,
11
+ DeleteMutationFnParams,
12
+ InsertMutationFn,
13
+ InsertMutationFnParams,
14
+ SyncConfig,
15
+ UpdateMutationFn,
16
+ UpdateMutationFnParams,
17
+ UtilsRecord,
18
+ } from "@tanstack/db"
19
+
20
+ export interface QueryCollectionConfig<
21
+ TItem extends object,
22
+ TError = unknown,
23
+ TQueryKey extends QueryKey = QueryKey,
24
+ > {
25
+ queryKey: TQueryKey
26
+ queryFn: (context: QueryFunctionContext<TQueryKey>) => Promise<Array<TItem>>
27
+ queryClient: QueryClient
28
+
29
+ // Query-specific options
30
+ enabled?: boolean
31
+ refetchInterval?: QueryObserverOptions<
32
+ Array<TItem>,
33
+ TError,
34
+ Array<TItem>,
35
+ Array<TItem>,
36
+ TQueryKey
37
+ >[`refetchInterval`]
38
+ retry?: QueryObserverOptions<
39
+ Array<TItem>,
40
+ TError,
41
+ Array<TItem>,
42
+ Array<TItem>,
43
+ TQueryKey
44
+ >[`retry`]
45
+ retryDelay?: QueryObserverOptions<
46
+ Array<TItem>,
47
+ TError,
48
+ Array<TItem>,
49
+ Array<TItem>,
50
+ TQueryKey
51
+ >[`retryDelay`]
52
+ staleTime?: QueryObserverOptions<
53
+ Array<TItem>,
54
+ TError,
55
+ Array<TItem>,
56
+ Array<TItem>,
57
+ TQueryKey
58
+ >[`staleTime`]
59
+
60
+ // Standard Collection configuration properties
61
+ id?: string
62
+ getKey: CollectionConfig<TItem>[`getKey`]
63
+ schema?: CollectionConfig<TItem>[`schema`]
64
+ sync?: CollectionConfig<TItem>[`sync`]
65
+ startSync?: CollectionConfig<TItem>[`startSync`]
66
+
67
+ // Direct persistence handlers
68
+ /**
69
+ * Optional asynchronous handler function called before an insert operation
70
+ * @param params Object containing transaction and collection information
71
+ * @returns Promise resolving to void or { refetch?: boolean } to control refetching
72
+ * @example
73
+ * // Basic query collection insert handler
74
+ * onInsert: async ({ transaction }) => {
75
+ * const newItem = transaction.mutations[0].modified
76
+ * await api.createTodo(newItem)
77
+ * // Automatically refetches query after insert
78
+ * }
79
+ *
80
+ * @example
81
+ * // Insert handler with refetch control
82
+ * onInsert: async ({ transaction }) => {
83
+ * const newItem = transaction.mutations[0].modified
84
+ * await api.createTodo(newItem)
85
+ * return { refetch: false } // Skip automatic refetch
86
+ * }
87
+ *
88
+ * @example
89
+ * // Insert handler with multiple items
90
+ * onInsert: async ({ transaction }) => {
91
+ * const items = transaction.mutations.map(m => m.modified)
92
+ * await api.createTodos(items)
93
+ * // Will refetch query to get updated data
94
+ * }
95
+ *
96
+ * @example
97
+ * // Insert handler with error handling
98
+ * onInsert: async ({ transaction }) => {
99
+ * try {
100
+ * const newItem = transaction.mutations[0].modified
101
+ * await api.createTodo(newItem)
102
+ * } catch (error) {
103
+ * console.error('Insert failed:', error)
104
+ * throw error // Transaction will rollback optimistic changes
105
+ * }
106
+ * }
107
+ */
108
+ onInsert?: InsertMutationFn<TItem>
109
+
110
+ /**
111
+ * Optional asynchronous handler function called before an update operation
112
+ * @param params Object containing transaction and collection information
113
+ * @returns Promise resolving to void or { refetch?: boolean } to control refetching
114
+ * @example
115
+ * // Basic query collection update handler
116
+ * onUpdate: async ({ transaction }) => {
117
+ * const mutation = transaction.mutations[0]
118
+ * await api.updateTodo(mutation.original.id, mutation.changes)
119
+ * // Automatically refetches query after update
120
+ * }
121
+ *
122
+ * @example
123
+ * // Update handler with multiple items
124
+ * onUpdate: async ({ transaction }) => {
125
+ * const updates = transaction.mutations.map(m => ({
126
+ * id: m.key,
127
+ * changes: m.changes
128
+ * }))
129
+ * await api.updateTodos(updates)
130
+ * // Will refetch query to get updated data
131
+ * }
132
+ *
133
+ * @example
134
+ * // Update handler with manual refetch
135
+ * onUpdate: async ({ transaction, collection }) => {
136
+ * const mutation = transaction.mutations[0]
137
+ * await api.updateTodo(mutation.original.id, mutation.changes)
138
+ *
139
+ * // Manually trigger refetch
140
+ * await collection.utils.refetch()
141
+ *
142
+ * return { refetch: false } // Skip automatic refetch
143
+ * }
144
+ *
145
+ * @example
146
+ * // Update handler with related collection refetch
147
+ * onUpdate: async ({ transaction, collection }) => {
148
+ * const mutation = transaction.mutations[0]
149
+ * await api.updateTodo(mutation.original.id, mutation.changes)
150
+ *
151
+ * // Refetch related collections when this item changes
152
+ * await Promise.all([
153
+ * collection.utils.refetch(), // Refetch this collection
154
+ * usersCollection.utils.refetch(), // Refetch users
155
+ * tagsCollection.utils.refetch() // Refetch tags
156
+ * ])
157
+ *
158
+ * return { refetch: false } // Skip automatic refetch since we handled it manually
159
+ * }
160
+ */
161
+ onUpdate?: UpdateMutationFn<TItem>
162
+
163
+ /**
164
+ * Optional asynchronous handler function called before a delete operation
165
+ * @param params Object containing transaction and collection information
166
+ * @returns Promise resolving to void or { refetch?: boolean } to control refetching
167
+ * @example
168
+ * // Basic query collection delete handler
169
+ * onDelete: async ({ transaction }) => {
170
+ * const mutation = transaction.mutations[0]
171
+ * await api.deleteTodo(mutation.original.id)
172
+ * // Automatically refetches query after delete
173
+ * }
174
+ *
175
+ * @example
176
+ * // Delete handler with refetch control
177
+ * onDelete: async ({ transaction }) => {
178
+ * const mutation = transaction.mutations[0]
179
+ * await api.deleteTodo(mutation.original.id)
180
+ * return { refetch: false } // Skip automatic refetch
181
+ * }
182
+ *
183
+ * @example
184
+ * // Delete handler with multiple items
185
+ * onDelete: async ({ transaction }) => {
186
+ * const keysToDelete = transaction.mutations.map(m => m.key)
187
+ * await api.deleteTodos(keysToDelete)
188
+ * // Will refetch query to get updated data
189
+ * }
190
+ *
191
+ * @example
192
+ * // Delete handler with related collection refetch
193
+ * onDelete: async ({ transaction, collection }) => {
194
+ * const mutation = transaction.mutations[0]
195
+ * await api.deleteTodo(mutation.original.id)
196
+ *
197
+ * // Refetch related collections when this item is deleted
198
+ * await Promise.all([
199
+ * collection.utils.refetch(), // Refetch this collection
200
+ * usersCollection.utils.refetch(), // Refetch users
201
+ * projectsCollection.utils.refetch() // Refetch projects
202
+ * ])
203
+ *
204
+ * return { refetch: false } // Skip automatic refetch since we handled it manually
205
+ * }
206
+ */
207
+ onDelete?: DeleteMutationFn<TItem>
208
+ // TODO type returning { refetch: boolean }
209
+ }
210
+
211
+ /**
212
+ * Type for the refetch utility function
213
+ */
214
+ export type RefetchFn = () => Promise<void>
215
+
216
+ /**
217
+ * Query collection utilities type
218
+ */
219
+ export interface QueryCollectionUtils extends UtilsRecord {
220
+ refetch: RefetchFn
221
+ }
222
+
223
+ /**
224
+ * Creates query collection options for use with a standard Collection
225
+ *
226
+ * @param config - Configuration options for the Query collection
227
+ * @returns Collection options with utilities
228
+ */
229
+ export function queryCollectionOptions<
230
+ TItem extends object,
231
+ TError = unknown,
232
+ TQueryKey extends QueryKey = QueryKey,
233
+ >(
234
+ config: QueryCollectionConfig<TItem, TError, TQueryKey>
235
+ ): CollectionConfig<TItem> & { utils: QueryCollectionUtils } {
236
+ const {
237
+ queryKey,
238
+ queryFn,
239
+ queryClient,
240
+ enabled,
241
+ refetchInterval,
242
+ retry,
243
+ retryDelay,
244
+ staleTime,
245
+ getKey,
246
+ onInsert,
247
+ onUpdate,
248
+ onDelete,
249
+ ...baseCollectionConfig
250
+ } = config
251
+
252
+ // Validate required parameters
253
+
254
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
255
+ if (!queryKey) {
256
+ throw new Error(`[QueryCollection] queryKey must be provided.`)
257
+ }
258
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
259
+ if (!queryFn) {
260
+ throw new Error(`[QueryCollection] queryFn must be provided.`)
261
+ }
262
+
263
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
264
+ if (!queryClient) {
265
+ throw new Error(`[QueryCollection] queryClient must be provided.`)
266
+ }
267
+
268
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
269
+ if (!getKey) {
270
+ throw new Error(`[QueryCollection] getKey must be provided.`)
271
+ }
272
+
273
+ const internalSync: SyncConfig<TItem>[`sync`] = (params) => {
274
+ const { begin, write, commit, collection } = params
275
+
276
+ const observerOptions: QueryObserverOptions<
277
+ Array<TItem>,
278
+ TError,
279
+ Array<TItem>,
280
+ Array<TItem>,
281
+ TQueryKey
282
+ > = {
283
+ queryKey: queryKey,
284
+ queryFn: queryFn,
285
+ enabled: enabled,
286
+ refetchInterval: refetchInterval,
287
+ retry: retry,
288
+ retryDelay: retryDelay,
289
+ staleTime: staleTime,
290
+ structuralSharing: true,
291
+ notifyOnChangeProps: `all`,
292
+ }
293
+
294
+ const localObserver = new QueryObserver<
295
+ Array<TItem>,
296
+ TError,
297
+ Array<TItem>,
298
+ Array<TItem>,
299
+ TQueryKey
300
+ >(queryClient, observerOptions)
301
+
302
+ const actualUnsubscribeFn = localObserver.subscribe((result) => {
303
+ if (result.isSuccess) {
304
+ const newItemsArray = result.data
305
+
306
+ if (
307
+ !Array.isArray(newItemsArray) ||
308
+ newItemsArray.some((item) => typeof item !== `object`)
309
+ ) {
310
+ console.error(
311
+ `[QueryCollection] queryFn did not return an array of objects. Skipping update.`,
312
+ newItemsArray
313
+ )
314
+ return
315
+ }
316
+
317
+ const currentSyncedItems = new Map(collection.syncedData)
318
+ const newItemsMap = new Map<string | number, TItem>()
319
+ newItemsArray.forEach((item) => {
320
+ const key = getKey(item)
321
+ newItemsMap.set(key, item)
322
+ })
323
+
324
+ begin()
325
+
326
+ // Helper function for shallow equality check of objects
327
+ const shallowEqual = (
328
+ obj1: Record<string, any>,
329
+ obj2: Record<string, any>
330
+ ): boolean => {
331
+ // Get all keys from both objects
332
+ const keys1 = Object.keys(obj1)
333
+ const keys2 = Object.keys(obj2)
334
+
335
+ // If number of keys is different, objects are not equal
336
+ if (keys1.length !== keys2.length) return false
337
+
338
+ // Check if all keys in obj1 have the same values in obj2
339
+ return keys1.every((key) => {
340
+ // Skip comparing functions and complex objects deeply
341
+ if (typeof obj1[key] === `function`) return true
342
+ if (typeof obj1[key] === `object` && obj1[key] !== null) {
343
+ // For nested objects, just compare references
344
+ // A more robust solution might do recursive shallow comparison
345
+ // or let users provide a custom equality function
346
+ return obj1[key] === obj2[key]
347
+ }
348
+ return obj1[key] === obj2[key]
349
+ })
350
+ }
351
+
352
+ currentSyncedItems.forEach((oldItem, key) => {
353
+ const newItem = newItemsMap.get(key)
354
+ if (!newItem) {
355
+ write({ type: `delete`, value: oldItem })
356
+ } else if (
357
+ !shallowEqual(
358
+ oldItem as Record<string, any>,
359
+ newItem as Record<string, any>
360
+ )
361
+ ) {
362
+ // Only update if there are actual differences in the properties
363
+ write({ type: `update`, value: newItem })
364
+ }
365
+ })
366
+
367
+ newItemsMap.forEach((newItem, key) => {
368
+ if (!currentSyncedItems.has(key)) {
369
+ write({ type: `insert`, value: newItem })
370
+ }
371
+ })
372
+
373
+ commit()
374
+ } else if (result.isError) {
375
+ console.error(
376
+ `[QueryCollection] Error observing query ${String(queryKey)}:`,
377
+ result.error
378
+ )
379
+ }
380
+ })
381
+
382
+ return async () => {
383
+ actualUnsubscribeFn()
384
+ await queryClient.cancelQueries({ queryKey })
385
+ queryClient.removeQueries({ queryKey })
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Refetch the query data
391
+ * @returns Promise that resolves when the refetch is complete
392
+ */
393
+ const refetch: RefetchFn = async (): Promise<void> => {
394
+ return queryClient.refetchQueries({
395
+ queryKey: queryKey,
396
+ })
397
+ }
398
+
399
+ // Create wrapper handlers for direct persistence operations that handle refetching
400
+ const wrappedOnInsert = onInsert
401
+ ? async (params: InsertMutationFnParams<TItem>) => {
402
+ const handlerResult = (await onInsert(params)) ?? {}
403
+ const shouldRefetch =
404
+ (handlerResult as { refetch?: boolean }).refetch !== false
405
+
406
+ if (shouldRefetch) {
407
+ await refetch()
408
+ }
409
+
410
+ return handlerResult
411
+ }
412
+ : undefined
413
+
414
+ const wrappedOnUpdate = onUpdate
415
+ ? async (params: UpdateMutationFnParams<TItem>) => {
416
+ const handlerResult = (await onUpdate(params)) ?? {}
417
+ const shouldRefetch =
418
+ (handlerResult as { refetch?: boolean }).refetch !== false
419
+
420
+ if (shouldRefetch) {
421
+ await refetch()
422
+ }
423
+
424
+ return handlerResult
425
+ }
426
+ : undefined
427
+
428
+ const wrappedOnDelete = onDelete
429
+ ? async (params: DeleteMutationFnParams<TItem>) => {
430
+ const handlerResult = (await onDelete(params)) ?? {}
431
+ const shouldRefetch =
432
+ (handlerResult as { refetch?: boolean }).refetch !== false
433
+
434
+ if (shouldRefetch) {
435
+ await refetch()
436
+ }
437
+
438
+ return handlerResult
439
+ }
440
+ : undefined
441
+
442
+ return {
443
+ ...baseCollectionConfig,
444
+ getKey,
445
+ sync: { sync: internalSync },
446
+ onInsert: wrappedOnInsert,
447
+ onUpdate: wrappedOnUpdate,
448
+ onDelete: wrappedOnDelete,
449
+ utils: {
450
+ refetch,
451
+ },
452
+ }
453
+ }