feat(endpoint): add Scalar schema for lens-dependent entity fields#3887
feat(endpoint): add Scalar schema for lens-dependent entity fields#3887
Conversation
🦋 Changeset detectedLatest commit: fa9ec23 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Size Change: +400 B (+0.5%) Total Size: 81.1 kB 📦 View Changed
ℹ️ View Unchanged
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3887 +/- ##
==========================================
+ Coverage 98.11% 98.21% +0.09%
==========================================
Files 153 154 +1
Lines 2917 3024 +107
Branches 567 605 +38
==========================================
+ Hits 2862 2970 +108
Misses 11 11
+ Partials 44 43 -1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: fa9ec23 | Previous: 4f5b6b6 | Ratio |
|---|---|---|---|
normalizeLong |
431 ops/sec (±2.81%) |
458 ops/sec (±1.29%) |
1.06 |
normalizeLong Values |
395 ops/sec (±0.22%) |
417 ops/sec (±0.37%) |
1.06 |
normalizeLong Scalar |
341 ops/sec (±0.50%) |
||
normalizeLong Scalar update |
848 ops/sec (±1.21%) |
||
denormalizeLong |
250 ops/sec (±3.89%) |
394 ops/sec (±1.17%) |
1.58 |
denormalizeLong Values |
223 ops/sec (±4.20%) |
339 ops/sec (±0.50%) |
1.52 |
denormalizeLong donotcache |
954 ops/sec (±0.11%) |
1015 ops/sec (±0.10%) |
1.06 |
denormalizeLong Values donotcache |
716 ops/sec (±0.42%) |
758 ops/sec (±0.23%) |
1.06 |
denormalizeLong Scalar donotcache |
901 ops/sec (±0.15%) |
||
denormalizeShort donotcache 500x |
1528 ops/sec (±1.45%) |
1558 ops/sec (±0.14%) |
1.02 |
denormalizeShort 500x |
731 ops/sec (±3.86%) |
1060 ops/sec (±2.16%) |
1.45 |
denormalizeShort 500x withCache |
6870 ops/sec (±0.21%) |
7687 ops/sec (±0.11%) |
1.12 |
queryShort 500x withCache |
2939 ops/sec (±0.28%) |
2999 ops/sec (±0.11%) |
1.02 |
buildQueryKey All |
55929 ops/sec (±0.59%) |
54049 ops/sec (±0.42%) |
0.97 |
query All withCache |
5888 ops/sec (±0.38%) |
6752 ops/sec (±0.32%) |
1.15 |
denormalizeLong with mixin Entity |
236 ops/sec (±3.62%) |
357 ops/sec (±2.60%) |
1.51 |
denormalizeLong withCache |
7184 ops/sec (±0.17%) |
7722 ops/sec (±0.45%) |
1.07 |
denormalizeLong withCache (Scalar churn) |
7157 ops/sec (±0.20%) |
||
denormalizeLong Values withCache |
5089 ops/sec (±0.24%) |
5215 ops/sec (±0.19%) |
1.02 |
denormalizeLong Scalar withCache |
7838 ops/sec (±1.74%) |
||
denormalizeLong Scalar update withCache |
4001 ops/sec (±1.12%) |
||
denormalizeLong All withCache |
5668 ops/sec (±1.37%) |
6457 ops/sec (±0.11%) |
1.14 |
denormalizeLong Query-sorted withCache |
5936 ops/sec (±0.39%) |
6764 ops/sec (±0.21%) |
1.14 |
denormalizeLongAndShort withEntityCacheOnly |
1756 ops/sec (±0.51%) |
1837 ops/sec (±0.18%) |
1.05 |
denormalize bidirectional 50 |
4970 ops/sec (±5.37%) |
6952 ops/sec (±0.28%) |
1.40 |
denormalize bidirectional 50 donotcache |
39859 ops/sec (±0.19%) |
40359 ops/sec (±1.32%) |
1.01 |
getResponse |
4544 ops/sec (±0.77%) |
4714 ops/sec (±0.69%) |
1.04 |
getResponse (null) |
9720464 ops/sec (±0.98%) |
10852221 ops/sec (±0.75%) |
1.12 |
getResponse (clear cache) |
226 ops/sec (±4.18%) |
338 ops/sec (±0.33%) |
1.50 |
getSmallResponse |
3495 ops/sec (±0.35%) |
3511 ops/sec (±0.09%) |
1.00 |
getSmallInferredResponse |
2615 ops/sec (±1.93%) |
2617 ops/sec (±0.11%) |
1.00 |
getResponse Collection |
4452 ops/sec (±0.57%) |
4708 ops/sec (±0.82%) |
1.06 |
get Collection |
4392 ops/sec (±0.36%) |
4732 ops/sec (±0.47%) |
1.08 |
get Query-sorted |
5093 ops/sec (±0.39%) |
4672 ops/sec (±0.27%) |
0.92 |
setLong |
435 ops/sec (±0.29%) |
469 ops/sec (±0.13%) |
1.08 |
setLongWithMerge |
251 ops/sec (±0.21%) |
255 ops/sec (±0.21%) |
1.02 |
setLongWithSimpleMerge |
265 ops/sec (±0.45%) |
277 ops/sec (±0.12%) |
1.05 |
setSmallResponse 500x |
955 ops/sec (±0.12%) |
918 ops/sec (±0.74%) |
0.96 |
This comment was automatically generated by workflow using github-action-benchmark.
There was a problem hiding this comment.
Benchmark React
Details
| Benchmark suite | Current: fa9ec23 | Previous: ce99359 | Ratio |
|---|---|---|---|
data-client: getlist-100 |
128.21 ops/s (± 3.7%) |
136.99 ops/s (± 5.1%) |
1.07 |
data-client: getlist-500 |
38.39 ops/s (± 7.0%) |
41.85 ops/s (± 4.7%) |
1.09 |
data-client: update-entity |
322.58 ops/s (± 8.4%) |
333.33 ops/s (± 9.8%) |
1.03 |
data-client: update-user |
327.96 ops/s (± 9.8%) |
344.83 ops/s (± 7.8%) |
1.05 |
data-client: getlist-500-sorted |
40.91 ops/s (± 8.1%) |
46.08 ops/s (± 6.6%) |
1.13 |
data-client: update-entity-sorted |
281.75 ops/s (± 6.4%) |
294.12 ops/s (± 7.4%) |
1.04 |
data-client: update-entity-multi-view |
303.03 ops/s (± 7.1%) |
317.54 ops/s (± 8.1%) |
1.05 |
data-client: list-detail-switch-10 |
6.99 ops/s (± 6.6%) |
7.48 ops/s (± 6.1%) |
1.07 |
data-client: update-user-10000 |
79.68 ops/s (± 8.2%) |
74.91 ops/s (± 14.1%) |
0.94 |
data-client: invalidate-and-resolve |
35.59 ops/s (± 5.1%) |
36.1 ops/s (± 5.2%) |
1.01 |
data-client: unshift-item |
204.08 ops/s (± 4.7%) |
238.1 ops/s (± 2.1%) |
1.17 |
data-client: delete-item |
263.16 ops/s (± 6.0%) |
270.27 ops/s (± 7.0%) |
1.03 |
data-client: move-item |
163.93 ops/s (± 11.0%) |
181.82 ops/s (± 5.0%) |
1.11 |
This comment was automatically generated by workflow using github-action-benchmark.
352ef6f to
f989a17
Compare
ad3412f to
fef2f80
Compare
0c3e870 to
a9e969c
Compare
Add nonFilterArgumentKeys feature, embed a Scalar HooksPlayground demo (replacing dead imports), recategorize binary auto-detection under Other Improvements, and link PR #3887 for the Scalar / denormalize delegate work. Made-with: Cursor
| // (always a string from Object.keys, no coercion needed). | ||
| if (parentEntity && parentEntity.pk) { | ||
| const entityKey: string = parentEntity.key; | ||
| const id = `${parentEntity.pk(parent, undefined, undefined, args)}`; |
There was a problem hiding this comment.
Scalar re-derives entity pk with wrong arguments
Medium Severity
Scalar.normalize re-computes the parent entity's pk via parentEntity.pk(parent, undefined, undefined, args), passing undefined for the second and third positional arguments. However, EntityMixin.normalize computes the authoritative pk at its line 268 using the real parent and key values from the data tree context. If an entity's pk() instance method reads its parent or key parameters, Scalar.normalize would derive a different pk than the one the entity is actually stored under, causing the Scalar cell to be keyed incorrectly and unresolvable during denormalize.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 71e0d15. Configure here.
71e0d15 to
b15601e
Compare
102ad61 to
6722c13
Compare
Add nonFilterArgumentKeys feature, embed a Scalar HooksPlayground demo (replacing dead imports), recategorize binary auto-detection under Other Improvements, and link PR #3887 for the Scalar / denormalize delegate work. Made-with: Cursor
Makes the `Dep` shape strictly monomorphic (`{path, entity}` only) by
removing the optional `key` field and having `WeakDependencyMap.set`
re-evaluate `path(args)` when the path is a `KeyFn`. Callers in
`globalCache` now forward `this._args` to `set`.
Benefits:
- Eliminates the `wrong map` deopts observed on `WeakDependencyMap.set`
and `GlobalCache#paths` caused by the previously polymorphic Dep shape.
- Simpler, tighter interface -- one fewer field to keep in sync at each
`argsKey` call site.
- Slightly smaller gzipped esm bundle (-17 B); cjs flat.
Macro throughput is statistically unchanged vs the prior shape across
the denormalize/normalize suites (all deltas well within 95% CI over
5 runs). The change is a clarity + deopt-cleanup refactor, not a perf
optimization.
Made-with: Cursor
Made-with: Cursor
Previously, the shared `memo` used by Project/User/AllProjects/Values benches was also primed with StockSchema (scalar) entries during suite setup. Cross-schema priming in a single MemoCache perturbs V8 hidden-class state for downstream cached-path benches — masking real deltas by ~15% on `denormalizeLong Values withCache` and adding systematic noise to other withCache benches. Move Stock priming to a dedicated `memoStock` MemoCache instance used only by the two Scalar withCache benches. Non-Scalar benches now see the same `memo` shape they did prior to the Scalar PR, so measurements are comparable against master. Verified with 5x full suite runs: denormalizeLong Values withCache: 7273 -> 8674 ops/sec (+19%) other benches within run-to-run noise. Made-with: Cursor
Recovers the 3–7% regressions introduced by "fix: cache busting with args" (acdb4b1) on cached denormalize benches. Root cause: `argsKey` support added unconditional `typeof === 'function'` branches, dynamic `push`-based path materialization, and post-hoc filter scans to the hot `WeakDependencyMap.get` / `GlobalCache.paths` / `GlobalCache.getResults` paths — every caller paid the cost, even entity-only chains. Changes ------- normalizr/memo/WeakDependencyMap * Sticky `hasStr` flag: set true only when a `KeyFn` dep is stored. * `get` uses the pre-acdb entity-only loop when `hasStr` is false (common case), and a separate `_getMixed` slow path otherwise. * Expose `hasStringDeps` for consumers to gate their own work. normalizr/memo/globalCache * Per-frame `_hasArgsKey` flag set in `argsKey()`. * `paths()` restores pre-allocated `new Array(n)` + indexed writes when no function-typed dep was pushed this frame. * `getResults` skips the function-strip scan on cache hit unless the result WDM has ever stored a string dep. normalizr/denormalize/unvisit + schemas/{Array,Object}, endpoint/schemas/ {Array,Object,Values,EntityMixin} * Hoist `delegate.args` / `delegate.unvisit` out of per-entity and per-array-element loops so hot denormalize walks read a closure local instead of doing a property load per call. Measurements (5-run medians, ops/sec, vs a9e9… pre-acdb baseline) ----------------------------------------------------------------- pre at-acdb HEAD denormalizeShort 500x 1234 1198 1583 +28.3% denormalize bidirectional 50 8549 7922 10801 +26.3% denormalizeLong 437 424 552 +26.3% denormalizeLong with mixin Entity 411 396 515 +25.3% denormalizeLong All withCache 10479 10401 12242 +16.8% denormalizeLong Values 380 359 439 +15.5% denormalizeLong Query-sorted withCache 10858 10763 12305 +13.3% query All withCache 11071 10619 12387 +11.9% denormalizeLong withCache 12119 11708 12514 + 3.3% denormalizeLong Values withCache 8879 8692 8875 0.0% queryShort 500x withCache 4792 4556 4494 - 6.2% denormalizeShort 500x withCache 13126 12364 12397 - 5.6% The short 500x benches amplify per-call overhead ~500×; the residual regression there reflects the unavoidable delegate-object indirection still required for `argsKey` support. Aggregate across the suite is strongly net-positive vs pre-acdb. Tests: packages/normalizr + packages/endpoint — all 680 pass. Made-with: Cursor
When the result cache missed (new input ref) but every entity ref was unchanged, `GlobalCache.getEntity` replayed cached deps without running `computeValue`, leaving `_hasArgsKey` false. `paths()` then took its fast path and leaked function-typed (`argsKey`) deps from the replayed chain into the returned `EntityPath[]` subscription list. Set `_hasArgsKey` on cache hit when the per-entity `WeakDependencyMap` has ever stored a function dep (`hasStringDeps`), keeping the single branch outside the push loop to preserve hidden-class stability on the hot path. Made-with: Cursor
Scalar.normalize re-derives the enclosing entity's pk without the `parent`/`key` context that EntityMixin.normalize uses, so any custom pk() reading those args would key the Scalar cell under a different id than the entity is stored under. Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
…mitives Truthy non-string primitives (e.g. `0.5`, `true`, `42`) previously fell through the falsy/symbol guard and into `delegate.unvisit(this, input)`. Since Scalar has no `pk`, `unvisit`'s `createIfValid` fast path only matches string inputs, so non-string primitives re-dispatched to `Scalar.denormalize` — infinite recursion / stack overflow. This can surface during schema migration when Scalar is added to an entity that still has cached raw numeric or boolean field values in the store. Tighten the guard to pass through any non-string, non-object input so stale values degrade gracefully instead of crashing. Made-with: Cursor
| // fill pre-allocated slot 0 with the input reference | ||
| this.dependencies[0] = { path: { key: '', pk: '' }, entity: input }; | ||
| this._resultCache.set(this.dependencies, data); | ||
| this._resultCache.set(this.dependencies, data, this._args); |
There was a problem hiding this comment.
Cache hit mutates stored journey array via shift
High Severity
On the result-cache hit path, paths.shift() mutates the journey array that is stored by reference inside the WeakDependencyMap Link node. After the first cache hit removes the leading element, subsequent hits on the same cached entry will operate on an already-shifted array, progressively losing path entries. This corrupts the subscription list and can cause missed re-renders or stale data on repeated cache hits. A non-mutating approach like paths.slice(1) would avoid corrupting the stored journey.
Reviewed by Cursor Bugbot for commit 5abd1a8. Configure here.
5abd1a8 to
d7eacac
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d7eacac. Configure here.
…horitative map keys
Previously `entityPk` returned any non-undefined `key`, but
`Array.normalize` forwards the *parent's* field name as `key` to every
element. When `[Scalar]` or `Collection([Scalar])` was nested under a
plain object schema like `{ stock: [Scalar] }`, every item received the
same field-name pk, collapsing all cells onto one compound pk and
silently corrupting data.
Trust `key` only when the enclosing container literally maps it to the
cell — `parent[key] === input` — which holds for `Values(Scalar)` (the
intended use of the surrounding-key heuristic) but not for arrays.
Co-authored-by: Nathaniel Tucker <me@ntucker.me>


Motivation
A primary use case is displaying vast amounts of information in a grid (like a
spreadsheet). Each row is an entity, but some columns display relational data
that depends on a user selection — for example, which portfolio's equity
percentages to show for each company. We call this selection a "lens."
The goal is to:
require a refetch
entity fields
simultaneously
Solution
Introduces a
Scalarschema for lens-dependent entity fields, and thesupporting
IDenormalizeDelegate.argsKey(fn)memoization primitive.Normalize. Entity fields like
pct_equity: 0.5are replaced in the entityrow with lens-independent wrappers
[entityPk, fieldName, entityKey](a tuple,distinguishable from cell data via
Array.isArray). The actual scalar data isstored in a
Scalar(<key>)entity table keyed byentityKey|entityPk|lensValue.Denormalize.
EntityMixin.denormalizeis unchanged. The standarddelegate.unvisit(schema, input[key])loop callsScalar.denormalize, whichreads the wrapper, registers the lens as an
argsKeymemo dimension, looks upthe correct cell, and returns just the field value.
Design iteration
Schema shape: two classes → one
Started with a two-class design (
Scalar+ internalScalarCell) so the cellcould be entity-like (have
pk()) for storage in the entity table, whileScalarstayed lens-aware. TheScalarCellwas a pass-through that existedsolely to satisfy
normalizr's entity-shape constraints.Collapsed to a single class.
Scalarnow implementsMergeabledirectly(provides
merge/mergeWithStore/mergeMetaWithStore/shouldReorder/createIfValid) and stores cell data under a compound pk(
entityKey|entityPk|lensValue). Theunvisit.tsdenormalize router learned asmall branch to route schemas with
createIfValidand string inputs throughunvisitEntityeven when they don't expose.pk— lettingScalarbetable-resident without pretending to be an Entity.
Parent-entity context: smuggled → 7th arg
Earlier the entity context was smuggled in by passing the whole entity as
parentand encoding<entityKey>|<entityPk>|<fieldName>into thekeyarg,breaking the standard
Visitcontract. The visit walker (getVisit) nowtracks the nearest enclosing entity-like schema (anything with
pk) in aclosure variable and passes it as a 7th
parentEntityargument to everyschema.normalizecall. A newacceptsPrimitivesopt-in marker lets schemaslike
Scalarreceive primitive field values instead of being short-circuited.With that in place,
EntityMixin.normalize's field loop is one uniformvisit(...)— no Scalar-specific branch — andScalar.normalizereadsparentEntity.key/parentEntity.pk(parent, …, args)to derive the cell'scompound key.
parentis the entity data row as the contract specifies.Denormalize signature:
(input, args, unvisit)→(input, delegate)Scalarcached wrong because denormalize had no way to say "my output dependson these args" — the memo frame only tracked entity identity. Adding
argsasa memo dimension on every denormalize would regress unrelated schemas, so
instead
Schema.denormalize()was reshaped around a single delegate:The new
IDenormalizeDelegateexposes
unvisit,args, andargsKey(fn). Readingdelegate.argsdirectlydoes not contribute to the cache — schemas whose output varies with args
must call
argsKey:argsKey(fn)pushes a{path: fn, entity: undefined}dep onto the currententity-cache frame and returns
fn(args). Thefnreference doubles as astable cache path key and is re-evaluated on
setto bucket the new branch.This is a breaking change for custom schemas; all built-in schemas
(
Array,Object,Values,Union,Query,Invalidate,Lazy,Collection, plusEntityMixin) are migrated by the library. A jscodeshiftcodemod +
data-client-v0.17-migrationskill handle user migrations.Cache storage:
WeakDependencyMaplearns string-keyed depsWeakDependencyMappreviously walked entity-ref chains only. To supportargsKey, a dep path can now be aKeyFn(a function ofargsreturning astring bucket) alongside the existing entity-ref path type. String-keyed deps
branch via a lazily-allocated
Map<string, Link>on eachLink; entity-refdeps continue to use the pre-existing
WeakMap<K, Link>. TheDepshape waskept monomorphic (
{path, entity}) —setre-evaluatespath(args)whentypeof path === 'function'rather than carrying a precomputedkeyfield —which keeps V8's inline caches on the hot
WDM.set/pathspaths clean. Asticky
hasStrflag letsWDM.getpick the pre-PR entity-only fast path whenno
argsKeydep has ever been recorded in that map.GlobalCachemirrors thesame gating via a per-frame
_hasArgsKeyflag that governspaths()/getResults()function-stripping.Entities-vs-denormalize classifier
Controller.getResponsepreviously short-circuited denormalize viaschemaHasEntity— "no entities in the tree? just return the raw response."Scalar breaks that assumption: it has no entities of its own but does
transform values via
normalize/denormalize(the wrapper lookup). Renamed torequiresDenormalizeand reframed around "does any node in the schema treedefine
normalize?" so Scalar (and any future transform-only schema) isrouted through the full denormalize pipeline.
EntityMixin.denormalize's ownskip-fast-path got the same treatment.
Performance investigation
Three rounds of A/B benchmarks (
yarn workspace example-benchmark start normalizr), stash-pop methodology, trimmed-mean of 5 runs.Round 1 — parent-entity context plumbing.
normalizeLongnormalizeLong Valuesdelegate.currentEntitymutationObject.definePropertygetter ondelegateEntityMixin(oneacceptsPrimitivescheck)getVisit+ 7th arg +acceptsPrimitivesFindings:
delegatemutation regression came from V8 IC pollution: assigningdifferent
Entitysubclasses (each with a distinct hidden class) to asingle property turned the IC megamorphic and propagated deopts into hot
inlined call sites (
normalize,Object_normalize,normalizeValue).Confirmed with
--trace-deopt.Object.definePropertygetter regression was due to installing anaccessor property on
delegate— V8 deoptimizes other property accesses onthe object once any property becomes an accessor.
it's steady-state work in fully optimized code.
--trace-deoptwas cleanand gave a misleading green light at first; only a tight A/B benchmark
caught it. Lesson: deopt traces tell you whether you broke optimization,
but only A/B benchmarks tell you whether the optimized version is fast
enough.
schema.normalizeis essentially free inmodern V8.
Trade-off chosen: ~3% normalize-throughput cost on the hot path in
exchange for a uniform schema contract.
Round 2 —
argsKeylanding.acdb4b161caddedargsKeyplumbing toWeakDependencyMap,GlobalCache, andunvisit. Initial landing regressedcached denormalize 3–7%: unconditional
typeof === 'function'branches,dynamic
push-based path materialization, and post-hoc filter scans appearedon the hot
WDM.get/GlobalCache.paths/GlobalCache.getResultspaths —every caller paid, even entity-only chains.
Round 3 — entity-only fast paths (
b15601e5ec). Restored pre-acdbthroughput and then some:
denormalizeShort 500xdenormalize bidirectional 50denormalizeLongdenormalizeLong with mixin EntitydenormalizeLong All withCachedenormalizeLong ValuesdenormalizeLong Query-sorted withCachequery All withCachedenormalizeLong withCachedenormalizeLong Values withCachequeryShort 500x withCachedenormalizeShort 500x withCacheMechanism:
hasStronWDM(set true only when aKeyFndep is stored) —getuses the pre-acdb entity-only walk when no schema in that map usesargsKey;_getMixedslow path only when needed._hasArgsKeyonGlobalCache.paths()restores pre-allocatednew Array(n)+ indexed writes when no function-typed dep was pushed thisframe.
getResultsskips the function-strip scan on cache hit unless theresult WDM has ever stored a string dep.
delegate.args/delegate.unvisitout of per-entity andper-array-element loops in
unvisit,Array,Object,Values, andEntityMixin.denormalize— one property load per call, not one periteration.
Depshape (1188a504a4): drop the precomputedkeyfieldfrom
Depand haveWDM.setre-evaluatepath(args)whenpathis aKeyFn. Eliminates thewrong mapdeopts previously observed onWeakDependencyMap.set/GlobalCache#paths. −17 B gzipped ESM; macrothroughput within 95% CI.
Residual regression on the 500x cached benches reflects the unavoidable
delegate-object indirection still required for
argsKeysupport; aggregateacross the suite is strongly net-positive vs pre-PR baseline.
Open questions
Scalarsupport aprocessorvalidatehook for cell data?|) be configurable for entity keys/pksthat legitimately contain
|?