Skip to content

feat: add Lazy schema for deferred relationship denormalization#3829

Open
ntucker wants to merge 6 commits intomasterfrom
cursor/2-test-case-creation-with-synthetic-data-6097
Open

feat: add Lazy schema for deferred relationship denormalization#3829
ntucker wants to merge 6 commits intomasterfrom
cursor/2-test-case-creation-with-synthetic-data-6097

Conversation

@ntucker
Copy link
Copy Markdown
Collaborator

@ntucker ntucker commented Mar 28, 2026

Motivation

LazyQuery.queryKey checks typeof schema.queryKey === 'function' and delegates to the inner schema's queryKey if it exists. However, some schema classes like schema.Array and schema.Values have a queryKey method that always returns undefined. When the inner schema is an explicit class instance (e.g., new schema.Array(Building) instead of shorthand [Building]), the delegation produces undefined, causing MemoCache.query to short-circuit and return no data. The args[0] fallback -- which is the correct behavior for these schemas -- is never reached.

Solution

The fix adds a schema.key check alongside the typeof schema.queryKey === 'function' check in LazyQuery.queryKey. Only schemas with both a queryKey method and a key property (Entity, Collection) will have their queryKey delegated to. Container schemas like Array and Values (which lack key) fall through to the args[0] passthrough.

This approach:

  • Correctly distinguishes "queryable" schemas (Entity, Collection) from container schemas (Array, Values) at runtime
  • Preserves existing Entity lookup behavior (missing entity returns undefined)
  • Fixes explicit schema.Array(Entity) and schema.Values(Entity) usage with Lazy

Also fixes pre-existing TypeScript errors in Lazy.test.ts and adds test coverage for explicit schema.Array and schema.Values inner schemas including normalize, denormalize, and LazyQuery resolution.

Open questions

None.

Open in Web Open in Cursor 

cursoragent and others added 3 commits March 28, 2026 02:30
Introduces schema.Lazy(innerSchema) that:
- normalize: delegates to inner schema (entities stored normally)
- denormalize: no-op (returns raw PKs unchanged)
- .query getter: returns LazyQuery for use with useQuery()

LazyQuery resolves entities lazily:
- queryKey: delegates to inner schema if it has queryKey, otherwise passes through args[0]
- denormalize: delegates to inner schema via unvisit (full entity resolution)

No changes needed to EntityMixin or unvisit - Lazy.denormalize as no-op
means the existing denormalize loop works without any special handling.

Co-authored-by: natmaster <natmaster@gmail.com>
Tests cover:
- Normalization: inner entities stored correctly through Lazy wrapper
- Denormalization: Lazy field leaves raw PKs unchanged (no-op)
- LazyQuery (.query): resolves array of IDs, delegates to Entity.queryKey,
  handles missing entities, returns empty for empty IDs
- Memoization isolation: parent denorm stable when lazy entity changes
- Stack safety: 1500-node bidirectional graph does not overflow

Co-authored-by: natmaster <natmaster@gmail.com>
Documents the Lazy schema class including:
- Constructor and usage patterns (array, entity, collection)
- .query accessor for useQuery integration
- How normalization/denormalization works
- Performance characteristics

Co-authored-by: natmaster <natmaster@gmail.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 28, 2026

🦋 Changeset detected

Latest commit: d6ce627

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

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

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 Mar 28, 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 Mar 28, 2026 11:45am

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 28, 2026

Size Change: 0 B

Total Size: 80.5 kB

ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 3.18 kB
examples/test-bundlesize/dist/polyfill.js 307 B
examples/test-bundlesize/dist/rdcClient.js 10.3 kB
examples/test-bundlesize/dist/rdcEndpoint.js 6.35 kB
examples/test-bundlesize/dist/react.js 59.7 kB
examples/test-bundlesize/dist/webpack-runtime.js 726 B

compressed-size-action

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: d6ce627 Previous: 869f28f Ratio
data-client: getlist-100 172.41 ops/s (± 4.6%) 175.44 ops/s (± 3.9%) 1.02
data-client: getlist-500 47.39 ops/s (± 5.9%) 42.19 ops/s (± 6.0%) 0.89
data-client: update-entity 434.78 ops/s (± 5.7%) 416.67 ops/s (± 4.6%) 0.96
data-client: update-user 425.72 ops/s (± 8.4%) 434.78 ops/s (± 0.0%) 1.02
data-client: getlist-500-sorted 52.49 ops/s (± 6.5%) 50.76 ops/s (± 4.4%) 0.97
data-client: update-entity-sorted 400 ops/s (± 5.0%) 370.37 ops/s (± 5.3%) 0.93
data-client: update-entity-multi-view 370.37 ops/s (± 5.3%) 363.76 ops/s (± 7.4%) 0.98
data-client: list-detail-switch-10 14.26 ops/s (± 1.2%) 11.35 ops/s (± 3.4%) 0.80
data-client: update-user-10000 103.09 ops/s (± 3.7%) 99.02 ops/s (± 5.2%) 0.96
data-client: invalidate-and-resolve 52.08 ops/s (± 4.1%) 49.75 ops/s (± 3.7%) 0.96
data-client: unshift-item 285.71 ops/s (± 3.5%) 277.78 ops/s (± 4.1%) 0.97
data-client: delete-item 384.62 ops/s (± 6.3%) 400 ops/s (± 4.3%) 1.04
data-client: move-item 227.27 ops/s (± 4.4%) 212.77 ops/s (± 8.5%) 0.94

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

Details
Benchmark suite Current: d6ce627 Previous: 869f28f Ratio
normalizeLong 442 ops/sec (±1.70%) 451 ops/sec (±1.44%) 1.02
normalizeLong Values 405 ops/sec (±0.61%) 417 ops/sec (±0.23%) 1.03
denormalizeLong 267 ops/sec (±2.74%) 281 ops/sec (±2.20%) 1.05
denormalizeLong Values 257 ops/sec (±2.15%) 251 ops/sec (±2.29%) 0.98
denormalizeLong donotcache 1006 ops/sec (±0.10%) 1021 ops/sec (±0.37%) 1.01
denormalizeLong Values donotcache 729 ops/sec (±0.19%) 751 ops/sec (±0.19%) 1.03
denormalizeShort donotcache 500x 1574 ops/sec (±0.13%) 1585 ops/sec (±0.10%) 1.01
denormalizeShort 500x 841 ops/sec (±2.13%) 826 ops/sec (±2.18%) 0.98
denormalizeShort 500x withCache 6407 ops/sec (±0.27%) 6196 ops/sec (±0.14%) 0.97
queryShort 500x withCache 2691 ops/sec (±0.09%) 2683 ops/sec (±0.17%) 1.00
buildQueryKey All 53184 ops/sec (±0.35%) 53826 ops/sec (±0.29%) 1.01
query All withCache 5890 ops/sec (±0.29%) 6325 ops/sec (±0.24%) 1.07
denormalizeLong with mixin Entity 264 ops/sec (±2.26%) 268 ops/sec (±2.39%) 1.02
denormalizeLong withCache 6945 ops/sec (±0.16%) 6214 ops/sec (±0.27%) 0.89
denormalizeLong Values withCache 5061 ops/sec (±0.17%) 5006 ops/sec (±0.40%) 0.99
denormalizeLong All withCache 5726 ops/sec (±0.11%) 6028 ops/sec (±0.40%) 1.05
denormalizeLong Query-sorted withCache 5976 ops/sec (±0.15%) 6277 ops/sec (±0.23%) 1.05
denormalizeLongAndShort withEntityCacheOnly 1706 ops/sec (±0.19%) 1630 ops/sec (±0.22%) 0.96
denormalize bidirectional 50 2726 ops/sec (±1.87%) 2706 ops/sec (±1.72%) 0.99
denormalize bidirectional 50 donotcache 27387 ops/sec (±0.41%) 27107 ops/sec (±0.61%) 0.99
getResponse 4484 ops/sec (±0.42%) 4579 ops/sec (±0.51%) 1.02
getResponse (null) 9959491 ops/sec (±0.91%) 10510816 ops/sec (±0.97%) 1.06
getResponse (clear cache) 261 ops/sec (±2.20%) 257 ops/sec (±2.26%) 0.98
getSmallResponse 3279 ops/sec (±0.13%) 3366 ops/sec (±0.14%) 1.03
getSmallInferredResponse 2527 ops/sec (±0.09%) 2483 ops/sec (±0.31%) 0.98
getResponse Collection 4534 ops/sec (±0.39%) 4595 ops/sec (±0.38%) 1.01
get Collection 4542 ops/sec (±0.24%) 4531 ops/sec (±0.45%) 1.00
get Query-sorted 5171 ops/sec (±0.16%) 5174 ops/sec (±0.15%) 1.00
setLong 445 ops/sec (±0.47%) 461 ops/sec (±0.24%) 1.04
setLongWithMerge 249 ops/sec (±0.19%) 259 ops/sec (±0.25%) 1.04
setLongWithSimpleMerge 264 ops/sec (±0.14%) 274 ops/sec (±0.38%) 1.04
setSmallResponse 500x 945 ops/sec (±0.09%) 946 ops/sec (±0.13%) 1.00

This comment was automatically generated by workflow using github-action-benchmark.

Replaced shallow tests with thorough scenario-based tests (27 total):

- Round-trip: normalize API data → denormalize parent (Lazy stays raw) →
  LazyQuery resolves to full entities with all fields checked
- Mixed schema: non-Lazy Manager resolves alongside Lazy buildings on
  same entity; verified instanceof, field values, paths
- Dependency tracking: parent paths include Manager but exclude Building;
  LazyQuery paths include Building PKs but exclude Department
- LazyQuery edge cases: subset IDs, empty array, missing entity IDs
  filtered out, single Entity delegation via Building.queryKey
- Memoization isolation: parent ref equality preserved when Building
  changes; LazyQuery result updates when entity changes; ref equality
  maintained on unchanged state
- Nested Lazy: resolved Building still has its own Lazy rooms as raw IDs;
  second-level LazyQuery resolves Room entities
- Bidirectional Lazy: 1500-node chain no overflow; step-through resolution
  verifying each level's Lazy field stays raw while resolved entity is correct
- Lazy.queryKey returns undefined (not queryable directly)

Co-authored-by: natmaster <natmaster@gmail.com>
@ntucker ntucker marked this pull request as ready for review March 28, 2026 03:14
@jayseo5953
Copy link
Copy Markdown

jayseo5953 commented Mar 28, 2026

@ntucker
Thank you for your prompt turnover again!

Usability concern

The Lazy schema solves the crash, but it changes the consumer contract. Today, every relationship field returns resolved entities — dept.buildings[0].name works uniformly. With Lazy, consumers need to:

  1. Know which fields are lazy vs eager
  2. Call useQuery for lazy fields but not for eager ones
  3. Handle the different access patterns (string[] vs Entity[]) for what are conceptually the same thing — relationships

This leaks a performance optimization into the domain model and every component that touches a lazy field.

Questions:

  1. Is there a way to internalize the useQuery resolution into the entity itself, so that relationship access fires resolution transparently? This would preserve the existing consumer contract (dept.buildings[0].name always returns a resolved Entity). We attempted this with an Entity.denormalize override using ES5 getters — it eliminated the crash but broke cache invalidation because dependency registration is coupled to unvisit. See our detailed writeup: Lazy relationship resolution for large bidirectional entity graphs #3828

  2. Is there a path to eagerly register dependencies during the denormalization pass (shallow walk of schema fields to record referenced PKs) without eagerly resolving entities? This would allow lazy resolution while keeping the dependency list complete for cache invalidation.

- Prefix unused params with _ to satisfy @typescript-eslint/no-unused-vars
- Fix prettier formatting (auto-fixed via eslint --fix)
- Fix import order in schema.d.ts
- Remove unused imports in test file
- Add changeset for @data-client/endpoint, rest, graphql (minor)

Co-authored-by: natmaster <natmaster@gmail.com>
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 28, 2026

Codecov Report

❌ Patch coverage is 94.11765% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 98.05%. Comparing base (97ee20f) to head (2c9db86).

Files with missing lines Patch % Lines
packages/endpoint/src/schemas/Lazy.ts 93.75% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3829      +/-   ##
==========================================
- Coverage   98.07%   98.05%   -0.03%     
==========================================
  Files         151      152       +1     
  Lines        2855     2872      +17     
  Branches      561      563       +2     
==========================================
+ Hits         2800     2816      +16     
- Misses         11       12       +1     
  Partials       44       44              

☔ 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

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

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

…, Values)

When the inner schema is an explicit class instance (e.g. new schema.Array(Building)),
LazyQuery.queryKey would delegate to the inner schema's queryKey which always returns
undefined for Array and Values schemas. This caused MemoCache.query to short-circuit
and return no data, because the args[0] fallback was never reached.

Fix: only delegate to inner schema's queryKey when schema.key exists (Entity, Collection),
which distinguishes schemas with meaningful queryKey logic from container schemas
(Array, Values) that have no-op stubs.

Also fixes pre-existing TypeScript errors in Lazy.test.ts and adds tests for explicit
schema.Array and schema.Values inner schemas.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
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.

3 participants