From c17f30f289d7dbb28b13cf826b24bf43edb177c0 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 7 Jul 2026 13:18:06 -0600 Subject: [PATCH 1/5] docs: add query select semantics design --- ...026-03-23-query-select-semantics-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-23-query-select-semantics-design.md diff --git a/docs/superpowers/specs/2026-03-23-query-select-semantics-design.md b/docs/superpowers/specs/2026-03-23-query-select-semantics-design.md new file mode 100644 index 000000000..29f6f8289 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-query-select-semantics-design.md @@ -0,0 +1,112 @@ +# Query collection select row extraction semantics + +## Context + +Issue [#345](https://github.com/TanStack/db/issues/345) requested a `select` option for `@tanstack/query-db-collection` so APIs can return wrapped responses such as `{ data: Todo[], total, page }` while TanStack DB materializes only the row array. + +Current main already includes `select` support. RFC [#1643](https://github.com/TanStack/db/issues/1643) clarifies the remaining semantic distinction: query-db-collection's `select` is row extraction for DB materialization. It is not exactly the same semantic surface as TanStack Query's `select`. + +This PR should close the documentation/test gap for #345 without introducing a new projection or lens API. + +## Goals + +- Clarify that `select` extracts rows for TanStack DB materialization from a TanStack Query result. +- Clarify that the TanStack Query cache keeps the original query response shape. +- Preserve existing broadly useful direct-write cache patching behavior for wrapped responses. +- Add or sharpen focused tests for wrapped response materialization and metadata preservation. + +## Non-goals + +- Do not rename `select`. +- Do not add a new `rows.read` / `rows.write` API. +- Do not remove existing direct-write wrapper patching fallbacks unless a focused test exposes clear corruption. +- Do not redesign invalidation or refetch behavior for unpatchable derived projections. + +## Public semantics + +`select` should be described as a row extraction function: + +```ts +select: (response) => response.items +``` + +It tells query-db-collection which array of rows TanStack DB should materialize from the Query result. + +The original Query cache value remains the wrapped response: + +```ts +// Query cache value +{ + items: Todo[], + nextCursor: string, + total: number, +} + +// DB materialized rows +Todo[] +``` + +Docs should avoid implying that adapter-level `select` is exactly TanStack Query's observer-level `select`. The adapter uses it to bridge Query's document-cache shape into DB's normalized row store. + +## Direct-write cache patching semantics + +Direct write utilities such as `writeInsert`, `writeUpdate`, and `writeDelete` update TanStack DB's synced row state and make a best-effort attempt to keep matching Query cache entries in sync. + +For wrapped responses, the existing implementation should remain broadly compatible: + +1. Use `select(oldData)` and patch the direct wrapper property whose value is the selected array by reference equality. +2. Fall back to common wrapper fields such as `data`, `items`, and `results`. +3. Fall back to the first array property. +4. If no array property can be found, leave the cached wrapper unchanged. + +This behavior should be documented as best effort. It is reliable for simple wrappers like `{ data: [...] }`, `{ items: [...] }`, and `{ results: [...] }`. It is not a general bidirectional projection system. + +Derived projections such as GraphQL edge flattening are read-side row extraction only: + +```ts +select: (response) => response.edges.map((edge) => edge.node) +``` + +query-db-collection cannot generally reconstruct the original response envelope from updated rows. Users who need exact wrapped cache updates for derived projections should refetch/invalidate or wait for a future explicit read/write projection API. + +## Test plan + +Audit existing tests first and avoid duplicating coverage. Add or adjust tests only where coverage is incomplete. + +Recommended focused coverage: + +1. Wrapped response materialization: + - `queryFn` returns `{ items: rows, meta: { page: 1 } }`. + - `select` returns `response.items`. + - Collection materializes rows. + - `queryClient.getQueryData(queryKey)` still returns the wrapped response including metadata. + +2. Direct writes with a common wrapper: + - Start from a wrapped response with row array plus metadata. + - Exercise `writeInsert`, `writeUpdate`, and/or `writeDelete` as appropriate. + - Assert the collection updates. + - Assert the Query cache preserves wrapper metadata and updates the row array. + +3. Existing error behavior: + - Keep coverage that non-array `select` results are rejected and do not materialize rows. + +Avoid adding a brittle derived-projection behavior test unless current behavior clearly corrupts the cache in a way this PR intentionally fixes. + +## Documentation plan + +Update `docs/collections/query-collection.md` around the `select` option with: + +- a concise definition of `select` as DB row extraction; +- an example showing a wrapped response and preserved Query cache metadata; +- a note that direct-write cache patching for wrapped responses is best effort; +- examples of simple wrappers that work automatically; +- a limitation note for derived projections. + +Generated reference docs may need to be updated only if the source comments change and the repository expects generated docs in the PR. + +## Implementation constraints + +- Follow `AGENTS.md` quality guidance. +- Prefer tests/docs clarification over behavior changes. +- Keep any behavior change small and directly justified by a failing focused test. +- Do not broaden the PR into invalidation semantics or future API design. From 178912211c33e09ee1bebb0f8a7314b3b2dcaf07 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 7 Jul 2026 13:23:33 -0600 Subject: [PATCH 2/5] docs(query-db-collection): clarify select row extraction --- docs/collections/query-collection.md | 33 ++++++++++++++++- packages/query-db-collection/src/query.ts | 5 ++- .../query-db-collection/tests/query.test.ts | 36 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 73f3511ed..40362ed9e 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -56,7 +56,7 @@ The `queryCollectionOptions` function accepts the following options: ### Query Options -- `select`: Function that lets extract array items when they're wrapped with metadata +- `select`: Function that extracts the row array TanStack DB materializes from a wrapped Query response - `enabled`: Whether the query should automatically run (default: `true`) - `refetchInterval`: Refetch interval in milliseconds (default: 0 — set an interval to enable polling refetching) - `retry`: Retry configuration for failed queries @@ -96,6 +96,37 @@ const todosCollection = createCollection( If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`. +### Selecting Rows from Wrapped Responses + +Many APIs return rows inside a response envelope that also contains metadata such as pagination cursors, totals, or request information. Use `select` to extract the row array that TanStack DB should materialize: + +```typescript +interface TodosResponse { + items: Array<{ id: string; title: string }> + nextCursor?: string + total: number +} + +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: async (): Promise => { + const response = await fetch("/api/todos") + return response.json() + }, + select: (response) => response.items, + queryClient, + getKey: (item) => item.id, + }), +) +``` + +`select` is a query-db-collection row extraction hook. It tells TanStack DB which rows to materialize while the TanStack Query cache keeps the original query response shape. In the example above, `queryClient.getQueryData(["todos"])` still returns the full `TodosResponse`, including `nextCursor` and `total`. + +This differs from TanStack Query's observer-level `select`: query-db-collection uses this option to bridge Query's response object into DB's normalized row store. + +Direct write utilities such as `writeInsert`, `writeUpdate`, and `writeDelete` make a best-effort attempt to update the matching row array inside wrapped Query cache entries while preserving wrapper metadata. Simple wrappers such as `{ data: [...] }`, `{ items: [...] }`, and `{ results: [...] }` work automatically. Derived projections, such as `select: (response) => response.edges.map((edge) => edge.node)`, are read-side row extraction only; query-db-collection cannot generally reconstruct the original response envelope from updated rows. Refetch or invalidate the query if the wrapped cache must exactly reflect direct writes for a derived projection. + ### Collection Options - `id`: Unique identifier for the collection diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index ec3396117..ea303b8dd 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -77,7 +77,10 @@ export interface QueryCollectionConfig< ) => Promise> | Array ? (context: QueryFunctionContext) => Promise> | Array : TQueryFn - /* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */ + /** + * Extracts the row array TanStack DB materializes from the Query response. + * The Query cache keeps the original response shape. + */ select?: (data: TQueryData) => Array /** The TanStack Query client instance */ queryClient: QueryClient diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index aa9ee2406..e0e943f6e 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -898,6 +898,42 @@ describe(`QueryCollection`, () => { expect(initialCache).toEqual(initialMetaData) }) + it(`materializes selected rows while preserving wrapped Query cache response`, async () => { + const queryKey = [`select-row-extraction-test`] + const wrappedResponse = { + items: initialMetaData.data, + meta: { page: 1, total: initialMetaData.data.length }, + } + + const queryFn = vi.fn().mockResolvedValue(wrappedResponse) + const select = vi.fn((data: typeof wrappedResponse) => data.items) + + const options = queryCollectionOptions({ + id: `select-row-extraction-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(wrappedResponse.items.length) + }) + + expect(stripVirtualProps(collection.get(`1`))).toEqual( + wrappedResponse.items[0], + ) + expect(stripVirtualProps(collection.get(`2`))).toEqual( + wrappedResponse.items[1], + ) + expect(queryClient.getQueryData(queryKey)).toEqual(wrappedResponse) + }) + it(`should not throw error when using writeInsert with select option`, async () => { const queryKey = [`select-writeInsert-test`] const consoleErrorSpy = vi From 0b5a0fa01b96f0987591d6cca5dfc6ccf4a07de7 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 7 Jul 2026 13:54:04 -0600 Subject: [PATCH 3/5] Apply review simplifications --- docs/collections/query-collection.md | 10 +- ...026-03-23-query-select-semantics-design.md | 112 ------------------ 2 files changed, 9 insertions(+), 113 deletions(-) delete mode 100644 docs/superpowers/specs/2026-03-23-query-select-semantics-design.md diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 40362ed9e..825087cf6 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -125,7 +125,15 @@ const todosCollection = createCollection( This differs from TanStack Query's observer-level `select`: query-db-collection uses this option to bridge Query's response object into DB's normalized row store. -Direct write utilities such as `writeInsert`, `writeUpdate`, and `writeDelete` make a best-effort attempt to update the matching row array inside wrapped Query cache entries while preserving wrapper metadata. Simple wrappers such as `{ data: [...] }`, `{ items: [...] }`, and `{ results: [...] }` work automatically. Derived projections, such as `select: (response) => response.edges.map((edge) => edge.node)`, are read-side row extraction only; query-db-collection cannot generally reconstruct the original response envelope from updated rows. Refetch or invalidate the query if the wrapped cache must exactly reflect direct writes for a derived projection. +Direct write utilities such as `writeInsert`, `writeUpdate`, and `writeDelete` make a best-effort attempt to update the matching row array inside wrapped Query cache entries while preserving wrapper metadata. + +This works automatically for simple wrappers such as: + +- `{ data: [...] }` +- `{ items: [...] }` +- `{ results: [...] }` + +Derived projections, such as `select: (response) => response.edges.map((edge) => edge.node)`, are read-side row extraction only. query-db-collection cannot generally reconstruct the original response envelope from updated rows. Refetch or invalidate the query if the wrapped cache must exactly reflect direct writes for a derived projection. ### Collection Options diff --git a/docs/superpowers/specs/2026-03-23-query-select-semantics-design.md b/docs/superpowers/specs/2026-03-23-query-select-semantics-design.md deleted file mode 100644 index 29f6f8289..000000000 --- a/docs/superpowers/specs/2026-03-23-query-select-semantics-design.md +++ /dev/null @@ -1,112 +0,0 @@ -# Query collection select row extraction semantics - -## Context - -Issue [#345](https://github.com/TanStack/db/issues/345) requested a `select` option for `@tanstack/query-db-collection` so APIs can return wrapped responses such as `{ data: Todo[], total, page }` while TanStack DB materializes only the row array. - -Current main already includes `select` support. RFC [#1643](https://github.com/TanStack/db/issues/1643) clarifies the remaining semantic distinction: query-db-collection's `select` is row extraction for DB materialization. It is not exactly the same semantic surface as TanStack Query's `select`. - -This PR should close the documentation/test gap for #345 without introducing a new projection or lens API. - -## Goals - -- Clarify that `select` extracts rows for TanStack DB materialization from a TanStack Query result. -- Clarify that the TanStack Query cache keeps the original query response shape. -- Preserve existing broadly useful direct-write cache patching behavior for wrapped responses. -- Add or sharpen focused tests for wrapped response materialization and metadata preservation. - -## Non-goals - -- Do not rename `select`. -- Do not add a new `rows.read` / `rows.write` API. -- Do not remove existing direct-write wrapper patching fallbacks unless a focused test exposes clear corruption. -- Do not redesign invalidation or refetch behavior for unpatchable derived projections. - -## Public semantics - -`select` should be described as a row extraction function: - -```ts -select: (response) => response.items -``` - -It tells query-db-collection which array of rows TanStack DB should materialize from the Query result. - -The original Query cache value remains the wrapped response: - -```ts -// Query cache value -{ - items: Todo[], - nextCursor: string, - total: number, -} - -// DB materialized rows -Todo[] -``` - -Docs should avoid implying that adapter-level `select` is exactly TanStack Query's observer-level `select`. The adapter uses it to bridge Query's document-cache shape into DB's normalized row store. - -## Direct-write cache patching semantics - -Direct write utilities such as `writeInsert`, `writeUpdate`, and `writeDelete` update TanStack DB's synced row state and make a best-effort attempt to keep matching Query cache entries in sync. - -For wrapped responses, the existing implementation should remain broadly compatible: - -1. Use `select(oldData)` and patch the direct wrapper property whose value is the selected array by reference equality. -2. Fall back to common wrapper fields such as `data`, `items`, and `results`. -3. Fall back to the first array property. -4. If no array property can be found, leave the cached wrapper unchanged. - -This behavior should be documented as best effort. It is reliable for simple wrappers like `{ data: [...] }`, `{ items: [...] }`, and `{ results: [...] }`. It is not a general bidirectional projection system. - -Derived projections such as GraphQL edge flattening are read-side row extraction only: - -```ts -select: (response) => response.edges.map((edge) => edge.node) -``` - -query-db-collection cannot generally reconstruct the original response envelope from updated rows. Users who need exact wrapped cache updates for derived projections should refetch/invalidate or wait for a future explicit read/write projection API. - -## Test plan - -Audit existing tests first and avoid duplicating coverage. Add or adjust tests only where coverage is incomplete. - -Recommended focused coverage: - -1. Wrapped response materialization: - - `queryFn` returns `{ items: rows, meta: { page: 1 } }`. - - `select` returns `response.items`. - - Collection materializes rows. - - `queryClient.getQueryData(queryKey)` still returns the wrapped response including metadata. - -2. Direct writes with a common wrapper: - - Start from a wrapped response with row array plus metadata. - - Exercise `writeInsert`, `writeUpdate`, and/or `writeDelete` as appropriate. - - Assert the collection updates. - - Assert the Query cache preserves wrapper metadata and updates the row array. - -3. Existing error behavior: - - Keep coverage that non-array `select` results are rejected and do not materialize rows. - -Avoid adding a brittle derived-projection behavior test unless current behavior clearly corrupts the cache in a way this PR intentionally fixes. - -## Documentation plan - -Update `docs/collections/query-collection.md` around the `select` option with: - -- a concise definition of `select` as DB row extraction; -- an example showing a wrapped response and preserved Query cache metadata; -- a note that direct-write cache patching for wrapped responses is best effort; -- examples of simple wrappers that work automatically; -- a limitation note for derived projections. - -Generated reference docs may need to be updated only if the source comments change and the repository expects generated docs in the PR. - -## Implementation constraints - -- Follow `AGENTS.md` quality guidance. -- Prefer tests/docs clarification over behavior changes. -- Keep any behavior change small and directly justified by a failing focused test. -- Do not broaden the PR into invalidation semantics or future API design. From 855d6c326c2d642df179588909004bd775c8b94e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 7 Jul 2026 13:54:29 -0600 Subject: [PATCH 4/5] Add changeset for query select semantics --- .changeset/clarify-query-select-semantics.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clarify-query-select-semantics.md diff --git a/.changeset/clarify-query-select-semantics.md b/.changeset/clarify-query-select-semantics.md new file mode 100644 index 000000000..632c15c28 --- /dev/null +++ b/.changeset/clarify-query-select-semantics.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-db-collection': patch +--- + +Clarify that `select` extracts rows for DB materialization while preserving the wrapped TanStack Query cache response. From 32aed69f62ae72b59ad50b69ba9af3d1b2ee2f32 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 7 Jul 2026 15:24:18 -0600 Subject: [PATCH 5/5] Strengthen select cache preservation test --- packages/query-db-collection/tests/query.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index e0e943f6e..376ae319a 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -904,6 +904,10 @@ describe(`QueryCollection`, () => { items: initialMetaData.data, meta: { page: 1, total: initialMetaData.data.length }, } + const expectedCacheResponse = { + items: initialMetaData.data.map((item) => ({ ...item })), + meta: { page: 1, total: initialMetaData.data.length }, + } const queryFn = vi.fn().mockResolvedValue(wrappedResponse) const select = vi.fn((data: typeof wrappedResponse) => data.items) @@ -931,7 +935,7 @@ describe(`QueryCollection`, () => { expect(stripVirtualProps(collection.get(`2`))).toEqual( wrappedResponse.items[1], ) - expect(queryClient.getQueryData(queryKey)).toEqual(wrappedResponse) + expect(queryClient.getQueryData(queryKey)).toEqual(expectedCacheResponse) }) it(`should not throw error when using writeInsert with select option`, async () => {