diff --git a/.changeset/runtime-query-binding.md b/.changeset/runtime-query-binding.md new file mode 100644 index 000000000..cd93840f7 --- /dev/null +++ b/.changeset/runtime-query-binding.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-db-collection': patch +--- + +Add `defineQueryCollectionOptions` for defining query collections without a `QueryClient` and binding a runtime client later. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 6c90f53df..4454de6c3 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -43,6 +43,31 @@ const todosCollection = createCollection( ) ``` +## Runtime QueryClient Binding + +For SSR or request-scoped environments, define the collection options without a `QueryClient`, then bind the request-local client where you create the collection: + +```typescript +import { QueryClient } from "@tanstack/query-core" +import { createCollection } from "@tanstack/db" +import { defineQueryCollectionOptions } from "@tanstack/query-db-collection" + +const todosDefinition = defineQueryCollectionOptions({ + queryKey: ["todos"], + queryFn: async () => { + const response = await fetch("/api/todos") + return response.json() + }, + getKey: (item) => item.id, +}) + +export function createTodosCollection(queryClient: QueryClient) { + return createCollection(todosDefinition.bind({ queryClient })) +} +``` + +Each call to `bind` uses only the `QueryClient` you pass. The direct `queryCollectionOptions({ queryClient, ... })` API remains supported. + ## Configuration Options The `queryCollectionOptions` function accepts the following options: diff --git a/packages/query-db-collection/src/index.ts b/packages/query-db-collection/src/index.ts index 14a5fe702..4cb0ee21b 100644 --- a/packages/query-db-collection/src/index.ts +++ b/packages/query-db-collection/src/index.ts @@ -3,7 +3,9 @@ export type { QueryCollectionMeta } from './global' export { + defineQueryCollectionOptions, queryCollectionOptions, + type DefinedQueryCollectionOptions, type QueryCollectionConfig, type QueryCollectionUtils, type SyncOperation, diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index ea303b8dd..5b5e01177 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -359,6 +359,194 @@ function getLoadSubsetOptionsForMeta( return serializableOptions } +export interface DefinedQueryCollectionOptions { + bind: (runtime: { queryClient: QueryClient }) => TResult + config: TConfig +} + +type QueryCollectionConfigWithoutClient< + T extends object = object, + TQueryFn extends (context: QueryFunctionContext) => any = ( + context: QueryFunctionContext, + ) => any, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, + TQueryData = Awaited>, +> = Omit< + QueryCollectionConfig< + T, + TQueryFn, + TError, + TQueryKey, + TKey, + TSchema, + TQueryData + >, + `queryClient` +> & { + queryClient?: never +} + +export function defineQueryCollectionOptions< + T extends StandardSchemaV1, + TQueryFn extends (context: QueryFunctionContext) => any, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TQueryData = Awaited>, +>( + config: QueryCollectionConfigWithoutClient< + InferSchemaOutput, + TQueryFn, + TError, + TQueryKey, + TKey, + T + > & { + schema: T + select: (data: TQueryData) => Array> + }, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig< + InferSchemaOutput, + TKey, + T, + QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > + > & { + schema: T + utils: QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > + } +> + +export function defineQueryCollectionOptions< + T extends object, + TQueryFn extends (context: QueryFunctionContext) => any = ( + context: QueryFunctionContext, + ) => any, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TQueryData = Awaited>, +>( + config: QueryCollectionConfigWithoutClient< + T, + TQueryFn, + TError, + TQueryKey, + TKey, + never, + TQueryData + > & { + schema?: never + select: (data: TQueryData) => Array + }, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig> & { + schema?: never + utils: QueryCollectionUtils + } +> + +export function defineQueryCollectionOptions< + T extends StandardSchemaV1, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, +>( + config: QueryCollectionConfigWithoutClient< + InferSchemaOutput, + ( + context: QueryFunctionContext, + ) => Array> | Promise>>, + TError, + TQueryKey, + TKey, + T + > & { + schema: T + }, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig< + InferSchemaOutput, + TKey, + T, + QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > + > & { + schema: T + utils: QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > + } +> + +export function defineQueryCollectionOptions< + T extends object, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, +>( + config: QueryCollectionConfigWithoutClient< + T, + (context: QueryFunctionContext) => Array | Promise>, + TError, + TQueryKey, + TKey + > & { schema?: never }, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig> & { + schema?: never + utils: QueryCollectionUtils + } +> + +export function defineQueryCollectionOptions( + config: QueryCollectionConfigWithoutClient< + Record, + (context: QueryFunctionContext) => any + >, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig< + Record, + string | number, + never, + QueryCollectionUtils + > & { utils: QueryCollectionUtils } +> { + return { + config, + bind: ({ queryClient }) => + queryCollectionOptions({ + ...config, + queryClient, + }), + } +} + /** * Creates query collection options for use with a standard Collection. * This integrates TanStack Query with TanStack DB for automatic synchronization. diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index c526398f8..cdc39cf11 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -10,7 +10,10 @@ import { } from '@tanstack/db' import { QueryClient } from '@tanstack/query-core' import { z } from 'zod' -import { queryCollectionOptions } from '../src/query' +import { + defineQueryCollectionOptions, + queryCollectionOptions, +} from '../src/query' import type { DataTag, QueryFunctionContext, @@ -32,6 +35,37 @@ describe(`Query collection type resolution tests`, () => { // Create a mock QueryClient for tests const queryClient = new QueryClient() + it(`should preserve explicit types when definition is bound`, () => { + const definition = defineQueryCollectionOptions({ + id: `defined-test`, + queryKey: [`defined-test`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + }) + + const options = definition.bind({ queryClient }) + + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() + }) + + it(`should not accept QueryClient in runtime-bound definitions`, () => { + defineQueryCollectionOptions({ + id: `defined-test-without-client`, + queryKey: [`defined-test-without-client`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + }) + + defineQueryCollectionOptions({ + id: `defined-test-with-client`, + // @ts-expect-error - queryClient is supplied by definition.bind() + queryClient, + queryKey: [`defined-test-with-client`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + }) + }) + it(`should prioritize explicit type in QueryCollectionConfig`, () => { const options = queryCollectionOptions({ id: `test`, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 9a792093f..62855b00b 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -14,7 +14,10 @@ import { stripVirtualProps, } from '../../db/tests/utils' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' -import { queryCollectionOptions } from '../src/query' +import { + defineQueryCollectionOptions, + queryCollectionOptions, +} from '../src/query' import type { QueryFunctionContext } from '@tanstack/query-core' import type { Collection, @@ -185,6 +188,62 @@ describe(`QueryCollection`, () => { queryClient.clear() }) + it(`should define options without QueryClient and bind at runtime`, async () => { + const initialItems: Array = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(initialItems) + + const definition = defineQueryCollectionOptions({ + id: `defined-test`, + queryKey: [`definedItems`], + queryFn, + getKey, + startSync: true, + }) + + const collection = createCollection(definition.bind({ queryClient })) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(initialItems.length) + }) + + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialItems[0]) + }) + + it(`should isolate bindings to the provided QueryClient`, async () => { + const firstClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const secondClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `Item 1` }]) + + const definition = defineQueryCollectionOptions({ + id: `isolated-test`, + queryKey: [`isolatedItems`], + queryFn, + getKey, + startSync: false, + }) + + createCollection(definition.bind({ queryClient: firstClient })) + createCollection(definition.bind({ queryClient: secondClient })) + + firstClient.setQueryData( + [`isolatedItems`], + [{ id: `first`, name: `First` }], + ) + + expect(firstClient.getQueryData([`isolatedItems`])).toEqual([ + { id: `first`, name: `First` }, + ]) + expect(secondClient.getQueryData([`isolatedItems`])).toBeUndefined() + + firstClient.clear() + secondClient.clear() + }) + it(`should initialize and fetch initial data`, async () => { const queryKey = [`testItems`] const initialItems: Array = [