[pull] master from reactive:master#136
Merged
pull[bot] merged 1 commit intoerickirt:masterfrom Apr 28, 2026
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 : )