feat: add Lazy schema for deferred relationship denormalization#3829
feat: add Lazy schema for deferred relationship denormalization#3829
Conversation
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>
|
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Size Change: 0 B Total Size: 80.5 kB ℹ️ View Unchanged
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 Usability concern The
This leaks a performance optimization into the domain model and every component that touches a lazy field. Questions:
|
Fixes #3822.
Motivation
Large bidirectional entity graphs (e.g., Department <-> Building chains of 1500+ nodes) cause
RangeError: Maximum call stack size exceededduring 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'sstatic schema):normalize: delegates to inner schema (entities stored normally in entity tables)denormalize: no-op — returns raw PKs/IDs unchangedEntityMixin,unvisit, or any existing code pathsLazyQuery(accessed via.querygetter, for use withuseQuery):queryKey: delegates to inner schema'squeryKeyif available (Entity, Collection), otherwise passes throughargs[0](for array schemas where user passes raw IDs directly)denormalize: delegates to inner schema viaunvisit(full entity resolution)Key design decisions:
useQueryLazy.denormalizeas no-op means zero changes to EntityMixin's denormalize loop — noinstanceofchecks, no marker properties, no precomputed key listsUsage:
Test coverage (27 tests):
Open questions
LazyQuery.queryKey: currentlyreadonly any[]. Could be refined with conditional types to match inner schema's args when it hasqueryKey, orreadonly [any]for array pass-through.