Skip to content

feat(endpoint): add Scalar schema for lens-dependent entity fields#3887

Merged
ntucker merged 38 commits intomasterfrom
cursor/scalar-schema-design-for-lenses-d6e3
Apr 28, 2026
Merged

feat(endpoint): add Scalar schema for lens-dependent entity fields#3887
ntucker merged 38 commits intomasterfrom
cursor/scalar-schema-design-for-lenses-d6e3

Conversation

@ntucker
Copy link
Copy Markdown
Collaborator

@ntucker ntucker commented Apr 8, 2026

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:

  • Cache different lens selections independently so switching back doesn't
    require a refetch
  • Support column-only fetches that update just the lens data without touching
    entity fields
  • Allow multiple components to render the same entity through different lenses
    simultaneously

Solution

Introduces a Scalar schema for lens-dependent entity fields, and the
supporting IDenormalizeDelegate.argsKey(fn) memoization primitive.

Normalize. Entity fields like pct_equity: 0.5 are replaced in the entity
row with lens-independent wrappers [entityPk, fieldName, entityKey] (a tuple,
distinguishable from cell data via Array.isArray). The actual scalar data is
stored in a Scalar(<key>) entity table keyed by entityKey|entityPk|lensValue.

Denormalize. EntityMixin.denormalize is unchanged. The standard
delegate.unvisit(schema, input[key]) loop calls Scalar.denormalize, which
reads the wrapper, registers the lens as an argsKey memo dimension, looks up
the correct cell, and returns just the field value.

const PortfolioScalar = new Scalar({
  lens: args => args[0]?.portfolio,
  key: 'portfolio',
  entity: Company,
});

class Company extends Entity {
  id = '';
  price = 0;
  pct_equity = 0;
  shares = 0;
  static schema = {
    pct_equity: PortfolioScalar,
    shares: PortfolioScalar,
  };
}

// Column-only endpoint (dictionary keyed by company pk)
const getPortfolioColumns = new RestEndpoint({
  path: '/companies/columns',
  searchParams: {} as { portfolio: string },
  schema: new schema.Values(PortfolioScalar),
});

Design iteration

Schema shape: two classes → one

Started with a two-class design (Scalar + internal ScalarCell) so the cell
could be entity-like (have pk()) for storage in the entity table, while
Scalar stayed lens-aware. The ScalarCell was a pass-through that existed
solely to satisfy normalizr's entity-shape constraints.

Collapsed to a single class. Scalar now implements Mergeable directly
(provides merge/mergeWithStore/mergeMetaWithStore/shouldReorder/
createIfValid) and stores cell data under a compound pk
(entityKey|entityPk|lensValue). The unvisit.ts denormalize router learned a
small branch to route schemas with createIfValid and string inputs through
unvisitEntity even when they don't expose .pk — letting Scalar be
table-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
parent and encoding <entityKey>|<entityPk>|<fieldName> into the key arg,
breaking the standard Visit contract. The visit walker (getVisit) now
tracks the nearest enclosing entity-like schema (anything with pk) in a
closure variable and passes it as a 7th parentEntity argument to every
schema.normalize call. A new acceptsPrimitives opt-in marker lets schemas
like Scalar receive primitive field values instead of being short-circuited.
With that in place, EntityMixin.normalize's field loop is one uniform
visit(...) — no Scalar-specific branch — and Scalar.normalize reads
parentEntity.key / parentEntity.pk(parent, …, args) to derive the cell's
compound key. parent is the entity data row as the contract specifies.

Denormalize signature: (input, args, unvisit)(input, delegate)

Scalar cached wrong because denormalize had no way to say "my output depends
on these args" — the memo frame only tracked entity identity. Adding args as
a memo dimension on every denormalize would regress unrelated schemas, so
instead Schema.denormalize() was reshaped around a single delegate:

// before
denormalize(input, args, unvisit) {
  return unvisit(this.schema, input);
}
// after
denormalize(input, delegate) {
  return delegate.unvisit(this.schema, input);
}

The new IDenormalizeDelegate
exposes unvisit, args, and argsKey(fn). Reading delegate.args directly
does not contribute to the cache — schemas whose output varies with args
must call argsKey:

const lens = delegate.argsKey(this.lensSelector); // fn is stable (ctor-bound)

argsKey(fn) pushes a {path: fn, entity: undefined} dep onto the current
entity-cache frame and returns fn(args). The fn reference doubles as a
stable cache path key and is re-evaluated on set to bucket the new branch.
This is a breaking change for custom schemas; all built-in schemas
(Array, Object, Values, Union, Query, Invalidate, Lazy,
Collection, plus EntityMixin) are migrated by the library. A jscodeshift
codemod + data-client-v0.17-migration skill handle user migrations.

Cache storage: WeakDependencyMap learns string-keyed deps

WeakDependencyMap previously walked entity-ref chains only. To support
argsKey, a dep path can now be a KeyFn (a function of args returning a
string bucket) alongside the existing entity-ref path type. String-keyed deps
branch via a lazily-allocated Map<string, Link> on each Link; entity-ref
deps continue to use the pre-existing WeakMap<K, Link>. The Dep shape was
kept monomorphic ({path, entity}) — set re-evaluates path(args) when
typeof path === 'function' rather than carrying a precomputed key field —
which keeps V8's inline caches on the hot WDM.set/paths paths clean. A
sticky hasStr flag lets WDM.get pick the pre-PR entity-only fast path when
no argsKey dep has ever been recorded in that map. GlobalCache mirrors the
same gating via a per-frame _hasArgsKey flag that governs
paths()/getResults() function-stripping.

Entities-vs-denormalize classifier

Controller.getResponse previously short-circuited denormalize via
schemaHasEntity — "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 to
requiresDenormalize and reframed around "does any node in the schema tree
define normalize?" so Scalar (and any future transform-only schema) is
routed through the full denormalize pipeline. EntityMixin.denormalize's own
skip-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.

Approach normalizeLong normalizeLong Values
Baseline 770 690
delegate.currentEntity mutation −3 to −4%
Object.defineProperty getter on delegate −44%
Dispatch in EntityMixin (one acceptsPrimitives check) 770 (0%) 686 (−0.6%)
Closure tracking in getVisit + 7th arg + acceptsPrimitives 741 (−3.8%) 660 (−4.3%)

Findings:

  • The delegate mutation regression came from V8 IC pollution: assigning
    different Entity subclasses (each with a distinct hidden class) to a
    single property turned the IC megamorphic and propagated deopts into hot
    inlined call sites (normalize, Object_normalize, normalizeValue).
    Confirmed with --trace-deopt.
  • The Object.defineProperty getter regression was due to installing an
    accessor property on delegate — V8 deoptimizes other property accesses on
    the object once any property becomes an accessor.
  • The closure save/restore around every entity visit is not a deopt —
    it's steady-state work in fully optimized code. --trace-deopt was clean
    and 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.
  • Passing an extra 7th arg to schema.normalize is essentially free in
    modern V8.

Trade-off chosen: ~3% normalize-throughput cost on the hot path in
exchange for a uniform schema contract.

Round 2 — argsKey landing. acdb4b161c added argsKey plumbing to
WeakDependencyMap, GlobalCache, and unvisit. Initial landing regressed
cached denormalize 3–7%: unconditional typeof === 'function' branches,
dynamic push-based path materialization, and post-hoc filter scans appeared
on the hot WDM.get / GlobalCache.paths / GlobalCache.getResults paths —
every caller paid, even entity-only chains.

Round 3 — entity-only fast paths (b15601e5ec). Restored pre-acdb
throughput and then some:

Benchmark (5-run median, ops/sec) pre-acdb 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%)

Mechanism:

  • Sticky hasStr on WDM (set true only when a KeyFn dep is stored) —
    get uses the pre-acdb entity-only walk when no schema in that map uses
    argsKey; _getMixed slow path only when needed.
  • Per-frame _hasArgsKey on GlobalCache. 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.
  • Hoist delegate.args / delegate.unvisit out of per-entity and
    per-array-element loops in unvisit, Array, Object, Values, and
    EntityMixin.denormalize — one property load per call, not one per
    iteration.
  • Monomorphic Dep shape (1188a504a4): drop the precomputed key field
    from Dep and have WDM.set re-evaluate path(args) when path is a
    KeyFn. Eliminates the wrong map deopts previously observed on
    WeakDependencyMap.set / GlobalCache#paths. −17 B gzipped ESM; macro
    throughput within 95% CI.

Residual regression on the 500x cached benches reflects the unavoidable
delegate-object indirection still required for argsKey support; aggregate
across the suite is strongly net-positive vs pre-PR baseline.

Open questions

  • Should Scalar support a process or validate hook for cell data?
  • Should the compound-pk delimiter (|) be configurable for entity keys/pks
    that legitimately contain |?
Open in Web Open in Cursor 

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 8, 2026

🦋 Changeset detected

Latest commit: fa9ec23

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

This PR includes changesets to release 16 packages
Name Type
@data-client/endpoint Major
@data-client/rest Major
@data-client/graphql Major
@data-client/normalizr Major
@data-client/core Major
@data-client/react Major
@data-client/vue Minor
@data-client/img Major
example-benchmark Patch
example-benchmark-react Patch
normalizr-github-example Patch
normalizr-redux-example Patch
normalizr-relationships Patch
test-bundlesize Patch
coinbase-lite Patch
@data-client/test Major

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

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 8, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs-site Ignored Ignored Preview Apr 28, 2026 1:36am

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 8, 2026

Size Change: +400 B (+0.5%)

Total Size: 81.1 kB

📦 View Changed
Filename Size Change
examples/test-bundlesize/dist/rdcClient.js 10.9 kB +380 B (+3.63%)
examples/test-bundlesize/dist/rdcEndpoint.js 8.03 kB +20 B (+0.25%)
ℹ️ View Unchanged
Filename Size Change
examples/test-bundlesize/dist/App.js 1.46 kB +1 B (+0.07%)
examples/test-bundlesize/dist/polyfill.js 307 B 0 B
examples/test-bundlesize/dist/react.js 59.7 kB 0 B
examples/test-bundlesize/dist/webpack-runtime.js 726 B 0 B

compressed-size-action

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 8, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.21%. Comparing base (9a51235) to head (fa9ec23).
⚠️ Report is 1 commits behind head on master.

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.
📢 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.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

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: 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.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

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: 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.

@ntucker ntucker marked this pull request as ready for review April 8, 2026 05:40
Comment thread packages/endpoint/src/schemas/Scalar.ts
Comment thread packages/endpoint/src/schemas/Scalar.ts Outdated
Comment thread packages/endpoint/src/schemas/Scalar.ts Outdated
@ntucker ntucker force-pushed the cursor/scalar-schema-design-for-lenses-d6e3 branch from 352ef6f to f989a17 Compare April 13, 2026 13:04
Comment thread packages/endpoint/src/schemas/Scalar.ts Outdated
Comment thread packages/normalizr/src/denormalize/unvisit.ts
Comment thread packages/normalizr/src/normalize/getVisit.ts
Comment thread packages/endpoint/src/schemas/Scalar.ts
Comment thread packages/endpoint/src/schemas/Scalar.ts Outdated
@ntucker ntucker force-pushed the cursor/scalar-schema-design-for-lenses-d6e3 branch from ad3412f to fef2f80 Compare April 18, 2026 00:26
@ntucker ntucker force-pushed the cursor/scalar-schema-design-for-lenses-d6e3 branch from 0c3e870 to a9e969c Compare April 22, 2026 13:47
Comment thread packages/core/src/controller/Controller.ts
ntucker added a commit that referenced this pull request Apr 25, 2026
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)}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 71e0d15. Configure here.

@ntucker ntucker force-pushed the cursor/scalar-schema-design-for-lenses-d6e3 branch from 71e0d15 to b15601e Compare April 26, 2026 22:53
@ntucker ntucker force-pushed the cursor/scalar-schema-design-for-lenses-d6e3 branch from 102ad61 to 6722c13 Compare April 26, 2026 23:23
Comment thread packages/normalizr/src/memo/globalCache.ts
ntucker added 18 commits April 27, 2026 10:18
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
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
…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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5abd1a8. Configure here.

@ntucker ntucker force-pushed the cursor/scalar-schema-design-for-lenses-d6e3 branch from 5abd1a8 to d7eacac Compare April 27, 2026 14:27
Copy link
Copy Markdown

@cursor cursor Bot left a comment

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.

There are 3 total unresolved issues (including 2 from previous reviews).

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 d7eacac. Configure here.

Comment thread packages/endpoint/src/schemas/Scalar.ts
…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>
@ntucker ntucker merged commit 84078d7 into master Apr 28, 2026
28 checks passed
@ntucker ntucker deleted the cursor/scalar-schema-design-for-lenses-d6e3 branch April 28, 2026 05:10
This was referenced Apr 28, 2026
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.

2 participants