Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/runtime-query-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-db-collection': patch
---

Add `defineQueryCollectionOptions` for defining query collections without a `QueryClient` and binding a runtime client later.
25 changes: 25 additions & 0 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions packages/query-db-collection/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
export type { QueryCollectionMeta } from './global'

export {
defineQueryCollectionOptions,
queryCollectionOptions,
type DefinedQueryCollectionOptions,
type QueryCollectionConfig,
type QueryCollectionUtils,
type SyncOperation,
Expand Down
188 changes: 188 additions & 0 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,194 @@ function getLoadSubsetOptionsForMeta(
return serializableOptions
}

export interface DefinedQueryCollectionOptions<TConfig, TResult> {
bind: (runtime: { queryClient: QueryClient }) => TResult
config: TConfig
}

type QueryCollectionConfigWithoutClient<
T extends object = object,
TQueryFn extends (context: QueryFunctionContext<any>) => any = (
context: QueryFunctionContext<any>,
) => any,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
TSchema extends StandardSchemaV1 = never,
TQueryData = Awaited<ReturnType<TQueryFn>>,
> = Omit<
QueryCollectionConfig<
T,
TQueryFn,
TError,
TQueryKey,
TKey,
TSchema,
TQueryData
>,
`queryClient`
> & {
queryClient?: never
}

export function defineQueryCollectionOptions<
T extends StandardSchemaV1,
TQueryFn extends (context: QueryFunctionContext<any>) => any,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
TQueryData = Awaited<ReturnType<TQueryFn>>,
>(
config: QueryCollectionConfigWithoutClient<
InferSchemaOutput<T>,
TQueryFn,
TError,
TQueryKey,
TKey,
T
> & {
schema: T
select: (data: TQueryData) => Array<InferSchemaInput<T>>
},
): DefinedQueryCollectionOptions<
typeof config,
CollectionConfig<
InferSchemaOutput<T>,
TKey,
T,
QueryCollectionUtils<
InferSchemaOutput<T>,
TKey,
InferSchemaInput<T>,
TError
>
> & {
schema: T
utils: QueryCollectionUtils<
InferSchemaOutput<T>,
TKey,
InferSchemaInput<T>,
TError
>
}
>

export function defineQueryCollectionOptions<
T extends object,
TQueryFn extends (context: QueryFunctionContext<any>) => any = (
context: QueryFunctionContext<any>,
) => any,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
TQueryData = Awaited<ReturnType<TQueryFn>>,
>(
config: QueryCollectionConfigWithoutClient<
T,
TQueryFn,
TError,
TQueryKey,
TKey,
never,
TQueryData
> & {
schema?: never
select: (data: TQueryData) => Array<T>
},
): DefinedQueryCollectionOptions<
typeof config,
CollectionConfig<T, TKey, never, QueryCollectionUtils<T, TKey, T, TError>> & {
schema?: never
utils: QueryCollectionUtils<T, TKey, T, TError>
}
>

export function defineQueryCollectionOptions<
T extends StandardSchemaV1,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
>(
config: QueryCollectionConfigWithoutClient<
InferSchemaOutput<T>,
(
context: QueryFunctionContext<any>,
) => Array<InferSchemaOutput<T>> | Promise<Array<InferSchemaOutput<T>>>,
TError,
TQueryKey,
TKey,
T
> & {
schema: T
},
): DefinedQueryCollectionOptions<
typeof config,
CollectionConfig<
InferSchemaOutput<T>,
TKey,
T,
QueryCollectionUtils<
InferSchemaOutput<T>,
TKey,
InferSchemaInput<T>,
TError
>
> & {
schema: T
utils: QueryCollectionUtils<
InferSchemaOutput<T>,
TKey,
InferSchemaInput<T>,
TError
>
}
>

export function defineQueryCollectionOptions<
T extends object,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
>(
config: QueryCollectionConfigWithoutClient<
T,
(context: QueryFunctionContext<any>) => Array<T> | Promise<Array<T>>,
TError,
TQueryKey,
TKey
> & { schema?: never },
): DefinedQueryCollectionOptions<
typeof config,
CollectionConfig<T, TKey, never, QueryCollectionUtils<T, TKey, T, TError>> & {
schema?: never
utils: QueryCollectionUtils<T, TKey, T, TError>
}
>

export function defineQueryCollectionOptions(
config: QueryCollectionConfigWithoutClient<
Record<string, unknown>,
(context: QueryFunctionContext<any>) => any
>,
): DefinedQueryCollectionOptions<
typeof config,
CollectionConfig<
Record<string, unknown>,
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.
Expand Down
36 changes: 35 additions & 1 deletion packages/query-db-collection/tests/query.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ExplicitType>({
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<ExplicitType>({
id: `defined-test-without-client`,
queryKey: [`defined-test-without-client`],
queryFn: () => Promise.resolve([]),
getKey: (item) => item.id,
})

defineQueryCollectionOptions<ExplicitType>({
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<ExplicitType>({
id: `test`,
Expand Down
61 changes: 60 additions & 1 deletion packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -185,6 +188,62 @@ describe(`QueryCollection`, () => {
queryClient.clear()
})

it(`should define options without QueryClient and bind at runtime`, async () => {
const initialItems: Array<TestItem> = [{ id: `1`, name: `Item 1` }]
const queryFn = vi.fn().mockResolvedValue(initialItems)

const definition = defineQueryCollectionOptions<TestItem>({
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<TestItem>({
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<TestItem> = [
Expand Down
Loading