From 34b71d0dff57b11dec79ad2f02f0ad21f6b2e8d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Nov 2025 17:31:04 +0000 Subject: [PATCH 1/5] docs: investigate pagination metadata feature request Comprehensive investigation of Discord user's feature request to support returning extra pagination info (like total_count) from queryFn in queryCollectionOptions. Key findings: - The full query response IS cached in TanStack Query already - The select function extracts just the array portion - But metadata is not accessible through collection.utils API - Current workaround requires direct queryClient.getQueryData() access Recommended solution: Add queryData property to QueryCollectionUtils to expose the full query response in a reactive, type-safe way. This would enable clean TanStack Table integration with pagination metadata without hacky workarounds like duplicating total_count into every item. --- PAGINATION_METADATA_INVESTIGATION.md | 349 +++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 PAGINATION_METADATA_INVESTIGATION.md diff --git a/PAGINATION_METADATA_INVESTIGATION.md b/PAGINATION_METADATA_INVESTIGATION.md new file mode 100644 index 000000000..e6914b61a --- /dev/null +++ b/PAGINATION_METADATA_INVESTIGATION.md @@ -0,0 +1,349 @@ +# Investigation: Supporting Pagination Metadata in queryCollectionOptions + +## Issue Summary + +A Discord user (Amir Hoseinian) requested a way to return extra pagination info alongside the data array from `queryFn` in TanStack Table integration. Their API returns: + +```typescript +{ + data: object[] + pagination_info: { total_count: number } +} +``` + +Currently, `queryFn` in `queryCollectionOptions` only allows returning an array of objects, forcing users to duplicate metadata into every item: + +```typescript +queryFn: async (ctx) => { + const { data } = await client.v1.getApiContacts(...) + + // Hack: duplicate total_count into every item + return data.response?.data?.map((contact) => ({ + ...contact, + total_count: data.response?.pagination?.total_count, + })) ?? [] +} +``` + +## Current Implementation + +### Files Examined +- `/home/user/db/packages/query-db-collection/src/query.ts` (lines 59-200, 520-770) +- `/home/user/db/packages/query-db-collection/tests/query.test.ts` (lines 451-570) +- `/home/user/db/docs/collections/query-collection.md` (lines 296-325) + +### How It Works Today + +1. **The `select` Function** (line 79 in query.ts): + ```typescript + /* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */ + select?: (data: TQueryData) => Array + ``` + +2. **Query Result Handling** (lines 733-746): + ```typescript + const rawData = result.data + const newItemsArray = select ? select(rawData) : rawData + ``` + - The full response (including metadata) IS cached in TanStack Query + - The `select` function extracts just the array portion + - Only the array is stored in the collection + +3. **Test Evidence** (lines 542-570 in query.test.ts): + ```typescript + it(`Whole response is cached in QueryClient when used with select option`, async () => { + // ... + const initialCache = queryClient.getQueryData(queryKey) as MetaDataType + expect(initialCache).toEqual(initialMetaData) // Full response with metadata! + }) + ``` + +### Current Workaround + +Users can access metadata via `queryClient.getQueryData(queryKey)`: + +```typescript +const collection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async () => { + const res = await api.getContacts() + return res // { data: [...], pagination_info: { total_count: 100 } } + }, + select: (data) => data.data, + queryClient, + getKey: (item) => item.id, + }) +) + +// In component: +const cachedResponse = queryClient.getQueryData(['contacts']) +const totalCount = cachedResponse?.pagination_info?.total_count +``` + +**Problems with this approach:** +- Not documented or discoverable +- Not reactive (doesn't trigger re-renders) +- Requires direct queryClient access +- Awkward to use with TanStack Table +- Breaks encapsulation (consumers need to know the queryKey) + +## Potential Solutions + +### Option 1: Add `queryData` to QueryCollectionUtils ⭐ RECOMMENDED + +Expose the full query response through the collection utils: + +```typescript +interface QueryCollectionUtils { + // ... existing properties (refetch, writeInsert, etc.) + + /** The full query response data including metadata */ + queryData: TQueryData | undefined +} +``` + +**Usage:** +```typescript +const collection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async () => { + const res = await api.getContacts() + return { data: res.data, totalCount: res.pagination.total_count } + }, + select: (data) => data.data, + queryClient, + getKey: (item) => item.id, + }) +) + +// In TanStack Table: +const totalCount = collection.utils.queryData?.totalCount +``` + +**Pros:** +- ✅ Clean, discoverable API +- ✅ Reactive (updates when query refetches) +- ✅ Easy to access in components +- ✅ Natural fit with existing utils +- ✅ Non-breaking addition +- ✅ TypeScript provides full type safety + +**Cons:** +- ⚠️ Adds another generic type parameter to QueryCollectionUtils +- ⚠️ May be unclear what `queryData` contains vs collection data + +**Implementation Notes:** +- Store `rawData` from line 733 in query.ts +- Add to state object and expose via utils +- Make reactive using the same pattern as other utils properties + +--- + +### Option 2: Add `selectMeta` Function + +Allow users to extract metadata separately: + +```typescript +interface QueryCollectionConfig<...> { + select?: (data: TQueryData) => Array + selectMeta?: (data: TQueryData) => TMeta +} + +interface QueryCollectionUtils<...> { + meta: TMeta | undefined +} +``` + +**Usage:** +```typescript +const collection = createCollection( + queryCollectionOptions({ + queryFn: async () => api.getContacts(), + select: (data) => data.data, + selectMeta: (data) => ({ totalCount: data.pagination.total_count }), + // ... + }) +) + +const totalCount = collection.utils.meta?.totalCount +``` + +**Pros:** +- ✅ More explicit about what's metadata +- ✅ User controls what to expose +- ✅ Smaller API surface + +**Cons:** +- ⚠️ Requires writing another function +- ⚠️ More configuration needed +- ⚠️ Less flexible (what if you need different metadata in different places?) + +--- + +### Option 3: Document the Workaround + +Simply document that users can access `queryClient.getQueryData(queryKey)`. + +**Pros:** +- ✅ No code changes needed +- ✅ Works today + +**Cons:** +- ❌ Not reactive in components +- ❌ Requires queryClient access +- ❌ Not discoverable +- ❌ Awkward with TanStack Table +- ❌ Doesn't solve the user's problem + +--- + +### Option 4: Change queryFn Return Type (NOT RECOMMENDED) + +Allow queryFn to return `{ data: Array, meta: any }`: + +**Cons:** +- ❌ Breaking change +- ❌ Conflicts with existing `select` pattern +- ❌ Less flexible + +--- + +## Recommendation + +**Implement Option 1: Add `queryData` to QueryCollectionUtils** + +This is the best solution because: +1. It's a non-breaking addition +2. It provides a clean, reactive API +3. It works naturally with the existing `select` pattern +4. It's discoverable through TypeScript autocomplete +5. It solves the user's TanStack Table use case perfectly + +### Implementation Plan + +1. **Update QueryCollectionUtils interface** (query.ts:154-200): + ```typescript + export interface QueryCollectionUtils< + TItem extends object = Record, + TKey extends string | number = string | number, + TInsertInput extends object = TItem, + TError = unknown, + TQueryData = any, // NEW + > extends UtilsRecord { + // ... existing properties + + /** The full query response data including metadata from queryFn */ + queryData: TQueryData | undefined + } + ``` + +2. **Update internal state** (query.ts:~203): + ```typescript + interface QueryCollectionState { + queryObserver: QueryObserver | null + unsubscribe: (() => void) | null + lastError: TError | undefined + errorCount: number + queryData: any | undefined // NEW + } + ``` + +3. **Store queryData in handleQueryResult** (query.ts:733): + ```typescript + const handleQueryResult: UpdateHandler = (result) => { + if (result.isSuccess) { + const rawData = result.data + state.queryData = rawData // NEW + const newItemsArray = select ? select(rawData) : rawData + // ... rest of the logic + } + } + ``` + +4. **Expose via utils** (query.ts:~900+): + ```typescript + queryData: state.queryData, + ``` + +5. **Add tests** (query.test.ts): + - Test that queryData is accessible via utils + - Test that it updates reactively + - Test with and without select + - Test TypeScript types + +6. **Update documentation** (docs/collections/query-collection.md): + - Document the queryData property + - Show example with pagination metadata + - Show TanStack Table integration example + +## Example Usage After Implementation + +```typescript +// API Setup +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async (ctx) => { + const parsed = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) + const res = await client.v1.getApiContacts({ + offset: 0, + limit: parsed.limit, + sort_field: parsed.sorts[0]?.field[0], + sort_direction: parsed.sorts[0]?.direction, + }, { signal: ctx.signal }) + + // Return full response - no more duplication! + return { + data: res.data.response?.data ?? [], + totalCount: res.data.response?.pagination?.total_count ?? 0, + } + }, + select: (response) => response.data, + queryClient, + getKey: (contact) => contact.id, + }) +) + +// TanStack Table Component +function ContactsTable() { + const contacts = useLiveQuery(contactsCollection) + const totalCount = contactsCollection.utils.queryData?.totalCount ?? 0 + + return ( +
+

Total contacts: {totalCount}

+ + + ) +} +``` + +## Breaking Changes + +None - this is a non-breaking addition. + +## Alternative Approaches Considered + +We could also add a subscription mechanism for metadata changes, but that seems over-engineered for this use case. The simpler approach of exposing `queryData` on utils should be sufficient. + +## Questions to Resolve + +1. Should we add `queryData` to the base `UtilsRecord` interface or only to `QueryCollectionUtils`? + - **Answer**: Only to `QueryCollectionUtils`, as it's specific to query collections + +2. Should we rename it to something more specific like `fullQueryResponse` or `rawQueryData`? + - **Answer**: `queryData` matches TanStack Query's naming convention (`queryClient.getQueryData`) + +3. Should we expose the TanStack Query's full `QueryObserverResult` instead? + - **Answer**: No, just the data. Users already have access to `isFetching`, `isLoading`, etc. through existing utils properties + +## Related Issues/PRs + +- PR #551: Added the `select` function to extract arrays from wrapped responses +- This feature request builds on that foundation + +--- + +**Status**: Investigation complete, awaiting decision on implementation approach. From eeff5c17d4ffa8e6e462f1d7b4f58ac3c51da83b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Nov 2025 17:40:34 +0000 Subject: [PATCH 2/5] feat(query-db-collection): add queryData property to expose full query response Implements support for accessing pagination metadata and other API response info from queryCollectionOptions. Resolves Discord feature request from Amir Hoseinian about returning extra pagination info. Changes: - Add queryData property to QueryCollectionUtils interface - Store full query response in internal state when query succeeds - Expose queryData via utils getter for reactive access - Add comprehensive test coverage (4 new tests) - Add detailed documentation with examples The queryData property provides access to the full queryFn response, including metadata that was previously inaccessible after using select() to extract the array portion. This enables clean TanStack Table integration with pagination info and other API metadata. Example usage: ```typescript const collection = createCollection( queryCollectionOptions({ queryFn: async () => ({ data: await api.getContacts(), total: pagination.total_count }), select: (response) => response.data, // ... }) ) const totalCount = collection.utils.queryData?.total ``` Benefits: - Type-safe metadata access - Reactive updates on refetch - No need to duplicate metadata into items - Cleaner API than accessing queryClient directly - Perfect for server-side pagination with TanStack Table Tests: All 145 tests passing Coverage: 86.5% statements, 84.52% branches --- docs/collections/query-collection.md | 184 +++++++++++++++++- packages/query-db-collection/src/query.ts | 27 +++ .../query-db-collection/tests/query.test.ts | 132 +++++++++++++ 3 files changed, 342 insertions(+), 1 deletion(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 4f43082d7..4ead25cb4 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -77,6 +77,166 @@ The `queryCollectionOptions` function accepts the following options: - `onUpdate`: Handler called before update operations - `onDelete`: Handler called before delete operations +## Working with API Responses + +Many APIs return data wrapped with metadata like pagination info, total counts, or other contextual information. Query Collection provides powerful tools to handle these scenarios. + +### The `select` Option + +When your API returns wrapped responses (data with metadata), use the `select` function to extract the array: + +```typescript +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async () => { + const response = await fetch('/api/contacts') + // API returns: { data: Contact[], pagination: { total: number } } + return response.json() + }, + select: (response) => response.data, // Extract the array + queryClient, + getKey: (contact) => contact.id, + }) +) +``` + +### Accessing Metadata with `queryData` + +While `select` extracts the array for the collection, you often need access to the metadata (like pagination info). Use `collection.utils.queryData` to access the full response: + +```typescript +// The full API response is available via utils.queryData +const totalContacts = contactsCollection.utils.queryData?.pagination?.total +const currentPage = contactsCollection.utils.queryData?.pagination?.page + +// Use in your components +function ContactsTable() { + const contacts = useLiveQuery(contactsCollection) + const totalCount = contactsCollection.utils.queryData?.pagination?.total ?? 0 + + return ( +
+

Showing {contacts.length} of {totalCount} contacts

+
+ + ) +} +``` + +### Type-Safe Metadata Access + +TypeScript automatically infers the type of `queryData` from your `queryFn` return type: + +```typescript +interface ContactsResponse { + data: Contact[] + pagination: { + total: number + page: number + perPage: number + } + metadata: { + lastSync: string + } +} + +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async (): Promise => { + const response = await fetch('/api/contacts') + return response.json() + }, + select: (response) => response.data, + queryClient, + getKey: (contact) => contact.id, + }) +) + +// TypeScript knows the structure of queryData +const total = contactsCollection.utils.queryData?.pagination.total // ✅ Type-safe +const lastSync = contactsCollection.utils.queryData?.metadata.lastSync // ✅ Type-safe +``` + +### Real-World Example: TanStack Table with Pagination + +A common use case is integrating with TanStack Table for server-side pagination: + +```typescript +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async (ctx) => { + const { limit, offset, sorts } = parseLoadSubsetOptions( + ctx.meta?.loadSubsetOptions + ) + + const response = await fetch('/api/contacts', { + method: 'POST', + body: JSON.stringify({ limit, offset, sorts }), + }) + + return response.json() // { data: Contact[], total: number } + }, + select: (response) => response.data, + queryClient, + getKey: (contact) => contact.id, + }) +) + +function ContactsTable() { + const contacts = useLiveQuery(contactsCollection) + const totalRowCount = contactsCollection.utils.queryData?.total ?? 0 + + // Use with TanStack Table + const table = useReactTable({ + data: contacts, + columns, + rowCount: totalRowCount, + // ... other options + }) + + return +} +``` + +### Without `select`: Direct Array Returns + +If your API returns a plain array, you don't need `select`. In this case, `queryData` will contain the array itself: + +```typescript +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => { + const response = await fetch('/api/todos') + return response.json() // Returns Todo[] directly + }, + queryClient, + getKey: (todo) => todo.id, + }) +) + +// queryData is the array +const todos = todosCollection.utils.queryData // Todo[] | undefined +``` + +### Reactive Updates + +The `queryData` property is reactive and updates automatically when: +- The query refetches +- Data is invalidated and refetched +- Manual refetch is triggered + +```typescript +// Trigger a refetch +await contactsCollection.utils.refetch() + +// queryData is automatically updated with new response +const newTotal = contactsCollection.utils.queryData?.pagination?.total +``` + ## Persistence Handlers You can define handlers that are called when mutations occur. These handlers can persist changes to your backend and control whether the query should refetch after the operation: @@ -135,13 +295,35 @@ This is useful when: ## Utility Methods -The collection provides these utility methods via `collection.utils`: +The collection provides utilities via `collection.utils` for managing the collection and accessing query state: + +### Methods - `refetch(opts?)`: Manually trigger a refetch of the query - `opts.throwOnError`: Whether to throw an error if the refetch fails (default: `false`) - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) - Returns `QueryObserverResult` for inspecting the result +- `clearError()`: Clear the error state and trigger a refetch + +- `writeInsert(data)`: Insert items directly to synced data (see [Direct Writes](#direct-writes)) +- `writeUpdate(data)`: Update items directly in synced data +- `writeDelete(keys)`: Delete items directly from synced data +- `writeUpsert(data)`: Insert or update items directly in synced data +- `writeBatch(callback)`: Perform multiple write operations atomically + +### Properties + +- `queryData`: The full response from `queryFn`, including metadata (see [Working with API Responses](#working-with-api-responses)) +- `lastError`: The last error encountered (if any) +- `isError`: Whether the collection is in an error state +- `errorCount`: Number of consecutive sync failures +- `isFetching`: Whether the query is currently fetching +- `isRefetching`: Whether the query is refetching in the background +- `isLoading`: Whether the query is loading for the first time +- `dataUpdatedAt`: Timestamp of the last successful data update +- `fetchStatus`: Current fetch status (`'fetching'`, `'paused'`, or `'idle'`) + ## Direct Writes Direct writes are intended for scenarios where the normal query/mutation flow doesn't fit your needs. They allow you to write directly to the synced data store, bypassing the optimistic update system and query refetch mechanism. diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index ee915aabf..7312182b0 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -156,6 +156,7 @@ export interface QueryCollectionUtils< TKey extends string | number = string | number, TInsertInput extends object = TItem, TError = unknown, + TQueryData = any, > extends UtilsRecord { /** Manually trigger a refetch of the query */ refetch: RefetchFn @@ -191,6 +192,25 @@ export interface QueryCollectionUtils< /** Get current fetch status */ fetchStatus: `fetching` | `paused` | `idle` + /** + * The full query response data from queryFn, including any metadata. + * When using the select option, this contains the raw response before extraction. + * Useful for accessing pagination info, total counts, or other API metadata. + * + * @example + * // Without select - queryData is the array + * queryFn: async () => fetchContacts(), // returns Contact[] + * // queryData will be Contact[] + * + * @example + * // With select - queryData is the full response + * queryFn: async () => fetchContacts(), // returns { data: Contact[], total: number } + * select: (response) => response.data, + * // queryData will be { data: Contact[], total: number } + * const total = collection.utils.queryData?.total + */ + queryData: TQueryData | undefined + /** * Clear the error state and trigger a refetch of the query * @returns Promise that resolves when the refetch completes successfully @@ -206,6 +226,7 @@ interface QueryCollectionState { lastError: any errorCount: number lastErrorUpdatedAt: number + queryData: any observers: Map< string, QueryObserver, any, Array, Array, any> @@ -302,6 +323,10 @@ class QueryCollectionUtilsImpl { (observer) => observer.getCurrentResult().fetchStatus ) } + + public get queryData() { + return this.state.queryData + } } /** @@ -574,6 +599,7 @@ export function queryCollectionOptions( lastError: undefined as any, errorCount: 0, lastErrorUpdatedAt: 0, + queryData: undefined as any, observers: new Map< string, QueryObserver, any, Array, Array, any> @@ -731,6 +757,7 @@ export function queryCollectionOptions( state.errorCount = 0 const rawData = result.data + state.queryData = rawData const newItemsArray = select ? select(rawData) : rawData if ( diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 2f218d73a..e6bcbe7c2 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -568,6 +568,138 @@ describe(`QueryCollection`, () => { ) as MetaDataType expect(initialCache).toEqual(initialMetaData) }) + + it(`queryData is accessible via utils when using select`, async () => { + const queryKey = [`queryData-select-test`] + + const queryFn = vi.fn().mockResolvedValue(initialMetaData) + const select = vi.fn().mockReturnValue(initialMetaData.data) + + const options = queryCollectionOptions({ + id: `test`, + queryClient, + queryKey, + queryFn, + select, + getKey, + startSync: true, + }) + const collection = createCollection(options) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(select).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(2) + }) + + // Verify that queryData contains the full response + expect(collection.utils.queryData).toEqual(initialMetaData) + expect(collection.utils.queryData?.metaDataOne).toBe(`example metadata`) + expect(collection.utils.queryData?.metaDataTwo).toBe(`example metadata`) + }) + + it(`queryData contains array when not using select`, async () => { + const queryKey = [`queryData-no-select-test`] + const items = [ + { id: `1`, name: `Item 1` }, + { id: `2`, name: `Item 2` }, + ] + + const queryFn = vi.fn().mockResolvedValue(items) + + const options = queryCollectionOptions({ + id: `test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + }) + const collection = createCollection(options) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(2) + }) + + // Verify that queryData contains the array directly + expect(collection.utils.queryData).toEqual(items) + }) + + it(`queryData updates reactively when query refetches`, async () => { + const queryKey = [`queryData-refetch-test`] + + const initialData: MetaDataType = { + metaDataOne: `initial`, + metaDataTwo: `metadata`, + data: [{ id: `1`, name: `Initial Item` }], + } + + const updatedData: MetaDataType = { + metaDataOne: `updated`, + metaDataTwo: `metadata`, + data: [ + { id: `1`, name: `Updated Item` }, + { id: `2`, name: `New Item` }, + ], + } + + const queryFn = vi + .fn() + .mockResolvedValueOnce(initialData) + .mockResolvedValueOnce(updatedData) + const select = vi.fn((data: MetaDataType) => data.data) + + const options = queryCollectionOptions({ + id: `test`, + queryClient, + queryKey, + queryFn, + select, + getKey, + startSync: true, + }) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + // Verify initial queryData + expect(collection.utils.queryData).toEqual(initialData) + expect(collection.utils.queryData?.metaDataOne).toBe(`initial`) + + // Trigger refetch + await collection.utils.refetch() + + // Wait for updated data + await vi.waitFor(() => { + expect(collection.size).toBe(2) + }) + + // Verify queryData has been updated + expect(collection.utils.queryData).toEqual(updatedData) + expect(collection.utils.queryData?.metaDataOne).toBe(`updated`) + }) + + it(`queryData is undefined initially before first fetch`, () => { + const queryKey = [`queryData-initial-test`] + const queryFn = vi.fn().mockResolvedValue(initialMetaData.data) + + const options = queryCollectionOptions({ + id: `test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: false, // Don't start sync automatically + }) + const collection = createCollection(options) + + // Before sync starts, queryData should be undefined + expect(collection.utils.queryData).toBeUndefined() + }) }) describe(`Direct persistence handlers`, () => { it(`should pass through direct persistence handlers to collection options`, () => { From 197d435572aaa750cae3886380641c1466b57a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Nov 2025 17:52:29 +0000 Subject: [PATCH 3/5] chore: add changeset for queryData property feature --- .changeset/add-querydata-property.md | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .changeset/add-querydata-property.md diff --git a/.changeset/add-querydata-property.md b/.changeset/add-querydata-property.md new file mode 100644 index 000000000..7d022b3cc --- /dev/null +++ b/.changeset/add-querydata-property.md @@ -0,0 +1,52 @@ +--- +"@tanstack/query-db-collection": minor +--- + +Add `queryData` property to `collection.utils` for accessing full query response including metadata. This resolves the common use case of needing pagination info (total counts, page numbers, etc.) alongside the data array when using the `select` option. + +Previously, when using `select` to extract an array from a wrapped API response, metadata was only accessible via `queryClient.getQueryData()` which was not reactive and required exposing the queryClient. Users resorted to duplicating metadata into every item as a workaround. + +**Example:** + +```ts +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async () => { + const response = await api.getContacts() + // API returns: { data: Contact[], pagination: { total: number } } + return response.json() + }, + select: (response) => response.data, // Extract array for collection + queryClient, + getKey: (contact) => contact.id, + }) +) + +// Access the full response including metadata +const totalCount = contactsCollection.utils.queryData?.pagination?.total + +// Perfect for TanStack Table pagination +function ContactsTable() { + const contacts = useLiveQuery(contactsCollection) + const totalRowCount = contactsCollection.utils.queryData?.total ?? 0 + + const table = useReactTable({ + data: contacts, + columns, + rowCount: totalRowCount, + }) + + return +} +``` + +**Benefits:** + +- Type-safe metadata access (TypeScript infers type from `queryFn` return) +- Reactive updates when query refetches +- Works seamlessly with existing `select` function +- No need to duplicate metadata into items +- Cleaner API than accessing `queryClient` directly + +The property is `undefined` before the first successful fetch and updates automatically on refetches. From 3587737edce8062e0858f8cf597e88bf1e4ee232 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Nov 2025 17:57:34 +0000 Subject: [PATCH 4/5] chore: change changeset to patch (pre-1.0) --- .changeset/add-querydata-property.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/add-querydata-property.md b/.changeset/add-querydata-property.md index 7d022b3cc..819cc4f18 100644 --- a/.changeset/add-querydata-property.md +++ b/.changeset/add-querydata-property.md @@ -1,5 +1,5 @@ --- -"@tanstack/query-db-collection": minor +"@tanstack/query-db-collection": patch --- Add `queryData` property to `collection.utils` for accessing full query response including metadata. This resolves the common use case of needing pagination info (total counts, page numbers, etc.) alongside the data array when using the `select` option. From fb31e81f44215fef77a3b3722c52a76f792fb355 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Nov 2025 18:00:48 +0000 Subject: [PATCH 5/5] chore: remove investigation document --- PAGINATION_METADATA_INVESTIGATION.md | 349 --------------------------- 1 file changed, 349 deletions(-) delete mode 100644 PAGINATION_METADATA_INVESTIGATION.md diff --git a/PAGINATION_METADATA_INVESTIGATION.md b/PAGINATION_METADATA_INVESTIGATION.md deleted file mode 100644 index e6914b61a..000000000 --- a/PAGINATION_METADATA_INVESTIGATION.md +++ /dev/null @@ -1,349 +0,0 @@ -# Investigation: Supporting Pagination Metadata in queryCollectionOptions - -## Issue Summary - -A Discord user (Amir Hoseinian) requested a way to return extra pagination info alongside the data array from `queryFn` in TanStack Table integration. Their API returns: - -```typescript -{ - data: object[] - pagination_info: { total_count: number } -} -``` - -Currently, `queryFn` in `queryCollectionOptions` only allows returning an array of objects, forcing users to duplicate metadata into every item: - -```typescript -queryFn: async (ctx) => { - const { data } = await client.v1.getApiContacts(...) - - // Hack: duplicate total_count into every item - return data.response?.data?.map((contact) => ({ - ...contact, - total_count: data.response?.pagination?.total_count, - })) ?? [] -} -``` - -## Current Implementation - -### Files Examined -- `/home/user/db/packages/query-db-collection/src/query.ts` (lines 59-200, 520-770) -- `/home/user/db/packages/query-db-collection/tests/query.test.ts` (lines 451-570) -- `/home/user/db/docs/collections/query-collection.md` (lines 296-325) - -### How It Works Today - -1. **The `select` Function** (line 79 in query.ts): - ```typescript - /* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */ - select?: (data: TQueryData) => Array - ``` - -2. **Query Result Handling** (lines 733-746): - ```typescript - const rawData = result.data - const newItemsArray = select ? select(rawData) : rawData - ``` - - The full response (including metadata) IS cached in TanStack Query - - The `select` function extracts just the array portion - - Only the array is stored in the collection - -3. **Test Evidence** (lines 542-570 in query.test.ts): - ```typescript - it(`Whole response is cached in QueryClient when used with select option`, async () => { - // ... - const initialCache = queryClient.getQueryData(queryKey) as MetaDataType - expect(initialCache).toEqual(initialMetaData) // Full response with metadata! - }) - ``` - -### Current Workaround - -Users can access metadata via `queryClient.getQueryData(queryKey)`: - -```typescript -const collection = createCollection( - queryCollectionOptions({ - queryKey: ['contacts'], - queryFn: async () => { - const res = await api.getContacts() - return res // { data: [...], pagination_info: { total_count: 100 } } - }, - select: (data) => data.data, - queryClient, - getKey: (item) => item.id, - }) -) - -// In component: -const cachedResponse = queryClient.getQueryData(['contacts']) -const totalCount = cachedResponse?.pagination_info?.total_count -``` - -**Problems with this approach:** -- Not documented or discoverable -- Not reactive (doesn't trigger re-renders) -- Requires direct queryClient access -- Awkward to use with TanStack Table -- Breaks encapsulation (consumers need to know the queryKey) - -## Potential Solutions - -### Option 1: Add `queryData` to QueryCollectionUtils ⭐ RECOMMENDED - -Expose the full query response through the collection utils: - -```typescript -interface QueryCollectionUtils { - // ... existing properties (refetch, writeInsert, etc.) - - /** The full query response data including metadata */ - queryData: TQueryData | undefined -} -``` - -**Usage:** -```typescript -const collection = createCollection( - queryCollectionOptions({ - queryKey: ['contacts'], - queryFn: async () => { - const res = await api.getContacts() - return { data: res.data, totalCount: res.pagination.total_count } - }, - select: (data) => data.data, - queryClient, - getKey: (item) => item.id, - }) -) - -// In TanStack Table: -const totalCount = collection.utils.queryData?.totalCount -``` - -**Pros:** -- ✅ Clean, discoverable API -- ✅ Reactive (updates when query refetches) -- ✅ Easy to access in components -- ✅ Natural fit with existing utils -- ✅ Non-breaking addition -- ✅ TypeScript provides full type safety - -**Cons:** -- ⚠️ Adds another generic type parameter to QueryCollectionUtils -- ⚠️ May be unclear what `queryData` contains vs collection data - -**Implementation Notes:** -- Store `rawData` from line 733 in query.ts -- Add to state object and expose via utils -- Make reactive using the same pattern as other utils properties - ---- - -### Option 2: Add `selectMeta` Function - -Allow users to extract metadata separately: - -```typescript -interface QueryCollectionConfig<...> { - select?: (data: TQueryData) => Array - selectMeta?: (data: TQueryData) => TMeta -} - -interface QueryCollectionUtils<...> { - meta: TMeta | undefined -} -``` - -**Usage:** -```typescript -const collection = createCollection( - queryCollectionOptions({ - queryFn: async () => api.getContacts(), - select: (data) => data.data, - selectMeta: (data) => ({ totalCount: data.pagination.total_count }), - // ... - }) -) - -const totalCount = collection.utils.meta?.totalCount -``` - -**Pros:** -- ✅ More explicit about what's metadata -- ✅ User controls what to expose -- ✅ Smaller API surface - -**Cons:** -- ⚠️ Requires writing another function -- ⚠️ More configuration needed -- ⚠️ Less flexible (what if you need different metadata in different places?) - ---- - -### Option 3: Document the Workaround - -Simply document that users can access `queryClient.getQueryData(queryKey)`. - -**Pros:** -- ✅ No code changes needed -- ✅ Works today - -**Cons:** -- ❌ Not reactive in components -- ❌ Requires queryClient access -- ❌ Not discoverable -- ❌ Awkward with TanStack Table -- ❌ Doesn't solve the user's problem - ---- - -### Option 4: Change queryFn Return Type (NOT RECOMMENDED) - -Allow queryFn to return `{ data: Array, meta: any }`: - -**Cons:** -- ❌ Breaking change -- ❌ Conflicts with existing `select` pattern -- ❌ Less flexible - ---- - -## Recommendation - -**Implement Option 1: Add `queryData` to QueryCollectionUtils** - -This is the best solution because: -1. It's a non-breaking addition -2. It provides a clean, reactive API -3. It works naturally with the existing `select` pattern -4. It's discoverable through TypeScript autocomplete -5. It solves the user's TanStack Table use case perfectly - -### Implementation Plan - -1. **Update QueryCollectionUtils interface** (query.ts:154-200): - ```typescript - export interface QueryCollectionUtils< - TItem extends object = Record, - TKey extends string | number = string | number, - TInsertInput extends object = TItem, - TError = unknown, - TQueryData = any, // NEW - > extends UtilsRecord { - // ... existing properties - - /** The full query response data including metadata from queryFn */ - queryData: TQueryData | undefined - } - ``` - -2. **Update internal state** (query.ts:~203): - ```typescript - interface QueryCollectionState { - queryObserver: QueryObserver | null - unsubscribe: (() => void) | null - lastError: TError | undefined - errorCount: number - queryData: any | undefined // NEW - } - ``` - -3. **Store queryData in handleQueryResult** (query.ts:733): - ```typescript - const handleQueryResult: UpdateHandler = (result) => { - if (result.isSuccess) { - const rawData = result.data - state.queryData = rawData // NEW - const newItemsArray = select ? select(rawData) : rawData - // ... rest of the logic - } - } - ``` - -4. **Expose via utils** (query.ts:~900+): - ```typescript - queryData: state.queryData, - ``` - -5. **Add tests** (query.test.ts): - - Test that queryData is accessible via utils - - Test that it updates reactively - - Test with and without select - - Test TypeScript types - -6. **Update documentation** (docs/collections/query-collection.md): - - Document the queryData property - - Show example with pagination metadata - - Show TanStack Table integration example - -## Example Usage After Implementation - -```typescript -// API Setup -const contactsCollection = createCollection( - queryCollectionOptions({ - queryKey: ['contacts'], - queryFn: async (ctx) => { - const parsed = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) - const res = await client.v1.getApiContacts({ - offset: 0, - limit: parsed.limit, - sort_field: parsed.sorts[0]?.field[0], - sort_direction: parsed.sorts[0]?.direction, - }, { signal: ctx.signal }) - - // Return full response - no more duplication! - return { - data: res.data.response?.data ?? [], - totalCount: res.data.response?.pagination?.total_count ?? 0, - } - }, - select: (response) => response.data, - queryClient, - getKey: (contact) => contact.id, - }) -) - -// TanStack Table Component -function ContactsTable() { - const contacts = useLiveQuery(contactsCollection) - const totalCount = contactsCollection.utils.queryData?.totalCount ?? 0 - - return ( -
-

Total contacts: {totalCount}

-
- - ) -} -``` - -## Breaking Changes - -None - this is a non-breaking addition. - -## Alternative Approaches Considered - -We could also add a subscription mechanism for metadata changes, but that seems over-engineered for this use case. The simpler approach of exposing `queryData` on utils should be sufficient. - -## Questions to Resolve - -1. Should we add `queryData` to the base `UtilsRecord` interface or only to `QueryCollectionUtils`? - - **Answer**: Only to `QueryCollectionUtils`, as it's specific to query collections - -2. Should we rename it to something more specific like `fullQueryResponse` or `rawQueryData`? - - **Answer**: `queryData` matches TanStack Query's naming convention (`queryClient.getQueryData`) - -3. Should we expose the TanStack Query's full `QueryObserverResult` instead? - - **Answer**: No, just the data. Users already have access to `isFetching`, `isLoading`, etc. through existing utils properties - -## Related Issues/PRs - -- PR #551: Added the `select` function to extract arrays from wrapped responses -- This feature request builds on that foundation - ---- - -**Status**: Investigation complete, awaiting decision on implementation approach.