Skip to content

fix(core): Allow controller.set() with Array schemas - #4103

Merged
ntucker merged 14 commits into
masterfrom
cursor/controller-set-array-ed3d
Oct 3, 2026
Merged

ntucker merged 14 commits into
masterfrom
cursor/controller-set-array-ed3d

Conversation

@ntucker

@ntucker ntucker commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Nathaniel · project thread

Motivation

Writing many entities without a fetch (a websocket snapshot, buffered stream messages, a CSV import) had no typed one-call form. controller.set([Entity], rows) already worked at runtime, but TypeScript rejected it, so code fell back to one set() per row or a push-only endpoint with setResponse().

// 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);

Each row merges with its stored entity; entities not in rows are untouched. new schema.Array(Ticker) works the same way.

The batch is much faster because each set() copies the entity type's table once. Writing into a store of 500 entities (examples/benchmark, core setMany, local Node 22):

Rows written One set() per row One set([Ticker], rows) Speedup
50 10.8 ms 0.54 ms ~20x
500 103 ms 1.08 ms ~95x

Solution

  • Types (@data-client/core): Controller.set() gets an overload for Array schemas taking (schema, rows), with no args and no updater function (an Array has no previous value to read). Runtime behavior is unchanged.
  • Tests: runtime coverage for both array forms (merge, add, unlisted untouched), plus type tests rejecting args, updaters, non-array values, and set(Entity, [row]).
  • Benchmark: setMany {50,500}x one-per-row vs setMany {50,500} batch in the core suite, so CI tracks it.
  • Docs:
  • Example: the coin app's StreamManager buffers Coinbase ticker messages and flushes them with one set([Ticker], rows) every 50ms.
  • Blog: starts the draft v0.19 release post with this feature and a benchmark chart.
  • Skills: data-client-manager and data-client-react say to batch with set([Entity], rows) instead of looping or adding an endpoint, with evals for both.

Open questions

Follow-up: Values and Invalidate schemas could also support a value-only set(), but are left out to keep this change narrow.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8


Note

Medium Risk
Touches core Controller.set() typing and the canonical store-write path for streams and imports; runtime logic is unchanged but mistaken batch usage could affect merge semantics or DevTools filtering.

Overview
Adds a typed controller.set([Entity], rows) (and new schema.Array(Entity)) overload so many entities can be written in one store update without looping per row or inventing push-only endpoints with setResponse(). Runtime behavior is unchanged; each row merges with its stored entity and omitted ids are left alone.

Documentation, agent skills/evals, and the coin-app StreamManager now recommend buffering high-frequency websocket traffic and flushing with a single batch set(). DevTools filter examples match batched writes via action.schema[0]. Benchmarks add setMany (per-row vs batch) to track the large speedup from one normalize/copy per flush.

Reviewed by Cursor Bugbot for commit b6ed0bd. Bugbot is set up for automated code reviews on this repo. Configure here.

cursoragent and others added 3 commits September 29, 2026 16:18
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 <me@ntucker.me>
Co-authored-by: Nathaniel Tucker <me@ntucker.me>
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 <me@ntucker.me>
@vercel

vercel Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docs-site Ready Ready Preview Oct 3, 2026 10:05pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b6ed0bd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@data-client/core Patch
@data-client/react Patch
@data-client/vue Patch
example-benchmark Patch
example-benchmark-react Patch
test-bundlesize Patch
coinbase-lite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 103 kB

ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 1.46 kB
examples/test-bundlesize/dist/polyfill.js 307 B
examples/test-bundlesize/dist/rdcClient.js 10.9 kB
examples/test-bundlesize/dist/rdcEndpoint.js 8.07 kB
examples/test-bundlesize/dist/rdcNextjs.js 12.3 kB
examples/test-bundlesize/dist/rdcPipeableStream.js 9.63 kB
examples/test-bundlesize/dist/react.js 59.7 kB
examples/test-bundlesize/dist/webpack-runtime.js 784 B

compressed-size-action

@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.87%. Comparing base (6f24567) to head (b6ed0bd).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #4103   +/-   ##
=======================================
  Coverage   97.87%   97.87%           
=======================================
  Files         156      156           
  Lines        3057     3057           
  Branches      612      612           
=======================================
  Hits         2992     2992           
  Misses         18       18           
  Partials       47       47           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark React

Details
Benchmark suite Current: b6ed0bd Previous: a76996a Ratio
data-client: getlist-100 140.85 ops/s (± 5.1%) 125 ops/s (± 4.8%) 0.89
data-client: getlist-500 44.05 ops/s (± 4.7%) 39.84 ops/s (± 5.5%) 0.90
data-client: update-entity 384.62 ops/s (± 7.1%) 312.5 ops/s (± 7.2%) 0.81
data-client: update-user 370.37 ops/s (± 9.7%) 294.12 ops/s (± 7.8%) 0.79
data-client: getlist-500-sorted 47.17 ops/s (± 8.8%) 43.39 ops/s (± 9.2%) 0.92
data-client: update-entity-sorted 333.33 ops/s (± 9.6%) 263.16 ops/s (± 5.9%) 0.79
data-client: update-entity-multi-view 363.76 ops/s (± 6.2%) 277.78 ops/s (± 7.0%) 0.76
data-client: list-detail-switch-10 9.85 ops/s (± 11.3%) 7.22 ops/s (± 5.2%) 0.73
data-client: update-user-10000 78.43 ops/s (± 14.0%) 71.43 ops/s (± 12.4%) 0.91
data-client: invalidate-and-resolve 39.06 ops/s (± 5.5%) 32.95 ops/s (± 5.6%) 0.84
data-client: unshift-item 222.22 ops/s (± 4.9%) 188.68 ops/s (± 5.9%) 0.85
data-client: delete-item 294.12 ops/s (± 3.2%) 250 ops/s (± 4.4%) 0.85
data-client: move-item 173.93 ops/s (± 9.8%) 158.77 ops/s (± 8.3%) 0.91

This comment was automatically generated by workflow using github-action-benchmark.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Details
Benchmark suite Current: b6ed0bd Previous: 93555f8 Ratio
normalizeLong 431 ops/sec (±4.60%) 447 ops/sec (±4.75%) 1.04
normalizeLong Values 393 ops/sec (±1.83%) 408 ops/sec (±1.51%) 1.04
normalizeLong Scalar 360 ops/sec (±3.39%) 352 ops/sec (±3.69%) 0.98
normalizeLong Scalar update 898 ops/sec (±0.98%) 895 ops/sec (±0.68%) 1.00
denormalizeLong 239 ops/sec (±5.83%) 233 ops/sec (±6.00%) 0.97
denormalizeLong Values 225 ops/sec (±4.46%) 213 ops/sec (±4.94%) 0.95
denormalizeLong donotcache 994 ops/sec (±0.73%) 1002 ops/sec (±0.64%) 1.01
denormalizeLong Values donotcache 749 ops/sec (±0.17%) 737 ops/sec (±0.59%) 0.98
denormalizeLong Scalar donotcache 1032 ops/sec (±0.21%) 1073 ops/sec (±0.13%) 1.04
denormalizeShort donotcache 500x 1388 ops/sec (±0.12%) 1437 ops/sec (±0.29%) 1.04
denormalizeShort 500x 635 ops/sec (±7.18%) 639 ops/sec (±6.97%) 1.01
denormalizeShort 500x withCache 6888 ops/sec (±0.14%) 6834 ops/sec (±5.52%) 0.99
queryShort 500x withCache 3187 ops/sec (±0.14%) 3206 ops/sec (±0.97%) 1.01
buildQueryKey All 59224 ops/sec (±1.18%) 58478 ops/sec (±1.39%) 0.99
query All withCache 6001 ops/sec (±2.12%) 5828 ops/sec (±2.46%) 0.97
denormalizeLong with mixin Entity 217 ops/sec (±7.17%) 209 ops/sec (±7.50%) 0.96
denormalizeLong withCache 7605 ops/sec (±0.21%) 7517 ops/sec (±0.32%) 0.99
denormalizeLong withCache (Scalar churn) 7548 ops/sec (±0.90%) 7491 ops/sec (±0.24%) 0.99
denormalizeLong Values withCache 5104 ops/sec (±1.81%) 5132 ops/sec (±1.60%) 1.01
denormalizeLong Scalar withCache 7602 ops/sec (±0.42%) 7648 ops/sec (±0.98%) 1.01
denormalizeLong Scalar update withCache 4122 ops/sec (±0.23%) 4074 ops/sec (±0.24%) 0.99
denormalizeLong All withCache 6326 ops/sec (±0.21%) 6058 ops/sec (±0.18%) 0.96
denormalizeLong Query-sorted withCache 6229 ops/sec (±1.61%) 6098 ops/sec (±1.48%) 0.98
denormalizeLongAndShort withEntityCacheOnly 1760 ops/sec (±0.89%) 1748 ops/sec (±0.19%) 0.99
denormalize bidirectional 50 4595 ops/sec (±9.99%) 4498 ops/sec (±10.41%) 0.98
denormalize bidirectional 50 donotcache 43648 ops/sec (±0.15%) 42385 ops/sec (±1.43%) 0.97
getResponse 4650 ops/sec (±1.99%) 4418 ops/sec (±4.05%) 0.95
getResponse (null) 9597346 ops/sec (±0.97%) 10236651 ops/sec (±0.70%) 1.07
getResponse (clear cache) 200 ops/sec (±8.52%) 203 ops/sec (±7.07%) 1.01
getSmallResponse 3412 ops/sec (±1.39%) 3543 ops/sec (±0.24%) 1.04
getSmallInferredResponse 2876 ops/sec (±1.06%) 2852 ops/sec (±1.79%) 0.99
getResponse Collection 4279 ops/sec (±4.15%) 4306 ops/sec (±4.05%) 1.01
get Collection 2831 ops/sec (±0.48%) 2707 ops/sec (±0.19%) 0.96
get Query-sorted 5051 ops/sec (±1.53%) 5052 ops/sec (±1.47%) 1.00
setLong 440 ops/sec (±0.19%) 467 ops/sec (±0.60%) 1.06
setLongWithMerge 252 ops/sec (±0.30%) 257 ops/sec (±0.45%) 1.02
setLongWithSimpleMerge 272 ops/sec (±0.19%) 272 ops/sec (±0.80%) 1
setSmallResponse 500x 902 ops/sec (±1.46%) 926 ops/sec (±1.48%) 1.03
setMany 50x one-per-row 152 ops/sec (±0.39%)
setMany 50 batch 3608 ops/sec (±0.48%)
setMany 500x one-per-row 15.27 ops/sec (±0.77%)
setMany 500 batch 1395 ops/sec (±3.30%)

This comment was automatically generated by workflow using github-action-benchmark.

cursoragent and others added 2 commits September 29, 2026 16:31
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 <me@ntucker.me>
Co-authored-by: Nathaniel Tucker <me@ntucker.me>

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: LGTM

Array overload is correctly ordered after the Queryable overloads, scoped to Schema[] / normalize→array + queryKey→undefined, and Entity is kept off via pk?: never (with a type test). Value-only shape matches runtime (createSet + setReducer normalize-once, merge per row, leave unlisted). Leaving createSet Queryable-constrained is the right call. Values omission is fine as a later FOLLOW_UP; docs heading still shows ...args but the Array section is clear. Skills/evals belong with this unlock.

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8

ntucker commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

The typecheck job in Vercel Preview Deployment is failing in website/src/components/HomepageFeatures.tsx, which this PR doesn't touch. The cause is two copies of @types/react in the lockfile: @types/react@npm:* resolves to 19.2.18 and is installed under @docusaurus/theme-common/node_modules, while the website pins 19.2.17. That makes the SVG ref types incompatible.

This PR doesn't change website/, yarn.lock or any package.json, and I get the same error from yarn workspace rdc-website typecheck locally. #4104 fails the same check on an unrelated change. No fix exists yet. A likely fix, for its own PR, is adding "@types/react": "19.2.17" (or bumping both to 19.2.18) to the root resolutions and re-running yarn install so there's only one copy.


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cbff687. Configure here.

Comment thread examples/coin-app/src/resources/StreamManager.ts
claude added 2 commits October 3, 2026 15:53
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8
…0.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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8
claude added 2 commits October 3, 2026 21:48
…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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8

ntucker commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Following up on the staff review:

  • Docs heading still shows ...args: fixed in this PR (859140f). The Array subsection is now titled set([Entity], rows), and the class overview at the top of Controller.md lists that signature.
  • Skills/evals belong with this unlock: fixed in this PR (859140f). An earlier cleanup commit had dropped the two evals.json files, and this restores them unchanged. Their assertions still match the skill wording.
  • Values omission: left for a follow-up. A value-only set() for Values and Invalidate is a separate type change with its own tests and docs. It stays listed under Open questions in the PR description.

Generated by Claude Code

…cker 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QygSDvhjbnZRtLhyLvuBB8

ntucker commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Adversarial review pass. Each finding was checked against the code before acting.

Fixed in b6ed0bd:

  • Rows in one batch that share a pk go through schema.merge(), not mergeWithStore(), so they skip shouldReorder() (NormalizeDelegate.mergeEntity). Coin-app Ticker relies on shouldReorder(), so StreamManager now buffers only the latest message per product_id. Controller.md and managers.md now document this.
  • The blog's DevTools tip now says action.schema[0] applies to the [Ticker] form; new schema.Array(Ticker) has no [0].

Not changed:

  • Changeset stays patch. The changeset skill reserves minor for breaking changes under 1.0, and this overload is additive. The blog stays a v0.19 draft that collects changes until the next minor.

Follow-up:

  • The new overload's Schema[] constraint also accepts [User, Admin], [[User]], and [() => 1]. Rows are typed as readonly {}[], so non-object rows pass too. The real cases ([Entity], schema.Array(Entity)) are typed correctly, and the bad ones fail or no-op at runtime. Restricting the schema to a single Entity element and typing the rows is a separate type-design change.

Generated by Claude Code

@ntucker
ntucker merged commit f343f9d into master Oct 3, 2026
30 checks passed
@ntucker
ntucker deleted the cursor/controller-set-array-ed3d branch October 3, 2026 22:56
@github-actions github-actions Bot mentioned this pull request Oct 3, 2026

ntucker commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

This PR merged, so every remaining review item is now either rejected or moved to a follow-up PR.

Follow-up PRs (being opened separately):

  • Overload types. Restrict the overload to a single-element Array of an Entity and type the rows, which closes the [User, Admin], [[User]] and [() => 1] holes. Also add schema.Values(Entity) support, which staff review deferred.
  • coin-app StreamManager cleanup. cleanup() doesn't cancel a pending reconnect timer. There's also the existing send() ArrayBufferView tsc error and the unused ctrl param.

Rejected:

  • Bump the changeset to minor. The changeset skill reserves minor for breaking changes before 1.0, and this change is additive.
  • Prototype keys in msg.type in this.entities. Message types come from the Coinbase feed, and the code is example-only, so this can't happen in practice.
  • Grader note for skill eval 1. The eval expects a single message to use set(Ticker, args, msg). That matches SKILL.md, which only forbids looping per row.

Generated by Claude Code

This branch was successfully deployed

1 active deployment
Preview — b6ed0bd1 Deployed Oct 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants