From 8f62d24fd78ac6f66cdd8b1b5f6a56745afca8a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 02:30:49 +0000 Subject: [PATCH 1/7] feat: add Lazy schema class for deferred relationship denormalization 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 --- packages/endpoint/src/index.ts | 1 + packages/endpoint/src/schema.d.ts | 3 +- packages/endpoint/src/schema.js | 1 + packages/endpoint/src/schemas/Lazy.ts | 118 ++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 packages/endpoint/src/schemas/Lazy.ts diff --git a/packages/endpoint/src/index.ts b/packages/endpoint/src/index.ts index c25f73ebb747..e8f6a0258bf7 100644 --- a/packages/endpoint/src/index.ts +++ b/packages/endpoint/src/index.ts @@ -18,6 +18,7 @@ export { Query, Values, All, + Lazy, unshift, } from './schema.js'; // Without this we get 'cannot be named without a reference to' for resource()....why is this? diff --git a/packages/endpoint/src/schema.d.ts b/packages/endpoint/src/schema.d.ts index 13f16fbe7d5b..baa9e02c3d67 100644 --- a/packages/endpoint/src/schema.d.ts +++ b/packages/endpoint/src/schema.d.ts @@ -26,6 +26,7 @@ import { } from './schemas/EntityMixin.js'; import { default as Invalidate } from './schemas/Invalidate.js'; import { default as Query } from './schemas/Query.js'; +import { default as Lazy } from './schemas/Lazy.js'; import type { CollectionConstructor, DefaultArgs, @@ -34,7 +35,7 @@ import type { UnionResult, } from './schemaTypes.js'; -export { EntityMap, Invalidate, Query, EntityMixin, Entity }; +export { EntityMap, Invalidate, Query, Lazy, EntityMixin, Entity }; export type { SchemaClass }; diff --git a/packages/endpoint/src/schema.js b/packages/endpoint/src/schema.js index 8c9eceb1c1f6..03b5ccaca386 100644 --- a/packages/endpoint/src/schema.js +++ b/packages/endpoint/src/schema.js @@ -11,3 +11,4 @@ export { default as Entity, } from './schemas/EntityMixin.js'; export { default as Query } from './schemas/Query.js'; +export { default as Lazy } from './schemas/Lazy.js'; diff --git a/packages/endpoint/src/schemas/Lazy.ts b/packages/endpoint/src/schemas/Lazy.ts new file mode 100644 index 000000000000..a5ddac2f3770 --- /dev/null +++ b/packages/endpoint/src/schemas/Lazy.ts @@ -0,0 +1,118 @@ +import type { Schema, SchemaSimple } from '../interface.js'; +import type { Denormalize, DenormalizeNullable, NormalizeNullable } from '../normal.js'; + +/** + * Skips eager denormalization of a relationship field. + * Raw normalized values (PKs/IDs) pass through unchanged. + * Use `.query` with `useQuery` to resolve lazily. + * + * @see https://dataclient.io/rest/api/Lazy + */ +export default class Lazy + implements SchemaSimple +{ + declare schema: S; + + /** + * @param {Schema} schema - The inner schema (e.g., [Building], Building, Collection) + */ + constructor(schema: S) { + this.schema = schema; + } + + normalize( + input: any, + parent: any, + key: any, + args: any[], + visit: (...args: any) => any, + delegate: any, + ): any { + return visit(this.schema, input, parent, key, args); + } + + denormalize(input: {}, args: readonly any[], unvisit: any): any { + return input; + } + + queryKey( + args: readonly any[], + unvisit: (...args: any) => any, + delegate: any, + ): undefined { + return undefined; + } + + /** Queryable schema for use with useQuery() to resolve lazy relationships */ + get query(): LazyQuery { + if (!this._query) { + this._query = new LazyQuery(this.schema); + } + return this._query; + } + + private _query: LazyQuery | undefined; + + declare _denormalizeNullable: ( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ) => any; + + declare _normalizeNullable: () => NormalizeNullable; +} + +/** + * Resolves lazy relationships via useQuery(). + * + * queryKey delegates to inner schema's queryKey if available, + * otherwise passes through args[0] (the raw normalized value). + */ +export class LazyQuery + implements SchemaSimple, readonly any[]> +{ + declare schema: S; + + constructor(schema: S) { + this.schema = schema; + } + + normalize( + input: any, + parent: any, + key: any, + args: any[], + visit: (...args: any) => any, + delegate: any, + ): any { + return input; + } + + denormalize( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ): Denormalize { + return unvisit(this.schema, input); + } + + queryKey( + args: readonly any[], + unvisit: (...args: any) => any, + delegate: { getEntity: any; getIndex: any }, + ): any { + const schema = this.schema as any; + if (typeof schema.queryKey === 'function') { + return schema.queryKey(args, unvisit, delegate); + } + return args[0]; + } + + declare _denormalizeNullable: ( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ) => DenormalizeNullable; + + declare _normalizeNullable: () => NormalizeNullable; +} From a06601149a67106447f306017798b6776c35f7d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 02:37:18 +0000 Subject: [PATCH 2/7] test: add comprehensive tests for Lazy schema 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 --- .../src/schemas/__tests__/Lazy.test.ts | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 packages/endpoint/src/schemas/__tests__/Lazy.test.ts diff --git a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts new file mode 100644 index 000000000000..8b1300037455 --- /dev/null +++ b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts @@ -0,0 +1,318 @@ +import { normalize, MemoCache } from '@data-client/normalizr'; +import { denormalize as plainDenormalize } from '@data-client/normalizr'; +import { IDEntity } from '__tests__/new'; + +import { SimpleMemoCache } from './denormalize'; +import { schema } from '../..'; +import Entity from '../Entity'; + +let dateSpy: jest.Spied; +beforeAll(() => { + dateSpy = jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date('2019-05-14T11:01:58.135Z').valueOf()); +}); +afterAll(() => { + dateSpy.mockRestore(); +}); + +class Building extends IDEntity { + readonly name: string = ''; +} + +class Department extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + + static schema = { + buildings: new schema.Lazy([Building]), + }; +} + +class SingleRefDepartment extends IDEntity { + readonly name: string = ''; + readonly mainBuilding: string = ''; + + static schema = { + mainBuilding: new schema.Lazy(Building), + }; +} + +describe('Lazy schema', () => { + const sampleData = { + id: 'dept-1', + name: 'Engineering', + buildings: [ + { id: 'bldg-1', name: 'Building A' }, + { id: 'bldg-2', name: 'Building B' }, + ], + }; + + describe('normalization', () => { + test('normalizes inner entities through Lazy wrapper', () => { + const result = normalize(Department, sampleData, []); + expect(result.result).toBe('dept-1'); + expect(result.entities.Department['dept-1']).toEqual({ + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1', 'bldg-2'], + }); + expect(result.entities.Building['bldg-1']).toEqual({ + id: 'bldg-1', + name: 'Building A', + }); + expect(result.entities.Building['bldg-2']).toEqual({ + id: 'bldg-2', + name: 'Building B', + }); + }); + + test('normalizes single entity through Lazy wrapper', () => { + const result = normalize( + SingleRefDepartment, + { + id: 'dept-1', + name: 'Engineering', + mainBuilding: { id: 'bldg-1', name: 'HQ' }, + }, + [], + ); + expect(result.result).toBe('dept-1'); + expect(result.entities.SingleRefDepartment['dept-1'].mainBuilding).toBe( + 'bldg-1', + ); + expect(result.entities.Building['bldg-1']).toEqual({ + id: 'bldg-1', + name: 'HQ', + }); + }); + }); + + describe('denormalization', () => { + const entities = { + Department: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1', 'bldg-2'], + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A' }, + 'bldg-2': { id: 'bldg-2', name: 'Building B' }, + }, + }; + + test('Lazy field leaves raw IDs unchanged (plainDenormalize)', () => { + const dept: any = plainDenormalize(Department, 'dept-1', entities); + expect(dept).toBeDefined(); + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(typeof dept.buildings[0]).toBe('string'); + }); + + test('Lazy field leaves raw IDs unchanged (SimpleMemoCache)', () => { + const memo = new SimpleMemoCache(); + const dept: any = memo.denormalize(Department, 'dept-1', entities); + expect(dept).toBeDefined(); + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(typeof dept.buildings[0]).toBe('string'); + }); + + test('single entity Lazy field leaves raw PK', () => { + const singleEntities = { + SingleRefDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + mainBuilding: 'bldg-1', + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'HQ' }, + }, + }; + const dept: any = plainDenormalize( + SingleRefDepartment, + 'dept-1', + singleEntities, + ); + expect(dept.mainBuilding).toBe('bldg-1'); + }); + + test('parent denormalization does not track lazy entity dependencies', () => { + const memo = new MemoCache(); + const result1 = memo.denormalize( + Department, + 'dept-1', + entities, + ); + expect(result1.data).toBeDefined(); + const deptPaths = result1.paths; + const buildingPaths = deptPaths.filter(p => p.key === 'Building'); + expect(buildingPaths).toHaveLength(0); + const deptPaths2 = deptPaths.filter(p => p.key === 'Department'); + expect(deptPaths2).toHaveLength(1); + }); + }); + + describe('.query (LazyQuery)', () => { + const state = { + entities: { + Department: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1', 'bldg-2'], + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A' }, + 'bldg-2': { id: 'bldg-2', name: 'Building B' }, + }, + }, + indexes: {}, + }; + + test('.query returns a LazyQuery instance', () => { + const lazyField = Department.schema.buildings; + expect(lazyField).toBeInstanceOf(schema.Lazy); + expect(lazyField.query).toBeDefined(); + expect(lazyField.query.queryKey).toBeInstanceOf(Function); + expect(lazyField.query.denormalize).toBeInstanceOf(Function); + }); + + test('.query getter returns same instance', () => { + const lazyField = Department.schema.buildings; + expect(lazyField.query).toBe(lazyField.query); + }); + + test('LazyQuery resolves array of IDs via MemoCache.query', () => { + const lazyQuery = Department.schema.buildings.query; + const memo = new MemoCache(); + const result = memo.query(lazyQuery, [['bldg-1', 'bldg-2']], state); + expect(result.data).toBeDefined(); + if (typeof result.data === 'symbol') return; + const buildings = result.data as any[]; + expect(buildings).toHaveLength(2); + expect(buildings[0].id).toBe('bldg-1'); + expect(buildings[0].name).toBe('Building A'); + expect(buildings[1].id).toBe('bldg-2'); + expect(buildings[1].name).toBe('Building B'); + }); + + test('LazyQuery tracks Building entity dependencies', () => { + const lazyQuery = Department.schema.buildings.query; + const memo = new MemoCache(); + const result = memo.query(lazyQuery, [['bldg-1', 'bldg-2']], state); + const buildingPaths = result.paths.filter(p => p.key === 'Building'); + expect(buildingPaths.length).toBeGreaterThanOrEqual(2); + }); + + test('LazyQuery with Entity inner schema delegates queryKey', () => { + const lazyField = SingleRefDepartment.schema.mainBuilding; + const lazyQuery = lazyField.query; + const memo = new MemoCache(); + const result = memo.query(lazyQuery, [{ id: 'bldg-1' }], state); + expect(result.data).toBeDefined(); + if (typeof result.data === 'symbol') return; + expect((result.data as any).id).toBe('bldg-1'); + expect((result.data as any).name).toBe('Building A'); + }); + + test('LazyQuery returns undefined for missing entity', () => { + const lazyQuery = SingleRefDepartment.schema.mainBuilding.query; + const memo = new MemoCache(); + const result = memo.query(lazyQuery, [{ id: 'nonexistent' }], state); + expect(result.data).toBeUndefined(); + }); + + test('LazyQuery returns empty array for empty IDs', () => { + const lazyQuery = Department.schema.buildings.query; + const memo = new MemoCache(); + const result = memo.query(lazyQuery, [[]], state); + expect(result.data).toEqual([]); + }); + }); + + describe('memoization isolation', () => { + test('parent memo is stable when lazy entity changes', () => { + const entities1 = { + Department: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1'], + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A' }, + }, + }; + const entities2 = { + Department: entities1.Department, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A UPDATED' }, + }, + }; + + const memo = new MemoCache(); + const result1 = memo.denormalize(Department, 'dept-1', entities1); + const result2 = memo.denormalize(Department, 'dept-1', entities2); + expect(result1.data).toBe(result2.data); + }); + }); + + describe('does not overflow stack with large bidirectional graphs', () => { + test('large chain with Lazy fields does not overflow', () => { + class LazyDepartment extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + } + class LazyBuilding extends IDEntity { + readonly name: string = ''; + readonly departments: string[] = []; + } + LazyDepartment.schema = { + buildings: new schema.Lazy([LazyBuilding]), + }; + LazyBuilding.schema = { + departments: new schema.Lazy([LazyDepartment]), + }; + + const CHAIN_LENGTH = 1500; + const departmentEntities: Record = {}; + const buildingEntities: Record = {}; + + for (let i = 0; i < CHAIN_LENGTH; i++) { + departmentEntities[`dept-${i}`] = { + id: `dept-${i}`, + name: `Department ${i}`, + buildings: [`bldg-${i}`], + }; + buildingEntities[`bldg-${i}`] = { + id: `bldg-${i}`, + name: `Building ${i}`, + departments: i < CHAIN_LENGTH - 1 ? [`dept-${i + 1}`] : [], + }; + } + + const entities = { + LazyDepartment: departmentEntities, + LazyBuilding: buildingEntities, + }; + + expect(() => + plainDenormalize(LazyDepartment, 'dept-0', entities), + ).not.toThrow(); + + const memo = new SimpleMemoCache(); + expect(() => + memo.denormalize(LazyDepartment, 'dept-0', entities), + ).not.toThrow(); + + const dept: any = plainDenormalize(LazyDepartment, 'dept-0', entities); + expect(dept.buildings).toEqual(['bldg-0']); + expect(typeof dept.buildings[0]).toBe('string'); + }); + }); +}); From e0d681cc7b64fa4ae19a54611a66e11946e6b0d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 02:38:38 +0000 Subject: [PATCH 3/7] docs: add API documentation for Lazy schema 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 --- docs/rest/api/Lazy.md | 134 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/rest/api/Lazy.md diff --git a/docs/rest/api/Lazy.md b/docs/rest/api/Lazy.md new file mode 100644 index 000000000000..58fb3a140977 --- /dev/null +++ b/docs/rest/api/Lazy.md @@ -0,0 +1,134 @@ +--- +title: Lazy Schema - Deferred Relationship Denormalization +sidebar_label: Lazy +--- + +# Lazy + +`Lazy` wraps a schema to skip eager denormalization of relationship fields. During parent entity denormalization, the field retains its raw normalized value (primary keys/IDs). The relationship can then be resolved on demand via [useQuery](/docs/api/useQuery) using the `.query` accessor. + +This is useful for: +- **Large bidirectional graphs** that would overflow the call stack during recursive denormalization +- **Performance optimization** by deferring resolution of relationships that aren't always needed +- **Memoization isolation** — changes to lazy entities don't invalidate the parent's denormalized form + +## Constructor + +```typescript +new schema.Lazy(innerSchema) +``` + +- `innerSchema`: Any [Schema](/rest/api/schema) — an [Entity](./Entity.md), an array shorthand like `[MyEntity]`, a [Collection](./Collection.md), etc. + +## Usage + +### Array relationship (most common) + +```typescript +import { Entity, schema } from '@data-client/rest'; + +class Building extends Entity { + id = ''; + name = ''; +} + +class Department extends Entity { + id = ''; + name = ''; + buildings: string[] = []; + + static schema = { + buildings: new schema.Lazy([Building]), + }; +} +``` + +When a `Department` is denormalized, `dept.buildings` will contain raw primary keys (e.g., `['bldg-1', 'bldg-2']`) instead of resolved `Building` instances. + +To resolve the buildings, use [useQuery](/docs/api/useQuery) with the `.query` accessor: + +```tsx +function DepartmentBuildings({ dept }: { dept: Department }) { + // dept.buildings contains raw IDs: ['bldg-1', 'bldg-2'] + const buildings = useQuery(Department.schema.buildings.query, dept.buildings); + // buildings: Building[] | undefined + + if (!buildings) return null; + return ( +
    + {buildings.map(b =>
  • {b.name}
  • )} +
+ ); +} +``` + +### Single entity relationship + +```typescript +class Department extends Entity { + id = ''; + name = ''; + mainBuilding = ''; + + static schema = { + mainBuilding: new schema.Lazy(Building), + }; +} +``` + +```tsx +// dept.mainBuilding is a raw PK string: 'bldg-1' +const building = useQuery( + Department.schema.mainBuilding.query, + { id: dept.mainBuilding }, +); +``` + +When the inner schema is an [Entity](./Entity.md) (or any schema with `queryKey`), `LazyQuery` delegates to its `queryKey` — so you pass the same args you'd use to query that entity directly. + +### Collection relationship + +```typescript +class Department extends Entity { + id = ''; + static schema = { + buildings: new schema.Lazy(buildingsCollection), + }; +} +``` + +```tsx +const buildings = useQuery( + Department.schema.buildings.query, + ...collectionArgs, +); +``` + +## `.query` + +Returns a `LazyQuery` instance suitable for [useQuery](/docs/api/useQuery). The `LazyQuery`: + +- **`queryKey(args)`** — If the inner schema has a `queryKey` (Entity, Collection, etc.), delegates to it. Otherwise returns `args[0]` directly (for array/object schemas where you pass the raw normalized value). +- **`denormalize(input, args, unvisit)`** — Delegates to the inner schema, resolving IDs into full entity instances. + +The `.query` getter always returns the same instance (cached). + +## How it works + +### Normalization + +`Lazy.normalize` delegates to the inner schema. Entities are stored in the normalized entity tables as usual — `Lazy` has no effect on normalization. + +### Denormalization (parent path) + +`Lazy.denormalize` is a **no-op** — it returns the input unchanged. When `EntityMixin.denormalize` iterates over schema fields and encounters a `Lazy` field, the `unvisit` dispatch calls `Lazy.denormalize`, which simply passes through the raw PKs. No nested entities are visited, no dependencies are registered in the cache. + +### Denormalization (useQuery path) + +When using `useQuery(lazyField.query, ...)`, `LazyQuery.denormalize` delegates to the inner schema via `unvisit`, resolving IDs into full entity instances through the normal denormalization pipeline. This runs in its own `MemoCache.query()` scope with independent dependency tracking and GC. + +## Performance characteristics + +- **Parent denormalization**: Fewer dependency hops (lazy entities excluded from deps). Faster cache hits. No invalidation when lazy entities change. +- **useQuery access**: Own memo scope with own `paths` and `countRef`. Changes to lazy entities only re-render components that called `useQuery`, not the parent. +- **No Proxy/getter overhead**: Raw IDs are plain values. Full resolution only happens through `useQuery`, using the normal denormalization path. From c1424e73efa541296663c1b5cad2770abb2929be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 02:46:01 +0000 Subject: [PATCH 4/7] test: rewrite Lazy tests with full scenario coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/schemas/__tests__/Lazy.test.ts | 540 ++++++++++++++---- 1 file changed, 429 insertions(+), 111 deletions(-) diff --git a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts index 8b1300037455..4f77c781e3c5 100644 --- a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts @@ -1,4 +1,4 @@ -import { normalize, MemoCache } from '@data-client/normalizr'; +import { normalize, MemoCache, INVALID } from '@data-client/normalizr'; import { denormalize as plainDenormalize } from '@data-client/normalizr'; import { IDEntity } from '__tests__/new'; @@ -18,14 +18,21 @@ afterAll(() => { class Building extends IDEntity { readonly name: string = ''; + readonly floors: number = 1; +} + +class Manager extends IDEntity { + readonly name: string = ''; } class Department extends IDEntity { readonly name: string = ''; readonly buildings: string[] = []; + readonly manager = Manager.fromJS(); static schema = { buildings: new schema.Lazy([Building]), + manager: Manager, }; } @@ -39,86 +46,164 @@ class SingleRefDepartment extends IDEntity { } describe('Lazy schema', () => { - const sampleData = { - id: 'dept-1', - name: 'Engineering', - buildings: [ - { id: 'bldg-1', name: 'Building A' }, - { id: 'bldg-2', name: 'Building B' }, - ], - }; + describe('normalize → denormalize round-trip', () => { + const apiResponse = { + id: 'dept-1', + name: 'Engineering', + manager: { id: 'mgr-1', name: 'Alice' }, + buildings: [ + { id: 'bldg-1', name: 'Building A', floors: 3 }, + { id: 'bldg-2', name: 'Building B', floors: 5 }, + ], + }; + + test('normalize stores entities correctly through Lazy', () => { + const result = normalize(Department, apiResponse, []); - describe('normalization', () => { - test('normalizes inner entities through Lazy wrapper', () => { - const result = normalize(Department, sampleData, []); expect(result.result).toBe('dept-1'); - expect(result.entities.Department['dept-1']).toEqual({ - id: 'dept-1', - name: 'Engineering', - buildings: ['bldg-1', 'bldg-2'], - }); + expect(Object.keys(result.entities.Building)).toEqual([ + 'bldg-1', + 'bldg-2', + ]); expect(result.entities.Building['bldg-1']).toEqual({ id: 'bldg-1', name: 'Building A', + floors: 3, }); expect(result.entities.Building['bldg-2']).toEqual({ id: 'bldg-2', name: 'Building B', + floors: 5, + }); + expect(result.entities.Manager['mgr-1']).toEqual({ + id: 'mgr-1', + name: 'Alice', }); + expect(result.entities.Department['dept-1'].buildings).toEqual([ + 'bldg-1', + 'bldg-2', + ]); + expect(result.entities.Department['dept-1'].manager).toBe('mgr-1'); + }); + + test('denormalize resolves non-Lazy fields but keeps Lazy fields as raw IDs', () => { + const { result, entities } = normalize(Department, apiResponse, []); + const dept: any = plainDenormalize(Department, result, entities); + + expect(dept.id).toBe('dept-1'); + expect(dept.name).toBe('Engineering'); + // non-Lazy field Manager is fully resolved + expect(dept.manager).toBeInstanceOf(Manager); + expect(dept.manager.id).toBe('mgr-1'); + expect(dept.manager.name).toBe('Alice'); + // Lazy field buildings stays as raw PK array + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(typeof dept.buildings[0]).toBe('string'); + expect(typeof dept.buildings[1]).toBe('string'); }); - test('normalizes single entity through Lazy wrapper', () => { + test('full pipeline: normalize → parent denorm → LazyQuery resolves', () => { + const { result, entities } = normalize(Department, apiResponse, []); + const dept: any = plainDenormalize(Department, result, entities); + + // Parent has raw IDs + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + + // Use LazyQuery to resolve those IDs into full Building entities + const memo = new MemoCache(); + const state = { entities, indexes: {} }; + const queryResult = memo.query( + Department.schema.buildings.query, + [dept.buildings], + state, + ); + const buildings = queryResult.data as any[]; + expect(buildings).toHaveLength(2); + expect(buildings[0]).toBeInstanceOf(Building); + expect(buildings[0].id).toBe('bldg-1'); + expect(buildings[0].name).toBe('Building A'); + expect(buildings[0].floors).toBe(3); + expect(buildings[1]).toBeInstanceOf(Building); + expect(buildings[1].id).toBe('bldg-2'); + expect(buildings[1].name).toBe('Building B'); + expect(buildings[1].floors).toBe(5); + }); + }); + + describe('normalization', () => { + test('single entity ref normalizes correctly through Lazy', () => { const result = normalize( SingleRefDepartment, { id: 'dept-1', name: 'Engineering', - mainBuilding: { id: 'bldg-1', name: 'HQ' }, + mainBuilding: { id: 'bldg-1', name: 'HQ', floors: 10 }, }, [], ); - expect(result.result).toBe('dept-1'); expect(result.entities.SingleRefDepartment['dept-1'].mainBuilding).toBe( 'bldg-1', ); expect(result.entities.Building['bldg-1']).toEqual({ id: 'bldg-1', name: 'HQ', + floors: 10, }); }); + + test('normalizing Lazy field with empty array', () => { + const result = normalize( + Department, + { + id: 'dept-empty', + name: 'Empty Dept', + manager: { id: 'mgr-1', name: 'Bob' }, + buildings: [], + }, + [], + ); + expect(result.entities.Department['dept-empty'].buildings).toEqual([]); + expect(result.entities.Building).toBeUndefined(); + }); }); - describe('denormalization', () => { + describe('denormalization preserves raw IDs', () => { const entities = { Department: { 'dept-1': { id: 'dept-1', name: 'Engineering', buildings: ['bldg-1', 'bldg-2'], + manager: 'mgr-1', }, }, Building: { - 'bldg-1': { id: 'bldg-1', name: 'Building A' }, - 'bldg-2': { id: 'bldg-2', name: 'Building B' }, + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + Manager: { + 'mgr-1': { id: 'mgr-1', name: 'Alice' }, }, }; - test('Lazy field leaves raw IDs unchanged (plainDenormalize)', () => { + test('plainDenormalize keeps Lazy array as string IDs', () => { const dept: any = plainDenormalize(Department, 'dept-1', entities); - expect(dept).toBeDefined(); expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); - expect(typeof dept.buildings[0]).toBe('string'); + expect(dept.buildings[0]).not.toBeInstanceOf(Building); + // non-Lazy Manager IS resolved + expect(dept.manager).toBeInstanceOf(Manager); + expect(dept.manager.name).toBe('Alice'); }); - test('Lazy field leaves raw IDs unchanged (SimpleMemoCache)', () => { + test('SimpleMemoCache keeps Lazy array as string IDs', () => { const memo = new SimpleMemoCache(); const dept: any = memo.denormalize(Department, 'dept-1', entities); - expect(dept).toBeDefined(); + expect(typeof dept).toBe('object'); expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); - expect(typeof dept.buildings[0]).toBe('string'); + expect(dept.manager).toBeInstanceOf(Manager); }); - test('single entity Lazy field leaves raw PK', () => { + test('single entity Lazy field stays as string PK', () => { const singleEntities = { SingleRefDepartment: { 'dept-1': { @@ -128,7 +213,7 @@ describe('Lazy schema', () => { }, }, Building: { - 'bldg-1': { id: 'bldg-1', name: 'HQ' }, + 'bldg-1': { id: 'bldg-1', name: 'HQ', floors: 10 }, }, }; const dept: any = plainDenormalize( @@ -137,25 +222,20 @@ describe('Lazy schema', () => { singleEntities, ); expect(dept.mainBuilding).toBe('bldg-1'); + expect(typeof dept.mainBuilding).toBe('string'); }); - test('parent denormalization does not track lazy entity dependencies', () => { + test('parent paths exclude lazy entity dependencies', () => { const memo = new MemoCache(); - const result1 = memo.denormalize( - Department, - 'dept-1', - entities, - ); - expect(result1.data).toBeDefined(); - const deptPaths = result1.paths; - const buildingPaths = deptPaths.filter(p => p.key === 'Building'); - expect(buildingPaths).toHaveLength(0); - const deptPaths2 = deptPaths.filter(p => p.key === 'Department'); - expect(deptPaths2).toHaveLength(1); + const result = memo.denormalize(Department, 'dept-1', entities); + expect(result.paths.some(p => p.key === 'Building')).toBe(false); + expect(result.paths.some(p => p.key === 'Department')).toBe(true); + // Manager IS in paths because it's a non-Lazy field + expect(result.paths.some(p => p.key === 'Manager')).toBe(true); }); }); - describe('.query (LazyQuery)', () => { + describe('LazyQuery resolution via .query', () => { const state = { entities: { Department: { @@ -163,127 +243,317 @@ describe('Lazy schema', () => { id: 'dept-1', name: 'Engineering', buildings: ['bldg-1', 'bldg-2'], + manager: 'mgr-1', }, }, Building: { - 'bldg-1': { id: 'bldg-1', name: 'Building A' }, - 'bldg-2': { id: 'bldg-2', name: 'Building B' }, + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + 'bldg-3': { id: 'bldg-3', name: 'Building C', floors: 2 }, + }, + Manager: { + 'mgr-1': { id: 'mgr-1', name: 'Alice' }, + }, + SingleRefDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + mainBuilding: 'bldg-1', + }, }, }, indexes: {}, }; - test('.query returns a LazyQuery instance', () => { - const lazyField = Department.schema.buildings; - expect(lazyField).toBeInstanceOf(schema.Lazy); - expect(lazyField.query).toBeDefined(); - expect(lazyField.query.queryKey).toBeInstanceOf(Function); - expect(lazyField.query.denormalize).toBeInstanceOf(Function); - }); - - test('.query getter returns same instance', () => { - const lazyField = Department.schema.buildings; - expect(lazyField.query).toBe(lazyField.query); + test('.query getter always returns the same instance', () => { + const lazy = Department.schema.buildings; + expect(lazy.query).toBe(lazy.query); }); - test('LazyQuery resolves array of IDs via MemoCache.query', () => { - const lazyQuery = Department.schema.buildings.query; + test('resolves array of IDs into Building instances', () => { const memo = new MemoCache(); - const result = memo.query(lazyQuery, [['bldg-1', 'bldg-2']], state); - expect(result.data).toBeDefined(); - if (typeof result.data === 'symbol') return; + const result = memo.query( + Department.schema.buildings.query, + [['bldg-1', 'bldg-2']], + state, + ); const buildings = result.data as any[]; expect(buildings).toHaveLength(2); + expect(buildings[0]).toBeInstanceOf(Building); expect(buildings[0].id).toBe('bldg-1'); expect(buildings[0].name).toBe('Building A'); + expect(buildings[0].floors).toBe(3); + expect(buildings[1]).toBeInstanceOf(Building); expect(buildings[1].id).toBe('bldg-2'); expect(buildings[1].name).toBe('Building B'); + expect(buildings[1].floors).toBe(5); }); - test('LazyQuery tracks Building entity dependencies', () => { - const lazyQuery = Department.schema.buildings.query; + test('resolved entities track Building dependencies', () => { const memo = new MemoCache(); - const result = memo.query(lazyQuery, [['bldg-1', 'bldg-2']], state); + const result = memo.query( + Department.schema.buildings.query, + [['bldg-1', 'bldg-2']], + state, + ); const buildingPaths = result.paths.filter(p => p.key === 'Building'); - expect(buildingPaths.length).toBeGreaterThanOrEqual(2); + expect(buildingPaths).toHaveLength(2); + expect(buildingPaths.map(p => p.pk).sort()).toEqual(['bldg-1', 'bldg-2']); + // Department should NOT be in paths — we're only resolving buildings + expect(result.paths.some(p => p.key === 'Department')).toBe(false); }); - test('LazyQuery with Entity inner schema delegates queryKey', () => { - const lazyField = SingleRefDepartment.schema.mainBuilding; - const lazyQuery = lazyField.query; + test('subset of IDs resolves only those buildings', () => { const memo = new MemoCache(); - const result = memo.query(lazyQuery, [{ id: 'bldg-1' }], state); - expect(result.data).toBeDefined(); - if (typeof result.data === 'symbol') return; - expect((result.data as any).id).toBe('bldg-1'); - expect((result.data as any).name).toBe('Building A'); + const result = memo.query( + Department.schema.buildings.query, + [['bldg-3']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(1); + expect(buildings[0].id).toBe('bldg-3'); + expect(buildings[0].name).toBe('Building C'); + expect(buildings[0].floors).toBe(2); }); - test('LazyQuery returns undefined for missing entity', () => { - const lazyQuery = SingleRefDepartment.schema.mainBuilding.query; + test('empty IDs array resolves to empty array', () => { const memo = new MemoCache(); - const result = memo.query(lazyQuery, [{ id: 'nonexistent' }], state); - expect(result.data).toBeUndefined(); + const result = memo.query( + Department.schema.buildings.query, + [[]], + state, + ); + expect(result.data).toEqual([]); + expect(result.paths).toEqual([]); }); - test('LazyQuery returns empty array for empty IDs', () => { - const lazyQuery = Department.schema.buildings.query; + test('IDs referencing missing entities are filtered out', () => { const memo = new MemoCache(); - const result = memo.query(lazyQuery, [[]], state); - expect(result.data).toEqual([]); + const result = memo.query( + Department.schema.buildings.query, + [['bldg-1', 'nonexistent', 'bldg-2']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(2); + expect(buildings[0].id).toBe('bldg-1'); + expect(buildings[1].id).toBe('bldg-2'); + }); + + test('Entity inner schema: delegates to Building.queryKey for single entity lookup', () => { + const memo = new MemoCache(); + const result = memo.query( + SingleRefDepartment.schema.mainBuilding.query, + [{ id: 'bldg-1' }], + state, + ); + const building = result.data as any; + expect(building).toBeInstanceOf(Building); + expect(building.id).toBe('bldg-1'); + expect(building.name).toBe('Building A'); + expect(building.floors).toBe(3); + }); + + test('Entity inner schema: missing entity returns undefined', () => { + const memo = new MemoCache(); + const result = memo.query( + SingleRefDepartment.schema.mainBuilding.query, + [{ id: 'nonexistent' }], + state, + ); + expect(result.data).toBeUndefined(); }); }); describe('memoization isolation', () => { - test('parent memo is stable when lazy entity changes', () => { + test('parent referential equality is preserved when lazy entity updates', () => { const entities1 = { Department: { 'dept-1': { id: 'dept-1', name: 'Engineering', buildings: ['bldg-1'], + manager: 'mgr-1', }, }, Building: { - 'bldg-1': { id: 'bldg-1', name: 'Building A' }, + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + }, + Manager: { + 'mgr-1': { id: 'mgr-1', name: 'Alice' }, }, }; + // Building entity changes, Department entity object stays the same ref const entities2 = { Department: entities1.Department, Building: { - 'bldg-1': { id: 'bldg-1', name: 'Building A UPDATED' }, + 'bldg-1': { id: 'bldg-1', name: 'Building A RENAMED', floors: 4 }, }, + Manager: entities1.Manager, }; const memo = new MemoCache(); const result1 = memo.denormalize(Department, 'dept-1', entities1); const result2 = memo.denormalize(Department, 'dept-1', entities2); + + // Parent entity denorm is referentially equal — Building change is invisible expect(result1.data).toBe(result2.data); + const dept: any = result1.data; + expect(dept.name).toBe('Engineering'); + expect(dept.buildings).toEqual(['bldg-1']); + expect(dept.manager).toBeInstanceOf(Manager); + }); + + test('LazyQuery result DOES update when lazy entity changes', () => { + const lazyQuery = Department.schema.buildings.query; + const state1 = { + entities: { + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Original', floors: 3 }, + }, + }, + indexes: {}, + }; + const state2 = { + entities: { + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Updated', floors: 4 }, + }, + }, + indexes: {}, + }; + + const memo = new MemoCache(); + const r1 = memo.query(lazyQuery, [['bldg-1']], state1); + const r2 = memo.query(lazyQuery, [['bldg-1']], state2); + + expect((r1.data as any)[0].name).toBe('Original'); + expect((r1.data as any)[0].floors).toBe(3); + expect((r2.data as any)[0].name).toBe('Updated'); + expect((r2.data as any)[0].floors).toBe(4); + expect(r1.data).not.toBe(r2.data); + }); + + test('LazyQuery result maintains referential equality on unchanged state', () => { + const lazyQuery = Department.schema.buildings.query; + const state = { + entities: { + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + }, + }, + indexes: {}, + }; + + const memo = new MemoCache(); + const r1 = memo.query(lazyQuery, [['bldg-1']], state); + const r2 = memo.query(lazyQuery, [['bldg-1']], state); + expect(r1.data).toBe(r2.data); }); }); - describe('does not overflow stack with large bidirectional graphs', () => { - test('large chain with Lazy fields does not overflow', () => { - class LazyDepartment extends IDEntity { - readonly name: string = ''; - readonly buildings: string[] = []; - } - class LazyBuilding extends IDEntity { - readonly name: string = ''; - readonly departments: string[] = []; - } - LazyDepartment.schema = { + describe('nested Lazy fields', () => { + class Room extends IDEntity { + readonly label: string = ''; + } + + class LazyBuilding extends IDEntity { + readonly name: string = ''; + readonly rooms: string[] = []; + + static schema = { + rooms: new schema.Lazy([Room]), + }; + } + + class LazyDepartment extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + + static schema = { buildings: new schema.Lazy([LazyBuilding]), }; - LazyBuilding.schema = { - departments: new schema.Lazy([LazyDepartment]), + } + + test('resolved entity still has its own Lazy fields as raw IDs', () => { + const state = { + entities: { + LazyBuilding: { + 'bldg-1': { + id: 'bldg-1', + name: 'Building A', + rooms: ['room-1', 'room-2'], + }, + }, + Room: { + 'room-1': { id: 'room-1', label: '101' }, + 'room-2': { id: 'room-2', label: '102' }, + }, + }, + indexes: {}, + }; + + const memo = new MemoCache(); + const result = memo.query( + LazyDepartment.schema.buildings.query, + [['bldg-1']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(1); + expect(buildings[0]).toBeInstanceOf(LazyBuilding); + expect(buildings[0].name).toBe('Building A'); + // Building's own Lazy field stays as raw IDs + expect(buildings[0].rooms).toEqual(['room-1', 'room-2']); + expect(typeof buildings[0].rooms[0]).toBe('string'); + }); + + test('second-level LazyQuery resolves deeper relationships', () => { + const state = { + entities: { + Room: { + 'room-1': { id: 'room-1', label: '101' }, + 'room-2': { id: 'room-2', label: '102' }, + }, + }, + indexes: {}, }; - const CHAIN_LENGTH = 1500; + const memo = new MemoCache(); + const result = memo.query( + LazyBuilding.schema.rooms.query, + [['room-1', 'room-2']], + state, + ); + const rooms = result.data as any[]; + expect(rooms).toHaveLength(2); + expect(rooms[0]).toBeInstanceOf(Room); + expect(rooms[0].label).toBe('101'); + expect(rooms[1].label).toBe('102'); + }); + }); + + describe('bidirectional Lazy prevents stack overflow', () => { + class BidirBuilding extends IDEntity { + readonly name: string = ''; + readonly departments: string[] = []; + } + class BidirDepartment extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + } + BidirDepartment.schema = { + buildings: new schema.Lazy([BidirBuilding]), + }; + BidirBuilding.schema = { + departments: new schema.Lazy([BidirDepartment]), + }; + + function buildChain(length: number) { const departmentEntities: Record = {}; const buildingEntities: Record = {}; - - for (let i = 0; i < CHAIN_LENGTH; i++) { + for (let i = 0; i < length; i++) { departmentEntities[`dept-${i}`] = { id: `dept-${i}`, name: `Department ${i}`, @@ -292,27 +562,75 @@ describe('Lazy schema', () => { buildingEntities[`bldg-${i}`] = { id: `bldg-${i}`, name: `Building ${i}`, - departments: i < CHAIN_LENGTH - 1 ? [`dept-${i + 1}`] : [], + departments: i < length - 1 ? [`dept-${i + 1}`] : [], }; } - - const entities = { - LazyDepartment: departmentEntities, - LazyBuilding: buildingEntities, + return { + BidirDepartment: departmentEntities, + BidirBuilding: buildingEntities, }; + } + test('1500-node chain does not overflow (plainDenormalize)', () => { + const entities = buildChain(1500); expect(() => - plainDenormalize(LazyDepartment, 'dept-0', entities), + plainDenormalize(BidirDepartment, 'dept-0', entities), ).not.toThrow(); + const dept: any = plainDenormalize( + BidirDepartment, + 'dept-0', + entities, + ); + expect(dept.id).toBe('dept-0'); + expect(dept.name).toBe('Department 0'); + expect(dept.buildings).toEqual(['bldg-0']); + }); + + test('1500-node chain does not overflow (SimpleMemoCache)', () => { + const entities = buildChain(1500); const memo = new SimpleMemoCache(); expect(() => - memo.denormalize(LazyDepartment, 'dept-0', entities), + memo.denormalize(BidirDepartment, 'dept-0', entities), ).not.toThrow(); + }); - const dept: any = plainDenormalize(LazyDepartment, 'dept-0', entities); - expect(dept.buildings).toEqual(['bldg-0']); - expect(typeof dept.buildings[0]).toBe('string'); + test('chain entities can still be resolved individually via LazyQuery', () => { + const entities = buildChain(5); + const state = { entities, indexes: {} }; + const memo = new MemoCache(); + + const deptBuildingsQuery = ( + BidirDepartment.schema.buildings as schema.Lazy + ).query; + const bldgDeptsQuery = ( + BidirBuilding.schema.departments as schema.Lazy + ).query; + + // Resolve dept-0's buildings + const r = memo.query(deptBuildingsQuery, [['bldg-0']], state); + const buildings = r.data as any[]; + expect(buildings).toHaveLength(1); + expect(buildings[0]).toBeInstanceOf(BidirBuilding); + expect(buildings[0].id).toBe('bldg-0'); + expect(buildings[0].name).toBe('Building 0'); + // Building's departments field is also Lazy — raw IDs + expect(buildings[0].departments).toEqual(['dept-1']); + + // Resolve building-0's departments + const r2 = memo.query(bldgDeptsQuery, [['dept-1']], state); + const depts = r2.data as any[]; + expect(depts).toHaveLength(1); + expect(depts[0]).toBeInstanceOf(BidirDepartment); + expect(depts[0].id).toBe('dept-1'); + expect(depts[0].buildings).toEqual(['bldg-1']); + }); + }); + + describe('Lazy.queryKey returns undefined', () => { + test('Lazy itself is not queryable', () => { + const lazy = new schema.Lazy([Building]); + expect(lazy.queryKey([], () => {}, {} as any)).toBeUndefined(); }); }); }); From 2c9db8682c83c8b9d0c5617456859b039d395b3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 09:48:31 +0000 Subject: [PATCH 5/7] fix: lint errors and add changeset for Lazy schema - 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 --- .changeset/add-lazy-schema.md | 25 +++++++++++++ packages/endpoint/src/schema.d.ts | 2 +- packages/endpoint/src/schemas/Lazy.ts | 37 ++++++++++--------- .../src/schemas/__tests__/Lazy.test.ts | 16 ++------ 4 files changed, 50 insertions(+), 30 deletions(-) create mode 100644 .changeset/add-lazy-schema.md diff --git a/.changeset/add-lazy-schema.md b/.changeset/add-lazy-schema.md new file mode 100644 index 000000000000..d919ce7822ed --- /dev/null +++ b/.changeset/add-lazy-schema.md @@ -0,0 +1,25 @@ +--- +'@data-client/endpoint': minor +'@data-client/rest': minor +'@data-client/graphql': minor +--- + +Add [schema.Lazy](https://dataclient.io/rest/api/Lazy) for deferred relationship denormalization. + +`schema.Lazy` wraps a relationship field so denormalization returns raw primary keys +instead of resolved entities. Use `.query` with [useQuery](/docs/api/useQuery) to +resolve on demand in a separate memo/GC scope. + +New exports: `schema.Lazy`, `Lazy` + +```ts +class Department extends Entity { + buildings: string[] = []; + static schema = { + buildings: new schema.Lazy([Building]), + }; +} + +// dept.buildings = ['bldg-1', 'bldg-2'] (raw PKs) +const buildings = useQuery(Department.schema.buildings.query, dept.buildings); +``` diff --git a/packages/endpoint/src/schema.d.ts b/packages/endpoint/src/schema.d.ts index baa9e02c3d67..5a0c06507d3d 100644 --- a/packages/endpoint/src/schema.d.ts +++ b/packages/endpoint/src/schema.d.ts @@ -25,8 +25,8 @@ import { default as Entity, } from './schemas/EntityMixin.js'; import { default as Invalidate } from './schemas/Invalidate.js'; -import { default as Query } from './schemas/Query.js'; import { default as Lazy } from './schemas/Lazy.js'; +import { default as Query } from './schemas/Query.js'; import type { CollectionConstructor, DefaultArgs, diff --git a/packages/endpoint/src/schemas/Lazy.ts b/packages/endpoint/src/schemas/Lazy.ts index a5ddac2f3770..86b45f3b7d01 100644 --- a/packages/endpoint/src/schemas/Lazy.ts +++ b/packages/endpoint/src/schemas/Lazy.ts @@ -1,5 +1,9 @@ import type { Schema, SchemaSimple } from '../interface.js'; -import type { Denormalize, DenormalizeNullable, NormalizeNullable } from '../normal.js'; +import type { + Denormalize, + DenormalizeNullable, + NormalizeNullable, +} from '../normal.js'; /** * Skips eager denormalization of a relationship field. @@ -8,9 +12,7 @@ import type { Denormalize, DenormalizeNullable, NormalizeNullable } from '../nor * * @see https://dataclient.io/rest/api/Lazy */ -export default class Lazy - implements SchemaSimple -{ +export default class Lazy implements SchemaSimple { declare schema: S; /** @@ -26,19 +28,19 @@ export default class Lazy key: any, args: any[], visit: (...args: any) => any, - delegate: any, + _delegate: any, ): any { return visit(this.schema, input, parent, key, args); } - denormalize(input: {}, args: readonly any[], unvisit: any): any { + denormalize(input: {}, _args: readonly any[], _unvisit: any): any { return input; } queryKey( - args: readonly any[], - unvisit: (...args: any) => any, - delegate: any, + _args: readonly any[], + _unvisit: (...args: any) => any, + _delegate: any, ): undefined { return undefined; } @@ -68,9 +70,10 @@ export default class Lazy * queryKey delegates to inner schema's queryKey if available, * otherwise passes through args[0] (the raw normalized value). */ -export class LazyQuery - implements SchemaSimple, readonly any[]> -{ +export class LazyQuery implements SchemaSimple< + Denormalize, + readonly any[] +> { declare schema: S; constructor(schema: S) { @@ -79,11 +82,11 @@ export class LazyQuery normalize( input: any, - parent: any, - key: any, - args: any[], - visit: (...args: any) => any, - delegate: any, + _parent: any, + _key: any, + _args: any[], + _visit: (...args: any) => any, + _delegate: any, ): any { return input; } diff --git a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts index 4f77c781e3c5..8c178d1bf856 100644 --- a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts @@ -1,10 +1,9 @@ -import { normalize, MemoCache, INVALID } from '@data-client/normalizr'; +import { normalize, MemoCache } from '@data-client/normalizr'; import { denormalize as plainDenormalize } from '@data-client/normalizr'; import { IDEntity } from '__tests__/new'; import { SimpleMemoCache } from './denormalize'; import { schema } from '../..'; -import Entity from '../Entity'; let dateSpy: jest.Spied; beforeAll(() => { @@ -319,11 +318,7 @@ describe('Lazy schema', () => { test('empty IDs array resolves to empty array', () => { const memo = new MemoCache(); - const result = memo.query( - Department.schema.buildings.query, - [[]], - state, - ); + const result = memo.query(Department.schema.buildings.query, [[]], state); expect(result.data).toEqual([]); expect(result.paths).toEqual([]); }); @@ -577,11 +572,7 @@ describe('Lazy schema', () => { plainDenormalize(BidirDepartment, 'dept-0', entities), ).not.toThrow(); - const dept: any = plainDenormalize( - BidirDepartment, - 'dept-0', - entities, - ); + const dept: any = plainDenormalize(BidirDepartment, 'dept-0', entities); expect(dept.id).toBe('dept-0'); expect(dept.name).toBe('Department 0'); expect(dept.buildings).toEqual(['bldg-0']); @@ -630,6 +621,7 @@ describe('Lazy schema', () => { describe('Lazy.queryKey returns undefined', () => { test('Lazy itself is not queryable', () => { const lazy = new schema.Lazy([Building]); + // eslint-disable-next-line @typescript-eslint/no-empty-function expect(lazy.queryKey([], () => {}, {} as any)).toBeUndefined(); }); }); From d6ce627493ec394860de8c2cd35d8b12c2a640c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 11:45:11 +0000 Subject: [PATCH 6/7] fix: LazyQuery.queryKey skips delegation for non-keyed schemas (Array, 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 --- packages/endpoint/src/schemas/Lazy.ts | 2 +- .../src/schemas/__tests__/Lazy.test.ts | 171 +++++++++++++++++- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/packages/endpoint/src/schemas/Lazy.ts b/packages/endpoint/src/schemas/Lazy.ts index 86b45f3b7d01..e9948cdd976d 100644 --- a/packages/endpoint/src/schemas/Lazy.ts +++ b/packages/endpoint/src/schemas/Lazy.ts @@ -105,7 +105,7 @@ export class LazyQuery implements SchemaSimple< delegate: { getEntity: any; getIndex: any }, ): any { const schema = this.schema as any; - if (typeof schema.queryKey === 'function') { + if (typeof schema.queryKey === 'function' && schema.key) { return schema.queryKey(args, unvisit, delegate); } return args[0]; diff --git a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts index 8c178d1bf856..5842a0764a1f 100644 --- a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts @@ -27,7 +27,7 @@ class Manager extends IDEntity { class Department extends IDEntity { readonly name: string = ''; readonly buildings: string[] = []; - readonly manager = Manager.fromJS(); + readonly manager: Manager = {} as any; static schema = { buildings: new schema.Lazy([Building]), @@ -538,10 +538,10 @@ describe('Lazy schema', () => { readonly name: string = ''; readonly buildings: string[] = []; } - BidirDepartment.schema = { + (BidirDepartment as any).schema = { buildings: new schema.Lazy([BidirBuilding]), }; - BidirBuilding.schema = { + (BidirBuilding as any).schema = { departments: new schema.Lazy([BidirDepartment]), }; @@ -592,10 +592,10 @@ describe('Lazy schema', () => { const memo = new MemoCache(); const deptBuildingsQuery = ( - BidirDepartment.schema.buildings as schema.Lazy + (BidirDepartment as any).schema.buildings as schema.Lazy ).query; const bldgDeptsQuery = ( - BidirBuilding.schema.departments as schema.Lazy + (BidirBuilding as any).schema.departments as schema.Lazy ).query; // Resolve dept-0's buildings @@ -618,6 +618,167 @@ describe('Lazy schema', () => { }); }); + describe('explicit schema.Array inner schema', () => { + class ArrayDepartment extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + + static schema = { + buildings: new schema.Lazy(new schema.Array(Building)), + }; + } + + const state = { + entities: { + ArrayDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1', 'bldg-2'], + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + }, + indexes: {}, + }; + + test('normalize stores entities correctly through Lazy(schema.Array)', () => { + const result = normalize( + ArrayDepartment, + { + id: 'dept-1', + name: 'Engineering', + buildings: [ + { id: 'bldg-1', name: 'Building A', floors: 3 }, + { id: 'bldg-2', name: 'Building B', floors: 5 }, + ], + }, + [], + ); + expect(result.entities.ArrayDepartment['dept-1'].buildings).toEqual([ + 'bldg-1', + 'bldg-2', + ]); + expect(result.entities.Building['bldg-1']).toEqual({ + id: 'bldg-1', + name: 'Building A', + floors: 3, + }); + }); + + test('denormalize keeps Lazy(schema.Array) as raw IDs', () => { + const dept: any = plainDenormalize( + ArrayDepartment, + 'dept-1', + state.entities, + ); + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(typeof dept.buildings[0]).toBe('string'); + }); + + test('LazyQuery resolves explicit schema.Array into Building instances', () => { + const memo = new MemoCache(); + const result = memo.query( + ArrayDepartment.schema.buildings.query, + [['bldg-1', 'bldg-2']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(2); + expect(buildings[0]).toBeInstanceOf(Building); + expect(buildings[0].id).toBe('bldg-1'); + expect(buildings[0].name).toBe('Building A'); + expect(buildings[1]).toBeInstanceOf(Building); + expect(buildings[1].id).toBe('bldg-2'); + expect(buildings[1].name).toBe('Building B'); + }); + + test('LazyQuery with empty array resolves to empty for schema.Array', () => { + const memo = new MemoCache(); + const result = memo.query( + ArrayDepartment.schema.buildings.query, + [[]], + state, + ); + expect(result.data).toEqual([]); + }); + }); + + describe('explicit schema.Values inner schema', () => { + class ValuesDepartment extends IDEntity { + readonly name: string = ''; + readonly buildingMap: Record = {}; + + static schema = { + buildingMap: new schema.Lazy(new schema.Values(Building)), + }; + } + + const state = { + entities: { + ValuesDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildingMap: { north: 'bldg-1', south: 'bldg-2' }, + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + }, + indexes: {}, + }; + + test('normalize stores entities correctly through Lazy(schema.Values)', () => { + const result = normalize( + ValuesDepartment, + { + id: 'dept-1', + name: 'Engineering', + buildingMap: { + north: { id: 'bldg-1', name: 'Building A', floors: 3 }, + south: { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + }, + [], + ); + expect( + result.entities.ValuesDepartment['dept-1'].buildingMap, + ).toEqual({ north: 'bldg-1', south: 'bldg-2' }); + }); + + test('denormalize keeps Lazy(schema.Values) as raw IDs', () => { + const dept: any = plainDenormalize( + ValuesDepartment, + 'dept-1', + state.entities, + ); + expect(dept.buildingMap).toEqual({ north: 'bldg-1', south: 'bldg-2' }); + expect(typeof dept.buildingMap.north).toBe('string'); + }); + + test('LazyQuery resolves explicit schema.Values into Building instances', () => { + const memo = new MemoCache(); + const result = memo.query( + ValuesDepartment.schema.buildingMap.query, + [{ north: 'bldg-1', south: 'bldg-2' }], + state, + ); + const buildingMap = result.data as any; + expect(buildingMap.north).toBeInstanceOf(Building); + expect(buildingMap.north.id).toBe('bldg-1'); + expect(buildingMap.north.name).toBe('Building A'); + expect(buildingMap.south).toBeInstanceOf(Building); + expect(buildingMap.south.id).toBe('bldg-2'); + expect(buildingMap.south.name).toBe('Building B'); + }); + }); + describe('Lazy.queryKey returns undefined', () => { test('Lazy itself is not queryable', () => { const lazy = new schema.Lazy([Building]); From edd4295a3e42d6b17257c4005556f7b483f2ed6a Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 28 Mar 2026 10:35:19 -0400 Subject: [PATCH 7/7] internal: lint --- packages/endpoint/src/schemas/__tests__/Lazy.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts index 5842a0764a1f..38bf01b8a268 100644 --- a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts @@ -1,3 +1,5 @@ +// eslint-env jest +/// import { normalize, MemoCache } from '@data-client/normalizr'; import { denormalize as plainDenormalize } from '@data-client/normalizr'; import { IDEntity } from '__tests__/new'; @@ -747,9 +749,10 @@ describe('Lazy schema', () => { }, [], ); - expect( - result.entities.ValuesDepartment['dept-1'].buildingMap, - ).toEqual({ north: 'bldg-1', south: 'bldg-2' }); + expect(result.entities.ValuesDepartment['dept-1'].buildingMap).toEqual({ + north: 'bldg-1', + south: 'bldg-2', + }); }); test('denormalize keeps Lazy(schema.Values) as raw IDs', () => {