fix(core): Allow controller.set() with Array schemas - #4103
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: b6ed0bd The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
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 |
|
Size Change: 0 B Total Size: 103 kB ℹ️ View Unchanged
|
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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
|
The This PR doesn't change Generated by Claude Code |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
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
…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
|
Following up on the staff review:
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
|
Adversarial review pass. Each finding was checked against the code before acting. Fixed in b6ed0bd:
Not changed:
Follow-up:
Generated by Claude Code |
|
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):
Rejected:
Generated by Claude Code |

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 oneset()per row or a push-only endpoint withsetResponse().Each row merges with its stored entity; entities not in
rowsare 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):set()per rowset([Ticker], rows)Solution
@data-client/core):Controller.set()gets an overload for Array schemas taking(schema, rows), with noargsand no updater function (an Array has no previous value to read). Runtime behavior is unchanged.set(Entity, [row]).setMany {50,500}x one-per-rowvssetMany {50,500} batchin thecoresuite, so CI tracks it.pk()/process()receive[]argspredicateexamples also skip batched[Ticker]writesStreamManagerbuffers Coinbase ticker messages and flushes them with oneset([Ticker], rows)every 50ms.data-client-manageranddata-client-reactsay to batch withset([Entity], rows)instead of looping or adding an endpoint, with evals for both.Open questions
Follow-up:
ValuesandInvalidateschemas could also support a value-onlyset(), 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)(andnew schema.Array(Entity)) overload so many entities can be written in one store update without looping per row or inventing push-only endpoints withsetResponse(). 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 viaaction.schema[0]. Benchmarks addsetMany(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.