Skip to content

[pull] master from reactive:master#136

Merged
pull[bot] merged 1 commit intoerickirt:masterfrom
reactive:master
Apr 28, 2026
Merged

[pull] master from reactive:master#136
pull[bot] merged 1 commit intoerickirt:masterfrom
reactive:master

Conversation

@pull
Copy link
Copy Markdown

@pull pull Bot commented Apr 28, 2026

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

…3887)

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

Introduces Scalar + ScalarCell classes following the Union pattern:
- Scalar (SchemaSimple, not entity-like) routes normalize/denormalize
- ScalarCell (entity-like, internal) stores grouped cell data
- EntityMixin.normalize: if/else to pass whole entity to Scalar
- EntityMixin.denormalize: completely unchanged (Union-like wrapper)
- Entity stores lens-independent {id,field} wrappers
- Denormalize joins correct cell based on endpoint args

Co-authored-by: natmaster <natmaster@gmail.com>

* test(endpoint): add Scalar schema tests

Tests cover:
- normalize stores wrapper refs in entity, cell data in ScalarCell
- multiple entities, different lenses produce separate cells
- denormalize joins correct cell based on lens args
- different lens args produce different results from same entity
- missing lens returns undefined for scalar fields
- column-only fetch via Values stores cells without modifying entities
- column fetch cells joinable via denormalize with Company schema
- merge accumulation updates existing cells
- Scalar constructor and queryKey

Co-authored-by: natmaster <natmaster@gmail.com>

* docs(rest): add Scalar schema API documentation

Covers usage (full entity + column-only endpoints), constructor options,
normalize/denormalize flow, normalized storage model, and related APIs.

Co-authored-by: natmaster <natmaster@gmail.com>

* Remove dead _lastCpk field from Scalar class

_lastCpk was declared and initialized but never read or written
elsewhere in the codebase.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* enchance: Better design

* enhance(endpoint): clean up Scalar parent-entity context plumbing

Replace the encoded-key hack with a direct `parentEntity` argument:
- `EntityMixin.normalize` now dispatches schemas marked `acceptsPrimitives`
  directly (bypassing `visit`'s primitive short-circuit) and passes `this`
  as the 7th arg.
- `Scalar.normalize` reads `parentEntity` to derive entity key and pk; no
  more parsing `'<entityKey>|<entityPk>|<fieldName>'` out of the visit key.
- `parent` is now the entity data row (standard `Visit` contract), not the
  Entity class.
- `getVisit` and the `SchemaSimple` interface are unchanged — zero impact
  on the normalize hot path (verified at parity with HEAD across 8-run A/B
  benchmarks).

Made-with: Cursor

* enhance(normalizr): track parentEntity in visit walker

Move parent-entity context tracking into `getVisit` itself, eliminating
the per-schema-type dispatch in `EntityMixin`. The walker now:
- Maintains a `currentEntity` closure variable, save/restored around
  every entity visit (schemas with `pk`).
- Passes it as a 7th `parentEntity` arg to every `schema.normalize` call.
- Honors a new `acceptsPrimitives` opt-in marker so schemas like `Scalar`
  receive primitive values instead of being short-circuited.

`EntityMixin.normalize`'s field loop is now a single uniform `visit(...)`
call — no more conditional branch for Scalar fields. `Scalar.normalize`
reads `parentEntity` from the standard 7th arg; `parent` is the entity
data row as the standard `Visit` contract specifies.

Trade-off: ~3% normalize-throughput cost on the hot path (closure
save/restore around every entity visit). Validated with 8-run A/B
benchmarks. Buys a uniform schema contract — Scalar (and any future
context-dependent schema) needs no special case in `EntityMixin`.

Made-with: Cursor

* refactor(normalizr): collapse entity/non-entity branches in visit walker

Both branches called `schema.normalize` with the same args except for the
parent-entity context. Snapshot `prev = currentEntity` first, then
conditionally update `currentEntity = schema` for entities. Pass `prev` —
which equals the prior entity for entities and the still-current entity
for non-entities — and unconditionally restore. One call site instead of
two, no behavior change. 8-run A/B benchmarks at parity with the prior
version (within noise).

Made-with: Cursor

* internal: Update website types

* fix: visit edge case

* fix(normalizr): tighten table-resident schema check in unvisit

The `else if` branch for table-resident schemas without `pk` was matching
any schema exposing a `key` property. `Invalidate` extends PolymorphicSchema
and exposes `key` via a getter, so it was being routed into `unvisitEntity`
-> `unvisitEntityObject`, which calls `schema.createIfValid()` -- a method
`Invalidate` does not implement. This caused `TypeError` on basic Invalidate
denormalization and broke deletion/symbol propagation.

Switch the discriminator to `typeof createIfValid === 'function'`, which is
the precise capability `unvisitEntityObject` requires. This matches Scalar
(which now implements Mergeable) and regular entities, but not Invalidate,
Query, Union, etc. -- they continue falling through to their own
`denormalize` methods.

Made-with: Cursor

* fix: edge cases

* docs: updates

* test(endpoint): cover Scalar merge, denormalize edge cases

Add direct tests for merge/shouldReorder/mergeWithStore/mergeMetaWithStore
and denormalize passthroughs (falsy, symbol, object, missing-lens, missing
cell, cpk string + Values round-trip) to bring Scalar.ts to 100% coverage.

Made-with: Cursor

* docs: Fix

* fix(core): handle Scalar/wrapper schemas in skip-denormalize check

`getResponseMeta` short-circuits `denormalize()` when the endpoint
schema doesn't transform the response. The previous check
(`schemaHasEntity`) had two bugs surfaced by Scalar:

1. For `Values(Scalar)` it walked `Object.values(scalarInstance)`,
   recursed into the `lensSelector` function, and looped forever.
2. Scalar is table-resident without `pk`, so it was not detected as
   needing denormalize — the raw cpk strings were returned instead of
   the joined cell data.

Replace with `requiresDenormalize`, which mirrors `getVisit.ts`:
schemas that define `normalize` always need denormalize to reconstruct
the response. This collapses the entity check, the Scalar special-
case, the wrapper `.schema` recursion, and the self-loop guard into a
single early-exit, so schema class instances never fall through to
`Object.values()` traversal of their instance fields.

Adds regression tests for both `Values(Scalar)` (column-only endpoint)
and Entity-with-Scalar-fields, with a hard 2s timeout so a re-
introduced infinite recursion fails fast rather than hanging Jest.

Net: -89B minified, neutral-to-positive on `core ^get` benchmarks.
Made-with: Cursor

* docs: Fix types

* internal: Update tests for check for memoization paths

* fix: cache busting with args

* fix: does not over-denormalize a schema map containing string values

* docs: Release notes and migration for breaking changes

* docs(blog): Conform v0.17 release post to blog style guide

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

* internal: Bench and upgrade skill

* refactor(normalizr): drop precomputed key from Dep, pass args to set

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

* internal: Add WeakDependencyMap microbenchmarks

Made-with: Cursor

* bench: isolate Scalar MemoCache from shared priming

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

* perf: restore entity-only fast paths in denormalize cache

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

* docs: Update docs

* internal: More tests

* fix(normalizr): propagate argsKey flag on entity-cache hit

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

* internal: TODO on Scalar pk context mismatch

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

* internal: clarify intent of Scalar.denormalize falsy guard

Made-with: Cursor

* internal: trim Scalar.denormalize guard comment

Made-with: Cursor

* docs: Tuning

* docs: Update agents to latest design

* fix(endpoint): guard Scalar.denormalize against truthy non-string primitives

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

* enhance: entityPk + queryKey

* fix(endpoint): scope Scalar.entityPk surrounding-key heuristic to authoritative 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>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
@pull pull Bot locked and limited conversation to collaborators Apr 28, 2026
@pull pull Bot added the ⤵️ pull label Apr 28, 2026
@pull pull Bot merged commit 84078d7 into erickirt:master Apr 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant