Skip to content

feat: add Lazy schema for deferred relationship denormalization#3829

Open
ntucker wants to merge 4 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 4 commits intomasterfrom
cursor/2-test-case-creation-with-synthetic-data-6097

Conversation

@ntucker
Copy link
Copy Markdown
Collaborator

@ntucker ntucker commented Mar 28, 2026

Fixes #3822.

Motivation

Large bidirectional entity graphs (e.g., Department <-> Building chains of 1500+ nodes) cause RangeError: Maximum call stack size exceeded during recursive denormalization. Even when cycles are detected, long acyclic chains exceed JavaScript's call stack limit.

Beyond stack safety, eagerly denormalizing large relationship graphs is often unnecessary — components may only need the parent entity's scalar fields, with relationships resolved on demand.

Solution

Introduces schema.Lazy(innerSchema) — a schema wrapper that skips eager denormalization:

Lazy (used in Entity's static schema):

  • normalize: delegates to inner schema (entities stored normally in entity tables)
  • denormalize: no-op — returns raw PKs/IDs unchanged
  • No changes needed to EntityMixin, unvisit, or any existing code paths

LazyQuery (accessed via .query getter, for use with useQuery):

  • queryKey: delegates to inner schema's queryKey if available (Entity, Collection), otherwise passes through args[0] (for array schemas where user passes raw IDs directly)
  • denormalize: delegates to inner schema via unvisit (full entity resolution)

Key design decisions:

  • No parent binding needed — the user passes raw normalized values (already available from parent denormalization) as args to useQuery
  • Lazy.denormalize as no-op means zero changes to EntityMixin's denormalize loop — no instanceof checks, no marker properties, no precomputed key lists
  • Parent memo is stable when lazy entities change (lazy entities excluded from parent's dependency tracking)

Usage:

class Department extends Entity {
  buildings: string[] = [];
  static schema = {
    buildings: new schema.Lazy([Building]),
  };
}

// Parent denorm: dept.buildings = ['bldg-1', 'bldg-2'] (raw PKs)
const dept = useSuspense(DepartmentResource.get, { id: '1' });

// Resolve lazily via useQuery
const buildings = useQuery(Department.schema.buildings.query, dept.buildings);

Test coverage (27 tests):

  • Round-trip: normalize API data → denormalize parent → LazyQuery resolves full entities
  • Mixed schemas: non-Lazy Manager resolves alongside Lazy buildings; dependency paths verified
  • LazyQuery edge cases: subset IDs, empty arrays, missing entities filtered, Entity queryKey delegation
  • Memoization isolation: parent stable on lazy entity change; LazyQuery updates; ref equality on unchanged state
  • Nested Lazy: resolved Building retains its own Lazy rooms as raw IDs; second-level resolution works
  • Bidirectional Lazy: 1500-node chain no overflow; step-through resolution at each level

Open questions

  • Args typing for LazyQuery.queryKey: currently readonly any[]. Could be refined with conditional types to match inner schema's args when it has queryKey, or readonly [any] for array pass-through.
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

⚠️ No Changeset found

Latest commit: c1424e7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a 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 2:46am

@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: c1424e7 Previous: 869f28f Ratio
data-client: getlist-100 181.88 ops/s (± 4.5%) 175.44 ops/s (± 3.9%) 0.96
data-client: getlist-500 46.08 ops/s (± 1.7%) 42.19 ops/s (± 6.0%) 0.92
data-client: update-entity 500 ops/s (± 2.6%) 416.67 ops/s (± 4.6%) 0.83
data-client: update-user 444.66 ops/s (± 8.3%) 434.78 ops/s (± 0.0%) 0.98
data-client: getlist-500-sorted 51.81 ops/s (± 3.1%) 50.76 ops/s (± 4.4%) 0.98
data-client: update-entity-sorted 434.78 ops/s (± 4.1%) 370.37 ops/s (± 5.3%) 0.85
data-client: update-entity-multi-view 400 ops/s (± 5.2%) 363.76 ops/s (± 7.4%) 0.91
data-client: list-detail-switch-10 11.95 ops/s (± 8.6%) 11.35 ops/s (± 3.4%) 0.95
data-client: update-user-10000 103.1 ops/s (± 2.4%) 99.02 ops/s (± 5.2%) 0.96
data-client: invalidate-and-resolve 51.3 ops/s (± 3.6%) 49.75 ops/s (± 3.7%) 0.97
data-client: unshift-item 333.33 ops/s (± 3.9%) 277.78 ops/s (± 4.1%) 0.83
data-client: delete-item 434.78 ops/s (± 5.4%) 400 ops/s (± 4.3%) 0.92
data-client: move-item 235.33 ops/s (± 5.4%) 212.77 ops/s (± 8.5%) 0.90

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: c1424e7 Previous: 869f28f Ratio
normalizeLong 454 ops/sec (±1.27%) 451 ops/sec (±1.44%) 0.99
normalizeLong Values 415 ops/sec (±0.40%) 417 ops/sec (±0.23%) 1.00
denormalizeLong 288 ops/sec (±3.08%) 281 ops/sec (±2.20%) 0.98
denormalizeLong Values 259 ops/sec (±2.81%) 251 ops/sec (±2.29%) 0.97
denormalizeLong donotcache 1031 ops/sec (±0.26%) 1021 ops/sec (±0.37%) 0.99
denormalizeLong Values donotcache 762 ops/sec (±0.19%) 751 ops/sec (±0.19%) 0.99
denormalizeShort donotcache 500x 1553 ops/sec (±0.22%) 1585 ops/sec (±0.10%) 1.02
denormalizeShort 500x 838 ops/sec (±2.14%) 826 ops/sec (±2.18%) 0.99
denormalizeShort 500x withCache 6172 ops/sec (±0.22%) 6196 ops/sec (±0.14%) 1.00
queryShort 500x withCache 2757 ops/sec (±0.13%) 2683 ops/sec (±0.17%) 0.97
buildQueryKey All 53806 ops/sec (±0.42%) 53826 ops/sec (±0.29%) 1.00
query All withCache 7781 ops/sec (±0.32%) 6325 ops/sec (±0.24%) 0.81
denormalizeLong with mixin Entity 276 ops/sec (±2.16%) 268 ops/sec (±2.39%) 0.97
denormalizeLong withCache 6637 ops/sec (±0.19%) 6214 ops/sec (±0.27%) 0.94
denormalizeLong Values withCache 5016 ops/sec (±0.56%) 5006 ops/sec (±0.40%) 1.00
denormalizeLong All withCache 7492 ops/sec (±0.26%) 6028 ops/sec (±0.40%) 0.80
denormalizeLong Query-sorted withCache 7832 ops/sec (±0.23%) 6277 ops/sec (±0.23%) 0.80
denormalizeLongAndShort withEntityCacheOnly 1668 ops/sec (±0.26%) 1630 ops/sec (±0.22%) 0.98
denormalize bidirectional 50 2749 ops/sec (±1.72%) 2706 ops/sec (±1.72%) 0.98
denormalize bidirectional 50 donotcache 26542 ops/sec (±0.87%) 27107 ops/sec (±0.61%) 1.02
getResponse 4732 ops/sec (±0.56%) 4579 ops/sec (±0.51%) 0.97
getResponse (null) 10391985 ops/sec (±0.54%) 10510816 ops/sec (±0.97%) 1.01
getResponse (clear cache) 265 ops/sec (±1.98%) 257 ops/sec (±2.26%) 0.97
getSmallResponse 3131 ops/sec (±0.16%) 3366 ops/sec (±0.14%) 1.08
getSmallInferredResponse 2348 ops/sec (±0.11%) 2483 ops/sec (±0.31%) 1.06
getResponse Collection 4628 ops/sec (±0.65%) 4595 ops/sec (±0.38%) 0.99
get Collection 4582 ops/sec (±0.23%) 4531 ops/sec (±0.45%) 0.99
get Query-sorted 5244 ops/sec (±0.26%) 5174 ops/sec (±0.15%) 0.99
setLong 461 ops/sec (±0.22%) 461 ops/sec (±0.24%) 1
setLongWithMerge 264 ops/sec (±0.22%) 259 ops/sec (±0.25%) 0.98
setLongWithSimpleMerge 275 ops/sec (±0.21%) 274 ops/sec (±0.38%) 1.00
setSmallResponse 500x 932 ops/sec (±0.10%) 946 ops/sec (±0.13%) 1.02

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.

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.

Maximum call stack size exceeded during denormalization with large bidirectional entity graphs

3 participants