From 5bedf8b4b16690f646df774c162ea042a9da23ad Mon Sep 17 00:00:00 2001 From: 0xluckycoder Date: Sat, 13 Sep 2025 23:35:47 +0530 Subject: [PATCH 1/9] feat: Add select option to extract items while preserving metadata --- packages/query-db-collection/src/query.ts | 37 +++++++--- .../query-db-collection/tests/query.test-d.ts | 71 +++++++++++++++++++ 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 73e71b4f4..dda366b66 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -56,6 +56,13 @@ type ResolveType< : InferSchemaOutput : TExplicit +// Select return type inference helper +type QueryFnResolveType = TQueryFn extends ( + ...args: any +) => Promise + ? R + : never + /** * Configuration options for creating a Query Collection * @template TExplicit - The explicit type of items stored in the collection (highest priority) @@ -67,24 +74,32 @@ type ResolveType< export interface QueryCollectionConfig< TExplicit extends object = object, TSchema extends StandardSchemaV1 = never, - TQueryFn extends ( - context: QueryFunctionContext - ) => Promise> = ( + TQueryFn extends (context: QueryFunctionContext) => Promise = ( context: QueryFunctionContext - ) => Promise>, + ) => Promise, TError = unknown, TQueryKey extends QueryKey = QueryKey, > { /** The query key used by TanStack Query to identify this query */ queryKey: TQueryKey /** Function that fetches data from the server. Must return the complete collection state */ + // Declare as a new Type (REFACTER!!!) queryFn: TQueryFn extends ( context: QueryFunctionContext ) => Promise> - ? TQueryFn - : ( + ? TQueryFn extends ( context: QueryFunctionContext ) => Promise>> + ? TQueryFn + : ( + context: QueryFunctionContext + ) => Promise>> + : TQueryFn + + // USE UTILITES!!! select?: (data: Awaited>) => Array> + select?: ( + data: QueryFnResolveType + ) => Array> /** The TanStack Query client instance */ queryClient: QueryClient @@ -426,11 +441,9 @@ export interface QueryCollectionUtils< export function queryCollectionOptions< TExplicit extends object = object, TSchema extends StandardSchemaV1 = never, - TQueryFn extends ( - context: QueryFunctionContext - ) => Promise> = ( + TQueryFn extends (context: QueryFunctionContext) => Promise = ( context: QueryFunctionContext - ) => Promise>, + ) => Promise, TError = unknown, TQueryKey extends QueryKey = QueryKey, TKey extends string | number = string | number, @@ -450,6 +463,7 @@ export function queryCollectionOptions< const { queryKey, queryFn, + select, queryClient, enabled, refetchInterval, @@ -529,7 +543,8 @@ export function queryCollectionOptions< lastError = undefined errorCount = 0 - const newItemsArray = result.data + const rawData = result.data as QueryFnResolveType + const newItemsArray = select ? select(rawData) : rawData if ( !Array.isArray(newItemsArray) || diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 5d639c28d..d741fd48b 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -274,4 +274,75 @@ describe(`Query collection type resolution tests`, () => { expectTypeOf(collection.toArray).toEqualTypeOf>() }) }) + + describe(`select type inference`, () => { + it(`queryFn type inference`, () => { + const dataSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + + const options = queryCollectionOptions({ + queryClient, + queryKey: [`x-queryFn-infer`], + queryFn: async (): Promise>> => { + return [] as Array> + }, + schema: dataSchema, + getKey: (item) => item.id, + }) + + type ExpectedType = z.infer + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExpectedType]>() + }) + + it(`select properly extracts array from wrapped response`, () => { + const userData = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + + type UserDataType = z.infer + + type MetaDataType = { + metaDataOne: string + metaDataTwo: string + data: T + } + + const metaDataObject: ResponseType = { + metaDataOne: `example meta data`, + metaDataTwo: `example meta data`, + data: [ + { + id: `1`, + name: `carter`, + email: `c@email.com`, + }, + ], + } + + type ResponseType = MetaDataType> + + const selectUserData = (data: ResponseType) => { + return data.data + } + + queryCollectionOptions({ + queryClient, + queryKey: [`x-queryFn-infer`], + queryFn: async (): Promise => { + return metaDataObject + }, + select: selectUserData, + schema: userData, + getKey: (item) => item.id, + }) + + // Should infer ResponseType as select parameter type + expectTypeOf(selectUserData).parameters.toEqualTypeOf<[ResponseType]>() + }) + }) }) From f44381cd76766ab182d9f0e95b6450b5ac1658d2 Mon Sep 17 00:00:00 2001 From: 0xluckycoder Date: Mon, 15 Sep 2025 23:13:05 +0530 Subject: [PATCH 2/9] test: add tests --- .../query-db-collection/tests/query.test.ts | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 47a14a706..60be5f70f 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -426,6 +426,99 @@ describe(`QueryCollection`, () => { expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ meta })) }) + describe(`Select method testing`, () => { + type MetaDataType = { + metaDataOne: string + metaDataTwo: string + data: Array + } + + const initialMetaData: MetaDataType = { + metaDataOne: `example metadata`, + metaDataTwo: `example metadata`, + data: [ + { + id: `1`, + name: `First Item`, + }, + { + id: `2`, + name: `Second Item`, + }, + ], + } + + it(`select extracts array from metadata`, async () => { + const queryKey = [`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).toBeGreaterThan(0) + }) + + expect(collection.size).toBe(initialMetaData.data.length) + expect(collection.get(`1`)).toEqual(initialMetaData.data[0]) + expect(collection.get(`2`)).toEqual(initialMetaData.data[1]) + }) + + it(`throws error if select returns non array`, async () => { + const queryKey = [`select-test`] + const consoleErrorSpy = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + + const queryFn = vi.fn().mockResolvedValue(initialMetaData) + // Returns non-array + const select = vi.fn().mockReturnValue(initialMetaData) + + 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) + }) + + // Verify the validation error was logged + await vi.waitFor(() => { + const errorCallArgs = consoleErrorSpy.mock.calls.find((call) => + call[0].includes( + `[QueryCollection] queryFn did not return an array of objects. Skipping update.` + ) + ) + expect(errorCallArgs).toBeDefined() + }) + + expect(collection.size).toBe(0) + + // Clean up the spy + consoleErrorSpy.mockRestore() + }) + }) + describe(`Direct persistence handlers`, () => { it(`should pass through direct persistence handlers to collection options`, () => { const queryKey = [`directPersistenceTest`] @@ -1243,7 +1336,7 @@ describe(`QueryCollection`, () => { }) }).toThrow(/does not exist/) }) - + // note! it(`should update query cache when using sync methods`, async () => { const queryKey = [`sync-cache-test`] const initialItems: Array = [ From cd5feb2597ab2834757a917f108212f8a0f0b394 Mon Sep 17 00:00:00 2001 From: 0xluckycoder Date: Wed, 17 Sep 2025 20:31:32 +0530 Subject: [PATCH 3/9] test: add more tests --- .../query-db-collection/tests/query.test.ts | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index d7b37f3e7..0d3de80a3 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -457,7 +457,7 @@ describe(`QueryCollection`, () => { ], } - it(`select extracts array from metadata`, async () => { + it(`Select extracts array from metadata`, async () => { const queryKey = [`select-test`] const queryFn = vi.fn().mockResolvedValue(initialMetaData) @@ -485,7 +485,7 @@ describe(`QueryCollection`, () => { expect(collection.get(`2`)).toEqual(initialMetaData.data[1]) }) - it(`throws error if select returns non array`, async () => { + it(`Throws error if select returns non array`, async () => { const queryKey = [`select-test`] const consoleErrorSpy = vi .spyOn(console, `error`) @@ -526,8 +526,37 @@ describe(`QueryCollection`, () => { // Clean up the spy consoleErrorSpy.mockRestore() }) - }) + it(`Whole response is cached in QueryClient when used with select option`, async () => { + const queryKey = [`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 the query cache state exists along with its metadata + const initialCache = queryClient.getQueryData( + queryKey + ) as MetaDataType + expect(initialCache).toEqual(initialMetaData) + }) + }) describe(`Direct persistence handlers`, () => { it(`should pass through direct persistence handlers to collection options`, () => { const queryKey = [`directPersistenceTest`] @@ -1345,7 +1374,7 @@ describe(`QueryCollection`, () => { }) }).toThrow(/does not exist/) }) - // note! + it(`should update query cache when using sync methods`, async () => { const queryKey = [`sync-cache-test`] const initialItems: Array = [ @@ -1398,7 +1427,7 @@ describe(`QueryCollection`, () => { const updatedItem = cacheAfterUpdate.find((item) => item.id === `1`) expect(updatedItem?.name).toBe(`Updated Item 1`) expect(updatedItem?.value).toBe(10) // Original value preserved - + // console.log(cacheAfterUpdate) // Test writeDelete updates cache collection.utils.writeDelete(`2`) From b4915745b06913c6cc1c36b4d1c67434b440114b Mon Sep 17 00:00:00 2001 From: 0xluckycoder Date: Wed, 24 Sep 2025 19:09:28 +0530 Subject: [PATCH 4/9] throw type mismatch error schema level --- packages/query-db-collection/tests/query.test-d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 7d03c01a2..8081a92df 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -262,10 +262,10 @@ describe(`Query collection type resolution tests`, () => { const options = queryCollectionOptions({ queryClient, queryKey: [`schema-priority`], - // @ts-expect-error – queryFn doesn't match the schema type queryFn: async () => { return [] as Array }, + // @ts-expect-error – queryFn doesn't match the schema type schema: userSchema, getKey: (item) => item.id, }) From 2bec250b4a47f64db5b2587c18c277af0ab2d53f Mon Sep 17 00:00:00 2001 From: 0xluckycoder Date: Sat, 27 Sep 2025 23:32:02 +0530 Subject: [PATCH 5/9] chore: remove a trailing comment --- packages/query-db-collection/tests/query.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 928035124..f3dd013c8 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1427,7 +1427,6 @@ describe(`QueryCollection`, () => { const updatedItem = cacheAfterUpdate.find((item) => item.id === `1`) expect(updatedItem?.name).toBe(`Updated Item 1`) expect(updatedItem?.value).toBe(10) // Original value preserved - // console.log(cacheAfterUpdate) // Test writeDelete updates cache collection.utils.writeDelete(`2`) From 072129b89083fa9b91ad96215e57cf43b196fa65 Mon Sep 17 00:00:00 2001 From: 0xluckycoder Date: Sat, 27 Sep 2025 23:34:46 +0530 Subject: [PATCH 6/9] chore: add implementation examples --- packages/query-db-collection/src/query.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 6aa22256f..a4aa38548 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -69,7 +69,7 @@ export interface QueryCollectionConfig< ) => Promise> ? (context: QueryFunctionContext) => Promise> : TQueryFn - + /* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */ select?: (data: TQueryData) => Array /** The TanStack Query client instance */ queryClient: QueryClient @@ -248,6 +248,19 @@ export interface QueryCollectionUtils< * } * }) * ) + * + * @example + * // The select option extracts the items array from a response with metadata + * const todosCollection = createCollection( + * queryCollectionOptions({ + * queryKey: ['todos'], + * queryFn: async () => fetch('/api/todos').then(r => r.json()), + * select: (data) => data.items, // Extract the array of items + * queryClient, + * schema: todoSchema, + * getKey: (item) => item.id, + * }) + * ) */ // Overload for when schema is provided and select present export function queryCollectionOptions< From 0e53989a9a43a30d06532f5c7c38891066da5a74 Mon Sep 17 00:00:00 2001 From: 0xluckycoder Date: Sun, 28 Sep 2025 02:00:29 +0530 Subject: [PATCH 7/9] doc: add select to options --- docs/collections/query-collection.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index ae826b46d..6f641dbf3 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -55,6 +55,7 @@ The `queryCollectionOptions` function accepts the following options: ### Query Options +- `select`: Function that lets extract array items when they’re wrapped with metadata - `enabled`: Whether the query should automatically run (default: `true`) - `refetchInterval`: Refetch interval in milliseconds - `retry`: Retry configuration for failed queries From af8af1108964f38e9eae1bc3843edd71e7ea74f8 Mon Sep 17 00:00:00 2001 From: 0xluckycoder Date: Sun, 28 Sep 2025 02:19:57 +0530 Subject: [PATCH 8/9] add changeset --- .changeset/yellow-pears-rush.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/yellow-pears-rush.md diff --git a/.changeset/yellow-pears-rush.md b/.changeset/yellow-pears-rush.md new file mode 100644 index 000000000..e163738bf --- /dev/null +++ b/.changeset/yellow-pears-rush.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-db-collection": patch +--- + +query-collection now supports a `select` function to transform raw query results into an array of items. This is useful for APIs that return data with metadata or nested structures, ensuring metadata remains cached while collections work with the unwrapped array. From 2dc9baae445885ec7f54de2bb4d74a2fd4157d8b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 29 Sep 2025 19:05:46 -0600 Subject: [PATCH 9/9] Improve select validation with better error messages and type tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced error messages to include package name, error type, received value type, and queryKey for easier debugging - Added negative type test to verify TypeScript catches wrapped response data without select function - Updated existing runtime tests to handle improved error messaging - Fixed eslint warnings for unnecessary conditions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/query-db-collection/src/query.ts | 10 +++--- .../query-db-collection/tests/query.test-d.ts | 31 +++++++++++++++++++ .../query-db-collection/tests/query.test.ts | 5 ++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index a4aa38548..b4ffa5578 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -409,6 +409,7 @@ export function queryCollectionOptions( throw new QueryClientRequiredError() } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!getKey) { throw new GetKeyRequiredError() } @@ -464,10 +465,11 @@ export function queryCollectionOptions( !Array.isArray(newItemsArray) || newItemsArray.some((item) => typeof item !== `object`) ) { - console.error( - `[QueryCollection] queryFn did not return an array of objects. Skipping update.`, - newItemsArray - ) + const errorMessage = select + ? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}` + : `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}` + + console.error(errorMessage) return } diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 8081a92df..dcc2bf32d 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -323,6 +323,37 @@ describe(`Query collection type resolution tests`, () => { expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExpectedType]>() }) + it(`should error when queryFn returns wrapped data without select`, () => { + const userData = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + + type UserDataType = z.infer + + type WrappedResponse = { + metadata: string + data: Array + } + + queryCollectionOptions({ + queryClient, + queryKey: [`wrapped-no-select`], + // @ts-expect-error - queryFn returns wrapped data but no select provided + queryFn: (): Promise => { + return Promise.resolve({ + metadata: `example`, + data: [], + }) + }, + // @ts-expect-error - schema type conflicts with queryFn return type + schema: userData, + // @ts-expect-error - item type is inferred as object due to type mismatch + getKey: (item) => item.id, + }) + }) + it(`select properly extracts array from wrapped response`, () => { const userData = z.object({ id: z.string(), diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index f3dd013c8..b2ad4433d 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -257,14 +257,13 @@ describe(`QueryCollection`, () => { await vi.waitFor(() => { const errorCallArgs = consoleErrorSpy.mock.calls.find((call) => call[0].includes( - `[QueryCollection] queryFn did not return an array of objects` + `@tanstack/query-db-collection: queryFn must return an array of objects` ) ) expect(errorCallArgs).toBeDefined() }) // The collection state should remain empty or unchanged - // Since we're not setting any initial data, we expect the state to be empty expect(collection.size).toBe(0) // Clean up the spy @@ -515,7 +514,7 @@ describe(`QueryCollection`, () => { await vi.waitFor(() => { const errorCallArgs = consoleErrorSpy.mock.calls.find((call) => call[0].includes( - `[QueryCollection] queryFn did not return an array of objects. Skipping update.` + `@tanstack/query-db-collection: select() must return an array of objects` ) ) expect(errorCallArgs).toBeDefined()