From 06053bc0b1a8e33f69128e52ae89baf938bad7d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 16:18:25 +0000 Subject: [PATCH 01/12] fix(core): Allow controller.set() with Array schemas controller.set([Entity], rows) and controller.set(new schema.Array(Entity), rows) already batch-write at runtime (one SET action, one normalize), but the Queryable constraint rejected Array schemas because their queryKey is undefined. Add an overload for Array schemas that takes only the value. Co-authored-by: Nathaniel Tucker --- .changeset/controller-set-array.md | 16 ++++++ docs/core/api/Controller.md | 13 +++++ packages/core/src/controller/Controller.ts | 10 ++++ .../src/hooks/__tests__/useController/set.tsx | 51 +++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 .changeset/controller-set-array.md diff --git a/.changeset/controller-set-array.md b/.changeset/controller-set-array.md new file mode 100644 index 000000000000..462d840e2575 --- /dev/null +++ b/.changeset/controller-set-array.md @@ -0,0 +1,16 @@ +--- +'@data-client/core': patch +'@data-client/react': patch +'@data-client/vue': patch +--- + +Fix `controller.set()` types for Array schemas + +`controller.set([Entity], rows)` and `controller.set(new schema.Array(Entity), rows)` now typecheck. This writes every row in one normalize: entities merge, and entities not in `rows` stay in the store. + +```ts +ctrl.set([Ticker], [ + { product_id: 'BTC-USD', price: 100 }, + { product_id: 'ETH-USD', price: 10 }, +]); +``` diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index 76d2b7ecea0b..921891b516e8 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -384,6 +384,19 @@ const id = '2'; ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 })); ``` +Array schemas set many entities in one normalize. Each entity merges with what is stored; entities not in the list are left alone. +Arrays take no args and no updater function. + +```ts +ctrl.set( + [Todo], + [ + { id: '5', completed: true }, + { id: '6', completed: false }, + ], +); +``` + ### setResponse(endpoint, ...args, response) {#setResponse} Stores `response` in cache for given [Endpoint](/rest/api/Endpoint) and args. diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index 932e505ecf3b..14a77a027208 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -233,6 +233,16 @@ export default class Controller< ...rest: readonly [...SchemaArgs, {}] ): Promise; + /** + * Sets every item of an Array schema like `[Entity]` in one normalize. + * @see https://dataclient.io/docs/api/Controller#set + */ + set< + S extends + | Schema[] + | { normalize(...args: any): any[]; queryKey(...args: any): undefined }, + >(schema: S, value: readonly {}[]): Promise; + set( schema: S, ...rest: readonly [...SchemaArgs, any] diff --git a/packages/react/src/hooks/__tests__/useController/set.tsx b/packages/react/src/hooks/__tests__/useController/set.tsx index cdc99b6df81a..42af6f38a5b9 100644 --- a/packages/react/src/hooks/__tests__/useController/set.tsx +++ b/packages/react/src/hooks/__tests__/useController/set.tsx @@ -1,4 +1,5 @@ import { DataProvider } from '@data-client/react'; +import { schema } from '@data-client/rest'; import { CoolerArticle } from '__tests__/new'; import nock from 'nock'; @@ -60,6 +61,10 @@ describe('set', () => { // type tests // TODO: move these to own unit tests if/when applicable () => { + controller.set(CoolerArticle, { id: 5 }, article => ({ + id: 5, + title: `${article.title}!`, + })); // @ts-expect-error controller.set(CoolerArticle, payload); controller.set( @@ -71,6 +76,52 @@ describe('set', () => { }; }); + it('should batch set entities with an array schema', async () => { + const { controller } = renderDataClient(() => null); + let promise: any; + act(() => { + controller.set(CoolerArticle, { id: 5 }, { ...payload, content: 'kept' }); + controller.set(CoolerArticle, { id: 1 }, createPayload); + promise = controller.set( + [CoolerArticle], + [ + { id: 5, title: 'merged' }, + { id: 6, title: 'new' }, + ], + ); + }); + await act(() => promise); + const state = controller.getState(); + expect(controller.get(CoolerArticle, { id: 5 }, state)).toMatchObject({ + title: 'merged', + content: 'kept', + }); + expect(controller.get(CoolerArticle, { id: 6 }, state)?.title).toBe('new'); + expect(controller.get(CoolerArticle, { id: 1 }, state)?.title).toBe( + createPayload.title, + ); + + act(() => { + promise = controller.set(new schema.Array(CoolerArticle), [ + { id: 6, title: 'array class' }, + ]); + }); + await act(() => promise); + expect( + controller.get(CoolerArticle, { id: 6 }, controller.getState())?.title, + ).toBe('array class'); + + // type tests + () => { + // @ts-expect-error array schemas have no args + controller.set([CoolerArticle], { id: 5 }, [payload]); + // @ts-expect-error array schemas have no previous value to update + controller.set([CoolerArticle], (articles: any) => articles); + // @ts-expect-error + controller.set([CoolerArticle], payload); + }; + }); + it('should update store with error', async () => { const { result, controller } = renderDataClient(() => { return useQuery(CoolerArticle, { id: payload.id }); From b13578ae19402e914df9d67914e488d32ea82b90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 16:20:23 +0000 Subject: [PATCH 02/12] chore: Format controller-set-array changeset Co-authored-by: Nathaniel Tucker --- .changeset/controller-set-array.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.changeset/controller-set-array.md b/.changeset/controller-set-array.md index 462d840e2575..d22a282f2c2b 100644 --- a/.changeset/controller-set-array.md +++ b/.changeset/controller-set-array.md @@ -9,8 +9,11 @@ Fix `controller.set()` types for Array schemas `controller.set([Entity], rows)` and `controller.set(new schema.Array(Entity), rows)` now typecheck. This writes every row in one normalize: entities merge, and entities not in `rows` stay in the store. ```ts -ctrl.set([Ticker], [ - { product_id: 'BTC-USD', price: 100 }, - { product_id: 'ETH-USD', price: 10 }, -]); +ctrl.set( + [Ticker], + [ + { product_id: 'BTC-USD', price: 100 }, + { product_id: 'ETH-USD', price: 10 }, + ], +); ``` From 70184e6285fe3e24f79189b2b1b76b30423fc187 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 16:20:23 +0000 Subject: [PATCH 03/12] docs(skills): Teach controller.set([Entity], rows) batch writes Add a short batch-write gotcha to the data-client-manager and data-client-react skills, with evals that fail a per-row controller.set(Entity, ...) loop or a setResponse/push-endpoint workaround and pass controller.set([Entity], rows). Co-authored-by: Nathaniel Tucker --- .agents/skills/data-client-manager/SKILL.md | 7 +++ .../data-client-manager/evals/evals.json | 47 +++++++++++++++++++ .agents/skills/data-client-react/SKILL.md | 3 ++ .../skills/data-client-react/evals/evals.json | 33 +++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 .agents/skills/data-client-manager/evals/evals.json create mode 100644 .agents/skills/data-client-react/evals/evals.json diff --git a/.agents/skills/data-client-manager/SKILL.md b/.agents/skills/data-client-manager/SKILL.md index b5a4193d09a1..65102120bfbf 100644 --- a/.agents/skills/data-client-manager/SKILL.md +++ b/.agents/skills/data-client-manager/SKILL.md @@ -71,6 +71,13 @@ export default class TimeManager implements Manager { } ``` +### Batch writes + +To write many entities at once (a websocket snapshot, buffered stream messages), call `controller.set([Entity], rows)` once. It normalizes once; each row merges with its stored entity, and entities not in `rows` stay. Array schemas take no args and no updater function. + +- Don't loop `controller.set(Entity, args, row)` per row: each call is a separate store update. +- Don't add an endpoint or call `setResponse()` just to batch: it caches a response nothing reads. + ## Reading and Consuming Actions [Controller](references/Controller.md) has data accessors: diff --git a/.agents/skills/data-client-manager/evals/evals.json b/.agents/skills/data-client-manager/evals/evals.json new file mode 100644 index 000000000000..d082a2cb2866 --- /dev/null +++ b/.agents/skills/data-client-manager/evals/evals.json @@ -0,0 +1,47 @@ +{ + "skill_name": "data-client-manager", + "evals": [ + { + "id": 1, + "name": "websocket-snapshot", + "prompt": "Write src/managers/TickerManager.ts for our exchange websocket (wss://feed.example.com). On connect it sends `{ type: 'snapshot', tickers: [...] }` with ~300 tickers, then `{ type: 'ticker', product_id, price, volume }` one at a time. Get both into the Data Client store so `useQuery(Ticker, { product_id })` updates. Ticker is in src/resources/Ticker.ts:\n\n```ts\nimport { Entity } from '@data-client/rest';\nexport class Ticker extends Entity {\n product_id = '';\n price = 0;\n volume = 0;\n pk() { return this.product_id; }\n static key = 'Ticker';\n}\n```", + "expected_output": "A Manager that opens the socket in init() or middleware, closes it in cleanup(), writes the snapshot with one controller.set([Ticker], msg.tickers) call, and writes single ticker messages with controller.set(Ticker, { product_id }, msg).", + "files": [], + "assertions": [ + "The snapshot is written with a single `controller.set([Ticker], tickers)` or `controller.set(new schema.Array(Ticker), tickers)` call that passes the rows and no args.", + "The snapshot rows are not written by calling `controller.set(Ticker, …)` once per row (no loop, forEach, or map over rows that calls set).", + "No endpoint is defined and `setResponse()` is not called to write the snapshot.", + "No `as any` or other cast is used to make the `controller.set` call typecheck.", + "The socket is closed in `cleanup()`." + ] + }, + { + "id": 2, + "name": "buffered-flush", + "prompt": "our ticker feed pushes ~500 msgs/sec and calling controller.set on every message is tanking perf. can you change the manager so it buffers messages and flushes to the store every 100ms? each message is `{ product_id, price, volume }` and the entity is `Ticker` (pk is product_id) from src/resources/Ticker.ts. current code:\n\n```ts\nimport type { Manager, Middleware } from '@data-client/react';\nimport { Ticker } from '../resources/Ticker';\n\nexport default class TickerManager implements Manager {\n declare protected ws: WebSocket;\n middleware: Middleware = controller => {\n this.ws = new WebSocket('wss://feed.example.com');\n this.ws.onmessage = e => {\n const msg = JSON.parse(e.data);\n controller.set(Ticker, { product_id: msg.product_id }, msg);\n };\n return next => async action => next(action);\n };\n cleanup() { this.ws.close(); }\n}\n```", + "expected_output": "The manager buffers messages (optionally keeping only the latest per product_id), and on each 100ms flush writes the whole buffer with one controller.set([Ticker], rows) call, then clears the buffer. The interval is cleared in cleanup().", + "files": [], + "assertions": [ + "Each flush writes the buffer with a single `controller.set([Ticker], rows)` or `controller.set(new schema.Array(Ticker), rows)` call that passes the rows and no args.", + "The flush does not call `controller.set(Ticker, …)` once per buffered row (no loop, forEach, or map over rows that calls set).", + "No endpoint is defined and `setResponse()` is not called to batch the writes.", + "No `as any` or other cast is used to make the `controller.set` call typecheck.", + "The flush interval or timer is cleared in `cleanup()`." + ] + }, + { + "id": 3, + "name": "setresponse-suggestion", + "prompt": "Reviewing a PR: to batch ticker writes, a teammate added `const pushTickers = new Endpoint(async (rows: Ticker[]) => rows, { schema: [Ticker], key: () => 'pushTickers' })` and flushes with `controller.setResponse(pushTickers, rows, rows)`. The comment says `controller.set([Ticker], rows)` doesn't typecheck. Is this the right approach? If not, rewrite the flush in src/managers/TickerManager.ts. We're on the latest @data-client/react.", + "expected_output": "Rejects the push-only endpoint and setResponse as unnecessary for batching (it caches an endpoint response nothing reads) and rewrites the flush as one controller.set([Ticker], rows) call with no cast, noting that entities merge and entities not in rows stay.", + "files": [], + "assertions": [ + "The response says the push-only endpoint plus `setResponse()` is not the right way to batch entity writes.", + "The rewritten flush uses a single `controller.set([Ticker], rows)` or `controller.set(new schema.Array(Ticker), rows)` call that passes the rows and no args.", + "The rewrite keeps no push-only endpoint and no `setResponse()` call for the batch.", + "The rewrite does not call `controller.set(Ticker, …)` once per row.", + "No `as any` or other cast is used to make the `controller.set` call typecheck." + ] + } + ] +} diff --git a/.agents/skills/data-client-react/SKILL.md b/.agents/skills/data-client-react/SKILL.md index cd689281727a..b6a13f997930 100644 --- a/.agents/skills/data-client-react/SKILL.md +++ b/.agents/skills/data-client-react/SKILL.md @@ -92,6 +92,9 @@ Its props are `fallback`, `errorComponent`, and `errorClassName` and `listen`. I ctrl.fetch(), ctrl.fetchIfStale(), ctrl.expireAll(), ctrl.invalidate(), ctrl.invalidateAll(), ctrl.setResponse(), ctrl.set(), ctrl.setError(), ctrl.resetEntireStore(), ctrl.subscribe(), ctrl.unsubscribe(). +To write many entities without a fetch, call `ctrl.set([Entity], rows)` once: one normalize, each row merges with its stored entity, and entities not in `rows` stay. +Don't loop `ctrl.set(Entity, args, row)` per row (one store update each), and don't add an endpoint or `setResponse()` just to batch (it caches a response nothing reads). + ## Programmatic queries ```ts diff --git a/.agents/skills/data-client-react/evals/evals.json b/.agents/skills/data-client-react/evals/evals.json new file mode 100644 index 000000000000..b3560fe0dc0e --- /dev/null +++ b/.agents/skills/data-client-react/evals/evals.json @@ -0,0 +1,33 @@ +{ + "skill_name": "data-client-react", + "evals": [ + { + "id": 1, + "name": "csv-import", + "prompt": "Add an `ImportProducts` component (src/components/ImportProducts.tsx) with a file input. When the user picks a CSV, parse it with `parseProductsCsv(file): Promise<{ id: string; name: string; price: number }[]>` from src/lib/csv.ts and put every row into the Data Client store, so `ProductRow` (which calls `useQuery(Product, { id })`) shows them. There is no server call for this. Product is in src/resources/Product.ts and extends Entity with `id`, `name`, `price`.", + "expected_output": "A component that uses useController() and, after parsing, writes all rows with one ctrl.set([Product], rows) call, with no fetch, endpoint, or setResponse.", + "files": [], + "assertions": [ + "All parsed rows are written with a single `ctrl.set([Product], rows)` or `ctrl.set(new schema.Array(Product), rows)` call that passes the rows and no args.", + "The rows are not written by calling `ctrl.set(Product, …)` once per row (no loop, forEach, or map over rows that calls set).", + "No endpoint is defined and `setResponse()` is not called to write the rows.", + "No `as any` or other cast is used to make the `ctrl.set` call typecheck.", + "The controller comes from `useController()`." + ] + }, + { + "id": 2, + "name": "worker-results", + "prompt": "Our pricing Web Worker posts back `{ type: 'prices', rows: Array<{ id: string; price: number }> }` a few thousand rows at a time. In src/components/PricingWorkerBridge.tsx, listen to the worker in an effect and write the rows into the Data Client store so every `useQuery(Product, { id })` gets the new price. Product (src/resources/Product.ts) is an Entity with id, name, price; rows only have id and price, so name must survive. Keep it fast.", + "expected_output": "An effect that subscribes to worker messages, writes each message's rows with one ctrl.set([Product], rows) call so price merges into stored Products while name is kept, and removes the listener on cleanup.", + "files": [], + "assertions": [ + "Each worker message's rows are written with a single `ctrl.set([Product], rows)` or `ctrl.set(new schema.Array(Product), rows)` call that passes the rows and no args.", + "The rows are not written by calling `ctrl.set(Product, …)` once per row (no loop, forEach, or map over rows that calls set).", + "No endpoint is defined and `setResponse()` is not called to batch the writes.", + "No `as any` or other cast is used to make the `ctrl.set` call typecheck.", + "The worker listener is removed in the effect cleanup." + ] + } + ] +} From 9dd2895a811477b8000f5f7a83ff6e70bddcf425 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 16:31:37 +0000 Subject: [PATCH 04/12] fix(core): Keep Entities off the Array set() overload Entity's any-typed normalize/queryKey matched the Array overload, so controller.set(Entity, [row]) typechecked but was a runtime no-op. Co-authored-by: Nathaniel Tucker --- packages/core/src/controller/Controller.ts | 7 ++++++- packages/react/src/hooks/__tests__/useController/set.tsx | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index 14a77a027208..a8da8f7ab97d 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -240,7 +240,12 @@ export default class Controller< set< S extends | Schema[] - | { normalize(...args: any): any[]; queryKey(...args: any): undefined }, + | { + normalize(...args: any): any[]; + queryKey(...args: any): undefined; + // excludes Entity, whose `any` returns match the members above + pk?: never; + }, >(schema: S, value: readonly {}[]): Promise; set( diff --git a/packages/react/src/hooks/__tests__/useController/set.tsx b/packages/react/src/hooks/__tests__/useController/set.tsx index 42af6f38a5b9..32e6cc70615b 100644 --- a/packages/react/src/hooks/__tests__/useController/set.tsx +++ b/packages/react/src/hooks/__tests__/useController/set.tsx @@ -117,8 +117,10 @@ describe('set', () => { controller.set([CoolerArticle], { id: 5 }, [payload]); // @ts-expect-error array schemas have no previous value to update controller.set([CoolerArticle], (articles: any) => articles); - // @ts-expect-error + // @ts-expect-error value must be an array controller.set([CoolerArticle], payload); + // @ts-expect-error entities need args, even with an array value + controller.set(CoolerArticle, [payload]); }; }); From 1eb03ce2cbd9a62636bd8c3a33113b105a722f40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 16:31:37 +0000 Subject: [PATCH 05/12] docs: Tighten controller.set Array wording in docs, skills, and evals Co-authored-by: Nathaniel Tucker --- .agents/skills/data-client-manager/SKILL.md | 2 +- .agents/skills/data-client-manager/evals/evals.json | 2 +- .agents/skills/data-client-react/SKILL.md | 6 ++++-- .changeset/controller-set-array.md | 2 +- docs/core/api/Controller.md | 5 ++--- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.agents/skills/data-client-manager/SKILL.md b/.agents/skills/data-client-manager/SKILL.md index 65102120bfbf..094e51baf8bd 100644 --- a/.agents/skills/data-client-manager/SKILL.md +++ b/.agents/skills/data-client-manager/SKILL.md @@ -73,7 +73,7 @@ export default class TimeManager implements Manager { ### Batch writes -To write many entities at once (a websocket snapshot, buffered stream messages), call `controller.set([Entity], rows)` once. It normalizes once; each row merges with its stored entity, and entities not in `rows` stay. Array schemas take no args and no updater function. +To write many entities at once (a websocket snapshot, buffered stream messages), call `controller.set([Entity], rows)` once. It normalizes once; each row merges with its stored entity, and entities not in `rows` stay. - Don't loop `controller.set(Entity, args, row)` per row: each call is a separate store update. - Don't add an endpoint or call `setResponse()` just to batch: it caches a response nothing reads. diff --git a/.agents/skills/data-client-manager/evals/evals.json b/.agents/skills/data-client-manager/evals/evals.json index d082a2cb2866..712d78990019 100644 --- a/.agents/skills/data-client-manager/evals/evals.json +++ b/.agents/skills/data-client-manager/evals/evals.json @@ -33,7 +33,7 @@ "id": 3, "name": "setresponse-suggestion", "prompt": "Reviewing a PR: to batch ticker writes, a teammate added `const pushTickers = new Endpoint(async (rows: Ticker[]) => rows, { schema: [Ticker], key: () => 'pushTickers' })` and flushes with `controller.setResponse(pushTickers, rows, rows)`. The comment says `controller.set([Ticker], rows)` doesn't typecheck. Is this the right approach? If not, rewrite the flush in src/managers/TickerManager.ts. We're on the latest @data-client/react.", - "expected_output": "Rejects the push-only endpoint and setResponse as unnecessary for batching (it caches an endpoint response nothing reads) and rewrites the flush as one controller.set([Ticker], rows) call with no cast, noting that entities merge and entities not in rows stay.", + "expected_output": "Rejects the push-only endpoint and setResponse as unnecessary for batching (it caches an endpoint response nothing reads) and rewrites the flush as one controller.set([Ticker], rows) call with no cast.", "files": [], "assertions": [ "The response says the push-only endpoint plus `setResponse()` is not the right way to batch entity writes.", diff --git a/.agents/skills/data-client-react/SKILL.md b/.agents/skills/data-client-react/SKILL.md index b6a13f997930..1a60def0768b 100644 --- a/.agents/skills/data-client-react/SKILL.md +++ b/.agents/skills/data-client-react/SKILL.md @@ -92,8 +92,10 @@ Its props are `fallback`, `errorComponent`, and `errorClassName` and `listen`. I ctrl.fetch(), ctrl.fetchIfStale(), ctrl.expireAll(), ctrl.invalidate(), ctrl.invalidateAll(), ctrl.setResponse(), ctrl.set(), ctrl.setError(), ctrl.resetEntireStore(), ctrl.subscribe(), ctrl.unsubscribe(). -To write many entities without a fetch, call `ctrl.set([Entity], rows)` once: one normalize, each row merges with its stored entity, and entities not in `rows` stay. -Don't loop `ctrl.set(Entity, args, row)` per row (one store update each), and don't add an endpoint or `setResponse()` just to batch (it caches a response nothing reads). +To write many entities without a fetch, call `ctrl.set([Entity], rows)` once. It normalizes once; each row merges with its stored entity, and entities not in `rows` stay. + +- Don't loop `ctrl.set(Entity, args, row)` per row: each call is a separate store update. +- Don't add an endpoint or call `setResponse()` just to batch: it caches a response nothing reads. ## Programmatic queries diff --git a/.changeset/controller-set-array.md b/.changeset/controller-set-array.md index d22a282f2c2b..52d47bcb00e1 100644 --- a/.changeset/controller-set-array.md +++ b/.changeset/controller-set-array.md @@ -6,7 +6,7 @@ Fix `controller.set()` types for Array schemas -`controller.set([Entity], rows)` and `controller.set(new schema.Array(Entity), rows)` now typecheck. This writes every row in one normalize: entities merge, and entities not in `rows` stay in the store. +`controller.set([Entity], rows)` and `controller.set(new schema.Array(Entity), rows)` now typecheck. This writes every row in one store update: each row merges with its stored entity, and entities not in `rows` stay. ```ts ctrl.set( diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index 921891b516e8..8d4500616cfb 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -365,7 +365,7 @@ function UserName() { ### set(queryable, ...args, value) {#set} -Updates any [Queryable](/rest/api/schema#queryable) [Schema](/rest/api/schema#schema-overview). +Updates any [Queryable](/rest/api/schema#queryable) [Schema](/rest/api/schema#schema-overview), or many entities at once with an [Array](/rest/api/Array) schema. ```ts ctrl.set( @@ -384,8 +384,7 @@ const id = '2'; ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 })); ``` -Array schemas set many entities in one normalize. Each entity merges with what is stored; entities not in the list are left alone. -Arrays take no args and no updater function. +An [Array](/rest/api/Array) schema updates many entities in one store update. Each row merges with its stored entity, and entities not in the list are untouched. Arrays take no args and no updater function. ```ts ctrl.set( From 2ccccf5ff5a171ca192c21a2d2bd81b7fcc58c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:23:23 +0000 Subject: [PATCH 06/12] docs: Document batch set() for streams; batch coin-app ticker writes - managers.md: add "Batching high-frequency updates" with a buffered set([Entity], rows) flush; fix stream example's controller reference - Controller.md: anchor the Array form, note pk()/process() get no args - Array.md: show ctrl.set([User], rows) - DevTools predicate examples also skip batched [Ticker] writes - coin-app StreamManager buffers ticker messages and flushes them with one set([Ticker], rows) per 50ms; Ticker.process tolerates empty args - skills: fold batch-write guidance into one line each; drop evals Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8 --- .agents/skills/data-client-manager/SKILL.md | 8 +--- .../data-client-manager/evals/evals.json | 47 ------------------- .agents/skills/data-client-react/SKILL.md | 5 +- .../skills/data-client-react/evals/evals.json | 33 ------------- docs/core/api/Controller.md | 9 +++- docs/core/api/DevToolsManager.md | 4 +- docs/core/concepts/managers.md | 47 ++++++++++++++++++- docs/rest/api/Array.md | 15 ++++++ examples/coin-app/src/getManagers.ts | 3 +- .../coin-app/src/resources/StreamManager.ts | 19 +++++++- examples/coin-app/src/resources/Ticker.ts | 2 +- 11 files changed, 95 insertions(+), 97 deletions(-) delete mode 100644 .agents/skills/data-client-manager/evals/evals.json delete mode 100644 .agents/skills/data-client-react/evals/evals.json diff --git a/.agents/skills/data-client-manager/SKILL.md b/.agents/skills/data-client-manager/SKILL.md index 094e51baf8bd..1ced88e474d5 100644 --- a/.agents/skills/data-client-manager/SKILL.md +++ b/.agents/skills/data-client-manager/SKILL.md @@ -27,6 +27,7 @@ Minimal working examples for each use case live in [references/managers.md](refe | Cross-tab sync | "Cross-tab synchronization" in [managers.md](references/managers.md) | BroadcastChannel + `controller.expireAll()` | | Offline persistence (IndexedDB) | "Offline persistence" in [managers.md](references/managers.md) | debounced IndexedDB write of `controller.getState()` (drop `optimistic` - not cloneable); restore via DataProvider `initialState`. Never use localStorage (blocking) | | Websocket/SSE push streams | "Middleware data stream" in [managers.md](references/managers.md) | `controller.set()` on message; connect in `init()`, close in `cleanup()` | +| High-frequency streams / snapshots | "Batching high-frequency updates" in [managers.md](references/managers.md) | buffer, then one `controller.set([Entity], rows)` per flush | | Polling/interval updates (ticker) | "Dispatching Actions" in [Manager.md](references/Manager.md); `TimeManager` below | `setInterval` + `controller.set()` | | Custom transport subscriptions | "Reading and Consuming Actions" in [Manager.md](references/Manager.md) | consume `SUBSCRIBE`/`UNSUBSCRIBE` without calling `next` | | Auth: logout on 401, reset store on deauth | [LogoutManager.md](references/LogoutManager.md) | `handleLogout(controller)` + `controller.resetEntireStore()` | @@ -71,12 +72,7 @@ export default class TimeManager implements Manager { } ``` -### Batch writes - -To write many entities at once (a websocket snapshot, buffered stream messages), call `controller.set([Entity], rows)` once. It normalizes once; each row merges with its stored entity, and entities not in `rows` stay. - -- Don't loop `controller.set(Entity, args, row)` per row: each call is a separate store update. -- Don't add an endpoint or call `setResponse()` just to batch: it caches a response nothing reads. +Write many entities in one store update with `controller.set([Entity], rows)` (websocket snapshots, buffered stream messages). Never loop `controller.set(Entity, args, row)` per row, and never add an endpoint or `setResponse()` just to batch. ## Reading and Consuming Actions diff --git a/.agents/skills/data-client-manager/evals/evals.json b/.agents/skills/data-client-manager/evals/evals.json deleted file mode 100644 index 712d78990019..000000000000 --- a/.agents/skills/data-client-manager/evals/evals.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "skill_name": "data-client-manager", - "evals": [ - { - "id": 1, - "name": "websocket-snapshot", - "prompt": "Write src/managers/TickerManager.ts for our exchange websocket (wss://feed.example.com). On connect it sends `{ type: 'snapshot', tickers: [...] }` with ~300 tickers, then `{ type: 'ticker', product_id, price, volume }` one at a time. Get both into the Data Client store so `useQuery(Ticker, { product_id })` updates. Ticker is in src/resources/Ticker.ts:\n\n```ts\nimport { Entity } from '@data-client/rest';\nexport class Ticker extends Entity {\n product_id = '';\n price = 0;\n volume = 0;\n pk() { return this.product_id; }\n static key = 'Ticker';\n}\n```", - "expected_output": "A Manager that opens the socket in init() or middleware, closes it in cleanup(), writes the snapshot with one controller.set([Ticker], msg.tickers) call, and writes single ticker messages with controller.set(Ticker, { product_id }, msg).", - "files": [], - "assertions": [ - "The snapshot is written with a single `controller.set([Ticker], tickers)` or `controller.set(new schema.Array(Ticker), tickers)` call that passes the rows and no args.", - "The snapshot rows are not written by calling `controller.set(Ticker, …)` once per row (no loop, forEach, or map over rows that calls set).", - "No endpoint is defined and `setResponse()` is not called to write the snapshot.", - "No `as any` or other cast is used to make the `controller.set` call typecheck.", - "The socket is closed in `cleanup()`." - ] - }, - { - "id": 2, - "name": "buffered-flush", - "prompt": "our ticker feed pushes ~500 msgs/sec and calling controller.set on every message is tanking perf. can you change the manager so it buffers messages and flushes to the store every 100ms? each message is `{ product_id, price, volume }` and the entity is `Ticker` (pk is product_id) from src/resources/Ticker.ts. current code:\n\n```ts\nimport type { Manager, Middleware } from '@data-client/react';\nimport { Ticker } from '../resources/Ticker';\n\nexport default class TickerManager implements Manager {\n declare protected ws: WebSocket;\n middleware: Middleware = controller => {\n this.ws = new WebSocket('wss://feed.example.com');\n this.ws.onmessage = e => {\n const msg = JSON.parse(e.data);\n controller.set(Ticker, { product_id: msg.product_id }, msg);\n };\n return next => async action => next(action);\n };\n cleanup() { this.ws.close(); }\n}\n```", - "expected_output": "The manager buffers messages (optionally keeping only the latest per product_id), and on each 100ms flush writes the whole buffer with one controller.set([Ticker], rows) call, then clears the buffer. The interval is cleared in cleanup().", - "files": [], - "assertions": [ - "Each flush writes the buffer with a single `controller.set([Ticker], rows)` or `controller.set(new schema.Array(Ticker), rows)` call that passes the rows and no args.", - "The flush does not call `controller.set(Ticker, …)` once per buffered row (no loop, forEach, or map over rows that calls set).", - "No endpoint is defined and `setResponse()` is not called to batch the writes.", - "No `as any` or other cast is used to make the `controller.set` call typecheck.", - "The flush interval or timer is cleared in `cleanup()`." - ] - }, - { - "id": 3, - "name": "setresponse-suggestion", - "prompt": "Reviewing a PR: to batch ticker writes, a teammate added `const pushTickers = new Endpoint(async (rows: Ticker[]) => rows, { schema: [Ticker], key: () => 'pushTickers' })` and flushes with `controller.setResponse(pushTickers, rows, rows)`. The comment says `controller.set([Ticker], rows)` doesn't typecheck. Is this the right approach? If not, rewrite the flush in src/managers/TickerManager.ts. We're on the latest @data-client/react.", - "expected_output": "Rejects the push-only endpoint and setResponse as unnecessary for batching (it caches an endpoint response nothing reads) and rewrites the flush as one controller.set([Ticker], rows) call with no cast.", - "files": [], - "assertions": [ - "The response says the push-only endpoint plus `setResponse()` is not the right way to batch entity writes.", - "The rewritten flush uses a single `controller.set([Ticker], rows)` or `controller.set(new schema.Array(Ticker), rows)` call that passes the rows and no args.", - "The rewrite keeps no push-only endpoint and no `setResponse()` call for the batch.", - "The rewrite does not call `controller.set(Ticker, …)` once per row.", - "No `as any` or other cast is used to make the `controller.set` call typecheck." - ] - } - ] -} diff --git a/.agents/skills/data-client-react/SKILL.md b/.agents/skills/data-client-react/SKILL.md index 1a60def0768b..e57528c61256 100644 --- a/.agents/skills/data-client-react/SKILL.md +++ b/.agents/skills/data-client-react/SKILL.md @@ -92,10 +92,7 @@ Its props are `fallback`, `errorComponent`, and `errorClassName` and `listen`. I ctrl.fetch(), ctrl.fetchIfStale(), ctrl.expireAll(), ctrl.invalidate(), ctrl.invalidateAll(), ctrl.setResponse(), ctrl.set(), ctrl.setError(), ctrl.resetEntireStore(), ctrl.subscribe(), ctrl.unsubscribe(). -To write many entities without a fetch, call `ctrl.set([Entity], rows)` once. It normalizes once; each row merges with its stored entity, and entities not in `rows` stay. - -- Don't loop `ctrl.set(Entity, args, row)` per row: each call is a separate store update. -- Don't add an endpoint or call `setResponse()` just to batch: it caches a response nothing reads. +Write many entities without a fetch with one `ctrl.set([Entity], rows)`. Never loop `ctrl.set(Entity, args, row)` per row, and never add an endpoint or `setResponse()` just to batch. ## Programmatic queries diff --git a/.agents/skills/data-client-react/evals/evals.json b/.agents/skills/data-client-react/evals/evals.json deleted file mode 100644 index b3560fe0dc0e..000000000000 --- a/.agents/skills/data-client-react/evals/evals.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "skill_name": "data-client-react", - "evals": [ - { - "id": 1, - "name": "csv-import", - "prompt": "Add an `ImportProducts` component (src/components/ImportProducts.tsx) with a file input. When the user picks a CSV, parse it with `parseProductsCsv(file): Promise<{ id: string; name: string; price: number }[]>` from src/lib/csv.ts and put every row into the Data Client store, so `ProductRow` (which calls `useQuery(Product, { id })`) shows them. There is no server call for this. Product is in src/resources/Product.ts and extends Entity with `id`, `name`, `price`.", - "expected_output": "A component that uses useController() and, after parsing, writes all rows with one ctrl.set([Product], rows) call, with no fetch, endpoint, or setResponse.", - "files": [], - "assertions": [ - "All parsed rows are written with a single `ctrl.set([Product], rows)` or `ctrl.set(new schema.Array(Product), rows)` call that passes the rows and no args.", - "The rows are not written by calling `ctrl.set(Product, …)` once per row (no loop, forEach, or map over rows that calls set).", - "No endpoint is defined and `setResponse()` is not called to write the rows.", - "No `as any` or other cast is used to make the `ctrl.set` call typecheck.", - "The controller comes from `useController()`." - ] - }, - { - "id": 2, - "name": "worker-results", - "prompt": "Our pricing Web Worker posts back `{ type: 'prices', rows: Array<{ id: string; price: number }> }` a few thousand rows at a time. In src/components/PricingWorkerBridge.tsx, listen to the worker in an effect and write the rows into the Data Client store so every `useQuery(Product, { id })` gets the new price. Product (src/resources/Product.ts) is an Entity with id, name, price; rows only have id and price, so name must survive. Keep it fast.", - "expected_output": "An effect that subscribes to worker messages, writes each message's rows with one ctrl.set([Product], rows) call so price merges into stored Products while name is kept, and removes the listener on cleanup.", - "files": [], - "assertions": [ - "Each worker message's rows are written with a single `ctrl.set([Product], rows)` or `ctrl.set(new schema.Array(Product), rows)` call that passes the rows and no args.", - "The rows are not written by calling `ctrl.set(Product, …)` once per row (no loop, forEach, or map over rows that calls set).", - "No endpoint is defined and `setResponse()` is not called to batch the writes.", - "No `as any` or other cast is used to make the `ctrl.set` call typecheck.", - "The worker listener is removed in the effect cleanup." - ] - } - ] -} diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index 8d4500616cfb..0770f426dd2a 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -384,7 +384,10 @@ const id = '2'; ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 })); ``` -An [Array](/rest/api/Array) schema updates many entities in one store update. Each row merges with its stored entity, and entities not in the list are untouched. Arrays take no args and no updater function. +#### Many entities at once {#set-array} + +Pass an [Array](/rest/api/Array) schema (`[Todo]` or `new schema.Array(Todo)`) and a list of rows to update +many entities in one store update. Each row merges with its stored entity; entities not in the list are untouched. ```ts ctrl.set( @@ -396,6 +399,10 @@ ctrl.set( ); ``` +Array schemas take no `args` (so [Entity.pk()](/rest/api/Entity#pk) and [Entity.process()](/rest/api/Entity#process) +receive `[]`) and no updater function. Use this instead of calling `set()` once per row, such as when +[batching high-frequency stream updates](../concepts/managers.md#batching). + ### setResponse(endpoint, ...args, response) {#setResponse} Stores `response` in cache for given [Endpoint](/rest/api/Endpoint) and args. diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 921615825e0c..047dfef8bb16 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -99,9 +99,11 @@ const managers = getDefaultManagers({ // Increase latency buffer for high-frequency updates latency: 1000, // Skip WebSocket SET actions for Ticker to reduce log spam + // (including batched set([Ticker], rows) writes) // highlight-start predicate: (state, action) => - action.type !== actionTypes.SET || action.schema !== Ticker, + action.type !== actionTypes.SET || + (action.schema !== Ticker && action.schema[0] !== Ticker), // highlight-end }, }); diff --git a/docs/core/concepts/managers.md b/docs/core/concepts/managers.md index d2f0049d9308..b4e60fd1f4f8 100644 --- a/docs/core/concepts/managers.md +++ b/docs/core/concepts/managers.md @@ -304,7 +304,7 @@ export default class StreamManager implements Manager { try { const msg = JSON.parse(event.data); if (msg.type in this.entities) - controller.set(this.entities[msg.type], ...msg.args, msg.data); + this.controller.set(this.entities[msg.type], ...msg.args, msg.data); } catch (e) { console.error('Failed to handle message'); console.error(e); @@ -326,6 +326,47 @@ export default class StreamManager implements Manager { [Controller.set()](../api/Controller.md#set) allows directly updating [Querable Schemas](/rest/api/schema#queryable) directly with `event.data`. +#### Batching high-frequency updates {#batching} + +Streams like exchange tickers can send hundreds of messages per second, and connections often start with a large snapshot. +Rather than calling `set()` per message, buffer them and write each batch with an [Array](/rest/api/Array) schema. +[Controller.set([Entity], rows)](../api/Controller.md#set-array) normalizes every row in one store update. + +```typescript +export default class StreamManager implements Manager { + // ... + protected buffer: Record = {}; + declare protected flushTimeout?: ReturnType; + + connect() { + this.evtSource = this.createEventSource(); + this.evtSource.onmessage = event => { + const msg = JSON.parse(event.data); + if (msg.type in this.entities) { + (this.buffer[msg.type] ??= []).push(msg.data); + this.flushTimeout ??= setTimeout(this.flush, 50); + } + }; + } + + // highlight-start + flush = () => { + const buffer = this.buffer; + this.buffer = {}; + this.flushTimeout = undefined; + for (const type in buffer) { + this.controller.set([this.entities[type]], buffer[type]); + } + }; + // highlight-end + + cleanup() { + this.evtSource?.close(); + clearTimeout(this.flushTimeout); + } +} +``` + #### Skipping DevTools for high-frequency updates When using WebSockets or other real-time data sources, you may want to skip logging @@ -348,8 +389,10 @@ export default function getManagers() { // Increase latency buffer for high-frequency updates latency: 1000, // Skip WebSocket SET actions to avoid log spam + // (batched writes use the [Ticker] schema) predicate: (state, action) => - action.type !== actionTypes.SET || action.schema !== Ticker, + action.type !== actionTypes.SET || + (action.schema !== Ticker && action.schema[0] !== Ticker), }, }), ]; diff --git a/docs/rest/api/Array.md b/docs/rest/api/Array.md index bfeb37ef9eb3..f45efc986d9c 100644 --- a/docs/rest/api/Array.md +++ b/docs/rest/api/Array.md @@ -81,6 +81,21 @@ render(); +### Updating many entities + +Use an Array with [Controller.set()](/docs/api/Controller#set-array) to write many entities in one store update, +without an endpoint. + +```ts +ctrl.set( + [User], + [ + { id: '123', name: 'Jim' }, + { id: '456', name: 'Jane' }, + ], +); +``` + ### Polymorphic types If your input data is an array of more than one type of entity, it is necessary to define a schema mapping. diff --git a/examples/coin-app/src/getManagers.ts b/examples/coin-app/src/getManagers.ts index 5dea416b2c28..2614faac1210 100644 --- a/examples/coin-app/src/getManagers.ts +++ b/examples/coin-app/src/getManagers.ts @@ -13,8 +13,9 @@ export default function getManagers() { // double latency to help with high frequency updates latency: 1000, // skip websocket updates as these are too spammy + // StreamManager writes them in batches with set([Ticker], rows) predicate: (state, action) => - action.type !== actionTypes.SET || action.schema !== Ticker, + action.type !== actionTypes.SET || action.schema[0] !== Ticker, }, }), ]; diff --git a/examples/coin-app/src/resources/StreamManager.ts b/examples/coin-app/src/resources/StreamManager.ts index 06b8a92a43da..10d537060b6b 100644 --- a/examples/coin-app/src/resources/StreamManager.ts +++ b/examples/coin-app/src/resources/StreamManager.ts @@ -14,6 +14,9 @@ export default class StreamManager implements Manager { []; protected product_ids: string[] = []; + /** Messages waiting to be written, grouped by entity type */ + protected buffer: Record = {}; + declare protected flushTimeout?: ReturnType; private attempts = 0; declare protected controller: Controller; @@ -128,16 +131,29 @@ export default class StreamManager implements Manager { } /** Every websocket message is sent here + * + * Messages are buffered so bursts become a single store update * * @param controller * @param msg JSON parsed message */ handleMessage(ctrl: Controller, msg: any) { if (msg.type in this.entities) { - ctrl.set(this.entities[msg.type], msg, msg); + (this.buffer[msg.type] ??= []).push(msg); + this.flushTimeout ??= setTimeout(this.flush, 50); } } + /** Writes all buffered messages; one `set()` per entity type */ + flush = () => { + const buffer = this.buffer; + this.buffer = {}; + this.flushTimeout = undefined; + for (const type in buffer) { + this.controller.set([this.entities[type]], buffer[type]); + } + }; + init() { this.connect(); this.evtSource.addEventListener('open', event => { @@ -164,6 +180,7 @@ export default class StreamManager implements Manager { // remove our event handler that attempts reconnection this.evtSource.onclose = null; this.evtSource.close(); + clearTimeout(this.flushTimeout); } getMiddleware() { diff --git a/examples/coin-app/src/resources/Ticker.ts b/examples/coin-app/src/resources/Ticker.ts index 81567daeb960..987491c7ab1c 100644 --- a/examples/coin-app/src/resources/Ticker.ts +++ b/examples/coin-app/src/resources/Ticker.ts @@ -45,7 +45,7 @@ export class Ticker extends Entity { ): any { const value = { ...input }; // sometimes product_id is not included in the API response - if (args[0].product_id) { + if (args[0]?.product_id) { value.product_id = args[0].product_id; } // fallback to current price to show no gain if we don't have 24 hour From e38cafe2e7bb18f8edcb87942a6b926e7df9bf40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:53:53 +0000 Subject: [PATCH 07/12] fix(coin-app): Reset stream buffer state on cleanup so re-init flushes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8 --- docs/core/concepts/managers.md | 2 ++ examples/coin-app/src/resources/StreamManager.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/core/concepts/managers.md b/docs/core/concepts/managers.md index b4e60fd1f4f8..5162a63a26a6 100644 --- a/docs/core/concepts/managers.md +++ b/docs/core/concepts/managers.md @@ -363,6 +363,8 @@ export default class StreamManager implements Manager { cleanup() { this.evtSource?.close(); clearTimeout(this.flushTimeout); + this.flushTimeout = undefined; + this.buffer = {}; } } ``` diff --git a/examples/coin-app/src/resources/StreamManager.ts b/examples/coin-app/src/resources/StreamManager.ts index 10d537060b6b..851733fbcd0d 100644 --- a/examples/coin-app/src/resources/StreamManager.ts +++ b/examples/coin-app/src/resources/StreamManager.ts @@ -181,6 +181,8 @@ export default class StreamManager implements Manager { this.evtSource.onclose = null; this.evtSource.close(); clearTimeout(this.flushTimeout); + this.flushTimeout = undefined; + this.buffer = {}; } getMiddleware() { From c2311cf909e7cae6a6bb21f27e15507a83fb9d60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:56:58 +0000 Subject: [PATCH 08/12] docs(blog): Start draft v0.19 release post with batch Controller.set() Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8 --- website/blog/2026-10-03-v0.19-batch-set.md | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 website/blog/2026-10-03-v0.19-batch-set.md diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md new file mode 100644 index 000000000000..43ebc7116b35 --- /dev/null +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -0,0 +1,53 @@ +--- +title: 'v0.19: Batch Controller.set()' +description: Write many entities in one store update with Controller.set([Entity], rows) +authors: [ntucker] +tags: [releases, managers, schema] +draft: true +--- + +import StackBlitz from '@site/src/components/StackBlitz'; + +**New APIs:** + +- [Controller.set() with Array schemas](/blog/2026/10/03/v0.19-batch-set#batch-set) - Write many entities in one store update with `ctrl.set([Entity], rows)` + +**Other Improvements:** + +- Fix TypeScript 7 module resolution for package `exports`; imports now resolve to declaration files ([#4019](https://github.com/reactive/data-client/pull/4019)) +- [renderDataHook()](/docs/api/renderDataHook) runs provider mount effects when the first render suspends ([#4099](https://github.com/reactive/data-client/pull/4099)) + +{/* truncate */} + +## Batch Controller.set() {#batch-set} + +[Controller.set()](/docs/api/Controller#set-array) now accepts an [Array](/rest/api/Array) schema, so a list of rows +is normalized in one store update. Each row merges with its stored entity, and entities not in the list are untouched. + +```ts +ctrl.set( + [Ticker], + [ + { product_id: 'BTC-USD', price: 100 }, + { product_id: 'ETH-USD', price: 10 }, + ], +); +``` + +This works at runtime before v0.19, but only typechecked for single entities, which pushed code toward a `set()` call +per row or a push-only endpoint with `setResponse()`. Array schemas take no `args` and no updater function. +[#4103](https://github.com/reactive/data-client/pull/4103) + +This is most useful in [Managers](/docs/concepts/managers) that receive high-frequency streams or large snapshots. +Buffer incoming messages and flush each batch with one `set()`, as described in +[Batching high-frequency updates](/docs/concepts/managers#batching). The coin app's `StreamManager` now +flushes Coinbase ticker messages this way. + + + +:::tip + +If you filter [DevToolsManager](/docs/api/DevToolsManager) actions by schema, batched writes use the Array +schema (`action.schema[0]`), not the Entity itself. + +::: From aafee4d78dafce77cc41bdb0b42385492521df78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:19:07 +0000 Subject: [PATCH 09/12] docs: Show before/after for batch set() in changeset and blog Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8 --- .changeset/controller-set-array.md | 14 +++++++------- website/blog/2026-10-03-v0.19-batch-set.md | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.changeset/controller-set-array.md b/.changeset/controller-set-array.md index 52d47bcb00e1..b602974a0099 100644 --- a/.changeset/controller-set-array.md +++ b/.changeset/controller-set-array.md @@ -9,11 +9,11 @@ Fix `controller.set()` types for Array schemas `controller.set([Entity], rows)` and `controller.set(new schema.Array(Entity), rows)` now typecheck. This writes every row in one store update: each row merges with its stored entity, and entities not in `rows` stay. ```ts -ctrl.set( - [Ticker], - [ - { product_id: 'BTC-USD', price: 100 }, - { product_id: 'ETH-USD', price: 10 }, - ], -); +// Before: TypeScript error on [Ticker], so batches became one set() per row +for (const row of rows) { + ctrl.set(Ticker, { product_id: row.product_id }, row); +} + +// After: one store update +ctrl.set([Ticker], rows); ``` diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 43ebc7116b35..638a35f42f17 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -25,13 +25,13 @@ import StackBlitz from '@site/src/components/StackBlitz'; is normalized in one store update. Each row merges with its stored entity, and entities not in the list are untouched. ```ts -ctrl.set( - [Ticker], - [ - { product_id: 'BTC-USD', price: 100 }, - { product_id: 'ETH-USD', price: 10 }, - ], -); +// Before: TypeScript error on [Ticker], so batches became one set() per row +for (const row of rows) { + ctrl.set(Ticker, { product_id: row.product_id }, row); +} + +// After: one store update +ctrl.set([Ticker], rows); ``` This works at runtime before v0.19, but only typechecked for single entities, which pushed code toward a `set()` call From 8ea8339d53ef2603a6682761b6edd38ab7bdd266 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:24:34 +0000 Subject: [PATCH 10/12] bench: Compare per-row set() to batch set([Entity], rows); chart in v0.19 post Adds core suite benchmarks 'setMany {50,500}x one-per-row' and 'setMany {50,500} batch' writing into a 500-entity store. Locally the batch is ~20x faster for 50 rows and ~95x for 500 rows. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8 --- .cursor/rules/benchmarking.mdc | 1 + examples/benchmark/core.js | 44 ++++++++++++++++++++++ website/blog/2026-10-03-v0.19-batch-set.md | 31 ++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 4607cfe4a191..430f90bad758 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -229,6 +229,7 @@ Use this mapping when deciding which suite(s) to run for a change: - **Recommended filters**: - Changes to `setResponse()` or reducer updates: `^set` - Changes to `getResponse()` or cache retrieval: `^get` + - Changes to `Controller.set()` or batch writes: `setMany` (one `set()` per row vs one `set([Entity], rows)`, into a 500-entity store) - **`spread`** (`@examples/benchmark/spread.js`, scenarios shared with `memory.js` via `@examples/benchmark/spread-scenarios.js`) - **Primary focus**: degenerate cases where spread-operation cost scales with **store size** rather than payload size — single-entity `setResponse` into 1k/10k/100k entity stores (per-type entity map clone in `NormalizeDelegate`), writes with 10k cached endpoint keys (`endpoints`/`meta` spreads in `setResponseReducer`), collection push onto 10k items (`pushMerge`), and `invalidateAll`/`expireAll` over 10k endpoints. diff --git a/examples/benchmark/core.js b/examples/benchmark/core.js index 7edd80d153c4..04b24c78291b 100644 --- a/examples/benchmark/core.js +++ b/examples/benchmark/core.js @@ -103,6 +103,36 @@ export default function addReducerSuite(suite, filter) { githubReducer(githubState, action); }; + // many entities written without an endpoint (e.g. a stream flush) + class Ticker extends Entity { + product_id = ''; + price = 0; + volume = 0; + + pk() { + return this.product_id; + } + + static key = 'Ticker'; + } + const tickerRows = Array.from({ length: 500 }, (_, i) => ({ + product_id: `T${i}-USD`, + price: i, + volume: i * 10, + })); + const tickerUpdates = tickerRows.map(({ product_id, price }) => ({ + product_id, + price: price + 1, + })); + const tickerCtrl = new Controller({}); + const tickerReducer = createReducer(tickerCtrl); + let tickerState = state; + tickerCtrl.dispatch = action => { + tickerState = tickerReducer(tickerState, action); + }; + tickerCtrl.set([Ticker], tickerRows); + const populatedTickerState = tickerState; + const add = createAdd(suite, filter); add('getResponse', () => { @@ -160,6 +190,20 @@ export default function addReducerSuite(suite, filter) { controller.setResponse(getUser, 'gnoff', userData); } }); + // store holds 500 tickers; each flush updates `count` of them + for (const count of [50, 500]) { + const updates = tickerUpdates.slice(0, count); + add(`setMany ${count}x one-per-row`, () => { + tickerState = populatedTickerState; + for (const row of updates) { + tickerCtrl.set(Ticker, row, row); + } + }); + add(`setMany ${count} batch`, () => { + tickerState = populatedTickerState; + return tickerCtrl.set([Ticker], updates); + }); + } return suite.on('complete', function () { if (process.env.SHOW_OPTIMIZATION) { diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 638a35f42f17..45926e162888 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -10,7 +10,7 @@ import StackBlitz from '@site/src/components/StackBlitz'; **New APIs:** -- [Controller.set() with Array schemas](/blog/2026/10/03/v0.19-batch-set#batch-set) - Write many entities in one store update with `ctrl.set([Entity], rows)` +- [Controller.set() with Array schemas](/blog/2026/10/03/v0.19-batch-set#batch-set) - Write many entities in one store update with `ctrl.set([Entity], rows)`, [up to 95x faster](/blog/2026/10/03/v0.19-batch-set#batch-set-performance) than one `set()` per row **Other Improvements:** @@ -38,6 +38,35 @@ This works at runtime before v0.19, but only typechecked for single entities, wh per row or a push-only endpoint with `setResponse()`. Array schemas take no `args` and no updater function. [#4103](https://github.com/reactive/data-client/pull/4103) +### Performance {#batch-set-performance} + +Each `set()` is a separate store update, and every store update copies that entity type's table. Writing rows one +at a time repeats that copy for every row, so the cost grows with both the batch size and the store size. A batch +pays it once: **20x** faster for 50 rows and **95x** faster for 500 rows, into a store holding 500 entities. +[#4103](https://github.com/reactive/data-client/pull/4103) + +
+ +
+ +```mermaid +xychart-beta + title "Writing rows into a 500-entity store" + x-axis ["50 rows", "500 rows"] + y-axis "Milliseconds (lower is better)" 0 --> 105 + bar [10.8, 103] + bar [0.54, 1.08] +``` + +
+ +One `set()` per row vs one `set([Ticker], rows)`. This measures the store update only; a batch also notifies +subscribers once instead of once per row. + +[Benchmarks over time](https://reactive.github.io/data-client/dev/bench/) | [View benchmark](https://github.com/reactive/data-client/tree/master/examples/benchmark) + +
+ This is most useful in [Managers](/docs/concepts/managers) that receive high-frequency streams or large snapshots. Buffer incoming messages and flush each batch with one `set()`, as described in [Batching high-frequency updates](/docs/concepts/managers#batching). The coin app's `StreamManager` now From 859140f7214cdbdcf9adea3978d9e72e207ed64e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:50:40 +0000 Subject: [PATCH 11/12] docs: Show set([Entity], rows) signature in Controller docs; restore skill evals Addresses staff review: the Controller.set heading only showed the ...args form, and the skill evals belong with this change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8 --- .../data-client-manager/evals/evals.json | 47 +++++++++++++++++++ .../skills/data-client-react/evals/evals.json | 33 +++++++++++++ docs/core/api/Controller.md | 3 +- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/data-client-manager/evals/evals.json create mode 100644 .agents/skills/data-client-react/evals/evals.json diff --git a/.agents/skills/data-client-manager/evals/evals.json b/.agents/skills/data-client-manager/evals/evals.json new file mode 100644 index 000000000000..712d78990019 --- /dev/null +++ b/.agents/skills/data-client-manager/evals/evals.json @@ -0,0 +1,47 @@ +{ + "skill_name": "data-client-manager", + "evals": [ + { + "id": 1, + "name": "websocket-snapshot", + "prompt": "Write src/managers/TickerManager.ts for our exchange websocket (wss://feed.example.com). On connect it sends `{ type: 'snapshot', tickers: [...] }` with ~300 tickers, then `{ type: 'ticker', product_id, price, volume }` one at a time. Get both into the Data Client store so `useQuery(Ticker, { product_id })` updates. Ticker is in src/resources/Ticker.ts:\n\n```ts\nimport { Entity } from '@data-client/rest';\nexport class Ticker extends Entity {\n product_id = '';\n price = 0;\n volume = 0;\n pk() { return this.product_id; }\n static key = 'Ticker';\n}\n```", + "expected_output": "A Manager that opens the socket in init() or middleware, closes it in cleanup(), writes the snapshot with one controller.set([Ticker], msg.tickers) call, and writes single ticker messages with controller.set(Ticker, { product_id }, msg).", + "files": [], + "assertions": [ + "The snapshot is written with a single `controller.set([Ticker], tickers)` or `controller.set(new schema.Array(Ticker), tickers)` call that passes the rows and no args.", + "The snapshot rows are not written by calling `controller.set(Ticker, …)` once per row (no loop, forEach, or map over rows that calls set).", + "No endpoint is defined and `setResponse()` is not called to write the snapshot.", + "No `as any` or other cast is used to make the `controller.set` call typecheck.", + "The socket is closed in `cleanup()`." + ] + }, + { + "id": 2, + "name": "buffered-flush", + "prompt": "our ticker feed pushes ~500 msgs/sec and calling controller.set on every message is tanking perf. can you change the manager so it buffers messages and flushes to the store every 100ms? each message is `{ product_id, price, volume }` and the entity is `Ticker` (pk is product_id) from src/resources/Ticker.ts. current code:\n\n```ts\nimport type { Manager, Middleware } from '@data-client/react';\nimport { Ticker } from '../resources/Ticker';\n\nexport default class TickerManager implements Manager {\n declare protected ws: WebSocket;\n middleware: Middleware = controller => {\n this.ws = new WebSocket('wss://feed.example.com');\n this.ws.onmessage = e => {\n const msg = JSON.parse(e.data);\n controller.set(Ticker, { product_id: msg.product_id }, msg);\n };\n return next => async action => next(action);\n };\n cleanup() { this.ws.close(); }\n}\n```", + "expected_output": "The manager buffers messages (optionally keeping only the latest per product_id), and on each 100ms flush writes the whole buffer with one controller.set([Ticker], rows) call, then clears the buffer. The interval is cleared in cleanup().", + "files": [], + "assertions": [ + "Each flush writes the buffer with a single `controller.set([Ticker], rows)` or `controller.set(new schema.Array(Ticker), rows)` call that passes the rows and no args.", + "The flush does not call `controller.set(Ticker, …)` once per buffered row (no loop, forEach, or map over rows that calls set).", + "No endpoint is defined and `setResponse()` is not called to batch the writes.", + "No `as any` or other cast is used to make the `controller.set` call typecheck.", + "The flush interval or timer is cleared in `cleanup()`." + ] + }, + { + "id": 3, + "name": "setresponse-suggestion", + "prompt": "Reviewing a PR: to batch ticker writes, a teammate added `const pushTickers = new Endpoint(async (rows: Ticker[]) => rows, { schema: [Ticker], key: () => 'pushTickers' })` and flushes with `controller.setResponse(pushTickers, rows, rows)`. The comment says `controller.set([Ticker], rows)` doesn't typecheck. Is this the right approach? If not, rewrite the flush in src/managers/TickerManager.ts. We're on the latest @data-client/react.", + "expected_output": "Rejects the push-only endpoint and setResponse as unnecessary for batching (it caches an endpoint response nothing reads) and rewrites the flush as one controller.set([Ticker], rows) call with no cast.", + "files": [], + "assertions": [ + "The response says the push-only endpoint plus `setResponse()` is not the right way to batch entity writes.", + "The rewritten flush uses a single `controller.set([Ticker], rows)` or `controller.set(new schema.Array(Ticker), rows)` call that passes the rows and no args.", + "The rewrite keeps no push-only endpoint and no `setResponse()` call for the batch.", + "The rewrite does not call `controller.set(Ticker, …)` once per row.", + "No `as any` or other cast is used to make the `controller.set` call typecheck." + ] + } + ] +} diff --git a/.agents/skills/data-client-react/evals/evals.json b/.agents/skills/data-client-react/evals/evals.json new file mode 100644 index 000000000000..b3560fe0dc0e --- /dev/null +++ b/.agents/skills/data-client-react/evals/evals.json @@ -0,0 +1,33 @@ +{ + "skill_name": "data-client-react", + "evals": [ + { + "id": 1, + "name": "csv-import", + "prompt": "Add an `ImportProducts` component (src/components/ImportProducts.tsx) with a file input. When the user picks a CSV, parse it with `parseProductsCsv(file): Promise<{ id: string; name: string; price: number }[]>` from src/lib/csv.ts and put every row into the Data Client store, so `ProductRow` (which calls `useQuery(Product, { id })`) shows them. There is no server call for this. Product is in src/resources/Product.ts and extends Entity with `id`, `name`, `price`.", + "expected_output": "A component that uses useController() and, after parsing, writes all rows with one ctrl.set([Product], rows) call, with no fetch, endpoint, or setResponse.", + "files": [], + "assertions": [ + "All parsed rows are written with a single `ctrl.set([Product], rows)` or `ctrl.set(new schema.Array(Product), rows)` call that passes the rows and no args.", + "The rows are not written by calling `ctrl.set(Product, …)` once per row (no loop, forEach, or map over rows that calls set).", + "No endpoint is defined and `setResponse()` is not called to write the rows.", + "No `as any` or other cast is used to make the `ctrl.set` call typecheck.", + "The controller comes from `useController()`." + ] + }, + { + "id": 2, + "name": "worker-results", + "prompt": "Our pricing Web Worker posts back `{ type: 'prices', rows: Array<{ id: string; price: number }> }` a few thousand rows at a time. In src/components/PricingWorkerBridge.tsx, listen to the worker in an effect and write the rows into the Data Client store so every `useQuery(Product, { id })` gets the new price. Product (src/resources/Product.ts) is an Entity with id, name, price; rows only have id and price, so name must survive. Keep it fast.", + "expected_output": "An effect that subscribes to worker messages, writes each message's rows with one ctrl.set([Product], rows) call so price merges into stored Products while name is kept, and removes the listener on cleanup.", + "files": [], + "assertions": [ + "Each worker message's rows are written with a single `ctrl.set([Product], rows)` or `ctrl.set(new schema.Array(Product), rows)` call that passes the rows and no args.", + "The rows are not written by calling `ctrl.set(Product, …)` once per row (no loop, forEach, or map over rows that calls set).", + "No endpoint is defined and `setResponse()` is not called to batch the writes.", + "No `as any` or other cast is used to make the `ctrl.set` call typecheck.", + "The worker listener is removed in the effect cleanup." + ] + } + ] +} diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index b9da4bfd6af2..ed8db5e3b567 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -34,6 +34,7 @@ class Controller { invalidateAll({ testKey }): Promise; resetEntireStore(): Promise; set(queryable, ...args, value): Promise; + set([Entity], rows): Promise; setResponse(endpoint, ...args, response): Promise; setError(endpoint, ...args, error): Promise; resolve(endpoint, { args, response, fetchedAt, error }): Promise; @@ -384,7 +385,7 @@ const id = '2'; ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 })); ``` -#### Many entities at once {#set-array} +#### set([Entity], rows) {#set-array} Pass an [Array](/rest/api/Array) schema (`[Todo]` or `new schema.Array(Todo)`) and a list of rows to update many entities in one store update. Each row merges with its stored entity; entities not in the list are untouched. From b6ed0bd18191fb6197747a67979e6b3c3b702d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 22:03:24 +0000 Subject: [PATCH 12/12] docs: Note same-pk rows in a batch skip shouldReorder; keep latest ticker per product Coin-app buffers only the latest message per product so batched writes keep Ticker.shouldReorder() ordering. Clarify the DevTools tip applies to [Ticker]. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8 --- docs/core/api/Controller.md | 3 ++- docs/core/concepts/managers.md | 3 +++ examples/coin-app/src/resources/StreamManager.ts | 10 ++++++---- website/blog/2026-10-03-v0.19-batch-set.md | 4 ++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index ed8db5e3b567..1c25d6f95a29 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -401,7 +401,8 @@ ctrl.set( ``` Array schemas take no `args` (so [Entity.pk()](/rest/api/Entity#pk) and [Entity.process()](/rest/api/Entity#process) -receive `[]`) and no updater function. Use this instead of calling `set()` once per row, such as when +receive `[]`) and no updater function. Rows that share a pk merge in list order, without +[Entity.shouldReorder()](/rest/api/Entity#shouldreorder). Use this instead of calling `set()` once per row, such as when [batching high-frequency stream updates](../concepts/managers.md#batching). ### setResponse(endpoint, ...args, response) {#setResponse} diff --git a/docs/core/concepts/managers.md b/docs/core/concepts/managers.md index 63af459c0510..4c13ee7f6b14 100644 --- a/docs/core/concepts/managers.md +++ b/docs/core/concepts/managers.md @@ -369,6 +369,9 @@ export default class StreamManager implements Manager { } ``` +Rows in one batch that share a pk merge in order and skip [Entity.shouldReorder()](/rest/api/Entity#shouldreorder), +so buffer only the latest message per pk when order matters. + #### Skipping DevTools for high-frequency updates When using WebSockets or other real-time data sources, you may want to skip logging diff --git a/examples/coin-app/src/resources/StreamManager.ts b/examples/coin-app/src/resources/StreamManager.ts index 851733fbcd0d..7817d1984db1 100644 --- a/examples/coin-app/src/resources/StreamManager.ts +++ b/examples/coin-app/src/resources/StreamManager.ts @@ -15,7 +15,7 @@ export default class StreamManager implements Manager { protected product_ids: string[] = []; /** Messages waiting to be written, grouped by entity type */ - protected buffer: Record = {}; + protected buffer: Record> = {}; declare protected flushTimeout?: ReturnType; private attempts = 0; declare protected controller: Controller; @@ -132,14 +132,16 @@ export default class StreamManager implements Manager { /** Every websocket message is sent here * - * Messages are buffered so bursts become a single store update + * Messages are buffered so bursts become a single store update. + * Only the latest message per product is kept, since rows in one batch + * skip Ticker.shouldReorder() * * @param controller * @param msg JSON parsed message */ handleMessage(ctrl: Controller, msg: any) { if (msg.type in this.entities) { - (this.buffer[msg.type] ??= []).push(msg); + (this.buffer[msg.type] ??= {})[msg.product_id] = msg; this.flushTimeout ??= setTimeout(this.flush, 50); } } @@ -150,7 +152,7 @@ export default class StreamManager implements Manager { this.buffer = {}; this.flushTimeout = undefined; for (const type in buffer) { - this.controller.set([this.entities[type]], buffer[type]); + this.controller.set([this.entities[type]], Object.values(buffer[type])); } }; diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 45926e162888..2c0f61f114e9 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -76,7 +76,7 @@ flushes Coinbase ticker messages this way. :::tip -If you filter [DevToolsManager](/docs/api/DevToolsManager) actions by schema, batched writes use the Array -schema (`action.schema[0]`), not the Entity itself. +If you filter [DevToolsManager](/docs/api/DevToolsManager) actions by schema, a batched write's `action.schema` +is the Array schema, so match `action.schema[0]` for `[Ticker]` rather than the Entity itself. :::