From 31ad11c3aa1cd6400cded37d025bc99cc08144a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 02:30:49 +0000 Subject: [PATCH 1/9] 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 76a187afc46a26f57607c7d47292b9ec980a3251 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 02:37:18 +0000 Subject: [PATCH 2/9] 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 3990815cb4f53375e405a747c078a8870cb3e3d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 02:38:38 +0000 Subject: [PATCH 3/9] 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 295e6ec49f909777c8ce2a30c56ae4a742de5962 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 02:46:01 +0000 Subject: [PATCH 4/9] 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 8e12ea02a02cf8140843bf090f19748abad6e8f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 09:48:31 +0000 Subject: [PATCH 5/9] 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 18356cb21abde53b3ee4234660388239302dcddd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 11:45:11 +0000 Subject: [PATCH 6/9] 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 615d87aafc167d81fb2844a12682fc039afaab02 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 28 Mar 2026 10:35:19 -0400 Subject: [PATCH 7/9] 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', () => { From 6dc8d84a45a0f21a88765416bfad157e93c80c86 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 29 Mar 2026 14:14:57 -0400 Subject: [PATCH 8/9] enhance: Stricter args typing --- packages/endpoint/src/schemas/Lazy.ts | 48 +-- packages/endpoint/tsconfig.json | 5 +- packages/endpoint/typescript-tests/lazy.ts | 295 ++++++++++++++++++ .../editor-types/@data-client/endpoint.d.ts | 65 +++- .../editor-types/@data-client/graphql.d.ts | 65 +++- .../editor-types/@data-client/rest.d.ts | 65 +++- .../Playground/editor-types/globals.d.ts | 65 +++- 7 files changed, 580 insertions(+), 28 deletions(-) create mode 100644 packages/endpoint/typescript-tests/lazy.ts diff --git a/packages/endpoint/src/schemas/Lazy.ts b/packages/endpoint/src/schemas/Lazy.ts index e9948cdd976d..65a4d44fef09 100644 --- a/packages/endpoint/src/schemas/Lazy.ts +++ b/packages/endpoint/src/schemas/Lazy.ts @@ -2,8 +2,32 @@ import type { Schema, SchemaSimple } from '../interface.js'; import type { Denormalize, DenormalizeNullable, + Normalize, NormalizeNullable, } from '../normal.js'; +import type { EntityFields } from './EntityFields.js'; + +type IsAny = 0 extends 1 & T ? true : false; + +/** Derives strict Args for LazyQuery from the inner schema S. + * + * - Entity: [EntityFields] for queryKey delegation + * - Collection (or schema with typed queryKey + key): inner schema's Args + * - Everything else: [Normalize] — pass the parent's raw normalized value + */ +export type LazySchemaArgs = + S extends { createIfValid: any; pk: any; key: string; prototype: infer U } ? + [EntityFields] + : S extends ( + { + queryKey(args: infer Args, ...rest: any): any; + key: string; + } + ) ? + IsAny extends true ? + [Normalize] + : Args + : [Normalize]; /** * Skips eager denormalization of a relationship field. @@ -34,6 +58,7 @@ export default class Lazy implements SchemaSimple { } denormalize(input: {}, _args: readonly any[], _unvisit: any): any { + // If we could figure out we're processing while nested vs from queryKey, then can can get rid of LazyQuery and just use this in both contexts. return input; } @@ -42,6 +67,7 @@ export default class Lazy implements SchemaSimple { _unvisit: (...args: any) => any, _delegate: any, ): undefined { + // denormalize must do nothing, so there's no point in making this queryable - hence we have `get query` return undefined; } @@ -70,27 +96,13 @@ export default class Lazy implements SchemaSimple { * queryKey delegates to inner schema's queryKey if available, * otherwise passes through args[0] (the raw normalized value). */ -export class LazyQuery implements SchemaSimple< - Denormalize, - readonly any[] -> { +export class LazyQuery> { 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[], @@ -100,7 +112,7 @@ export class LazyQuery implements SchemaSimple< } queryKey( - args: readonly any[], + args: Args, unvisit: (...args: any) => any, delegate: { getEntity: any; getIndex: any }, ): any { @@ -108,7 +120,7 @@ export class LazyQuery implements SchemaSimple< if (typeof schema.queryKey === 'function' && schema.key) { return schema.queryKey(args, unvisit, delegate); } - return args[0]; + return (args as readonly any[])[0]; } declare _denormalizeNullable: ( @@ -116,6 +128,4 @@ export class LazyQuery implements SchemaSimple< args: readonly any[], unvisit: (schema: any, input: any) => any, ) => DenormalizeNullable; - - declare _normalizeNullable: () => NormalizeNullable; } diff --git a/packages/endpoint/tsconfig.json b/packages/endpoint/tsconfig.json index 32a17ccde69d..cb890a545e77 100644 --- a/packages/endpoint/tsconfig.json +++ b/packages/endpoint/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "outDir": "lib", "rootDir": "src", - "skipLibCheck": false + "skipLibCheck": false, + "paths": { + "__tests__/*": ["../../__tests__/*"] + } }, "include": ["src", "typescript-tests"], "references": [{ "path": "../../__tests__" }] diff --git a/packages/endpoint/typescript-tests/lazy.ts b/packages/endpoint/typescript-tests/lazy.ts new file mode 100644 index 000000000000..c73520eb1950 --- /dev/null +++ b/packages/endpoint/typescript-tests/lazy.ts @@ -0,0 +1,295 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { IDEntity } from '__tests__/new'; + +import { useQuery } from '../../react/lib'; +import { schema, Collection, Lazy } from '../src'; + +// --- Entity Definitions --- + +class Building extends IDEntity { + readonly name: string = ''; + readonly floors: number = 1; +} + +class Room extends IDEntity { + readonly label: string = ''; +} + +class Manager extends IDEntity { + readonly name: string = ''; +} + +class User extends IDEntity { + readonly type = 'user' as const; +} +class Group extends IDEntity { + readonly type = 'group' as const; +} + +// ============================================= +// Plain array [Entity] +// ============================================= + +class DeptWithArray extends IDEntity { + readonly buildings: string[] = []; + static schema = { + buildings: new Lazy([Building]), + }; +} + +function usePlainArray() { + const _buildings: Building[] | undefined = useQuery( + DeptWithArray.schema.buildings.query, + ['bldg-1', 'bldg-2'], + ); + + // @ts-expect-error - no args at all + useQuery(DeptWithArray.schema.buildings.query); + + // @ts-expect-error - too many spread args + useQuery(DeptWithArray.schema.buildings.query, ['bldg-1'], 'extra'); +} + +// ============================================= +// Single Entity +// ============================================= + +class DeptWithEntity extends IDEntity { + readonly mainBuilding: string = ''; + static schema = { + mainBuilding: new Lazy(Building), + }; +} + +function useSingleEntity() { + const _building: Building | undefined = useQuery( + DeptWithEntity.schema.mainBuilding.query, + { id: 'bldg-1' }, + ); + + // @ts-expect-error - no args + useQuery(DeptWithEntity.schema.mainBuilding.query); + + // @ts-expect-error - wrong key (not a valid Building field) + useQuery(DeptWithEntity.schema.mainBuilding.query, { nonexistent: 'bldg-1' }); + + // prettier-ignore + // @ts-expect-error - too many args + useQuery(DeptWithEntity.schema.mainBuilding.query, { id: 'bldg-1' }, { id: 'bldg-2' }); +} + +// ============================================= +// schema.Array +// ============================================= + +class DeptWithSchemaArray extends IDEntity { + readonly buildings: string[] = []; + static schema = { + buildings: new Lazy(new schema.Array(Building)), + }; +} + +function useSchemaArray() { + const _buildings: Building[] | undefined = useQuery( + DeptWithSchemaArray.schema.buildings.query, + ['bldg-1', 'bldg-2'], + ); + + // @ts-expect-error - no args + useQuery(DeptWithSchemaArray.schema.buildings.query); + + // @ts-expect-error - too many args + useQuery(DeptWithSchemaArray.schema.buildings.query, ['a'], 'extra'); +} + +// ============================================= +// schema.Values +// ============================================= + +class DeptWithValues extends IDEntity { + readonly buildingMap: Record = {}; + static schema = { + buildingMap: new Lazy(new schema.Values(Building)), + }; +} + +function useSchemaValues() { + const _valuesResult: Record | undefined = + useQuery(DeptWithValues.schema.buildingMap.query, { + north: 'bldg-1', + south: 'bldg-2', + }); + + // @ts-expect-error - no args + useQuery(DeptWithValues.schema.buildingMap.query); + + // @ts-expect-error - too many args + useQuery(DeptWithValues.schema.buildingMap.query, { a: '1' }, 'extra'); +} + +// ============================================= +// schema.Object +// ============================================= + +class DeptWithObject extends IDEntity { + readonly info: { primary: string; secondary: string } = {} as any; + static schema = { + info: new Lazy(new schema.Object({ primary: Building, secondary: Room })), + }; +} + +function useSchemaObject() { + const _objectResult: + | { primary: Building | undefined; secondary: Room | undefined } + | undefined = useQuery(DeptWithObject.schema.info.query, { + primary: 'bldg-1', + secondary: 'rm-1', + }); + + // @ts-expect-error - no args + useQuery(DeptWithObject.schema.info.query); + + // @ts-expect-error - wrong key (not a member of the Object schema) + useQuery(DeptWithObject.schema.info.query, { totally_wrong: 'value' }); + + // @ts-expect-error - too many args + useQuery(DeptWithObject.schema.info.query, { id: '1' }, { id: '2' }); +} + +// ============================================= +// Collection (default args) +// ============================================= + +const buildingsCollection = new Collection([Building]); +class DeptWithCollection extends IDEntity { + static schema = { + buildings: new Lazy(buildingsCollection), + }; +} + +function useCollectionDefaultArgs() { + const _buildings: Building[] | undefined = useQuery( + DeptWithCollection.schema.buildings.query, + { departmentId: '1' }, + ); + + // DefaultArgs allows [], [Record], [Record, any] — these are valid by design + useQuery(DeptWithCollection.schema.buildings.query); + useQuery( + DeptWithCollection.schema.buildings.query, + { departmentId: '1' }, + 'extra', + ); +} + +// ============================================= +// Collection (typed args) +// ============================================= + +const typedCollection = new Collection([Building], { + argsKey: (urlParams: { departmentId: string }) => ({ + departmentId: urlParams.departmentId, + }), +}); +class DeptWithTypedCollection extends IDEntity { + static schema = { + buildings: new Lazy(typedCollection), + }; +} + +function useCollectionTypedArgs() { + const _buildings: Building[] | undefined = useQuery( + DeptWithTypedCollection.schema.buildings.query, + { departmentId: '1' }, + ); + + // @ts-expect-error - no args + useQuery(DeptWithTypedCollection.schema.buildings.query); + + // @ts-expect-error - wrong arg shape (missing required key) + useQuery(DeptWithTypedCollection.schema.buildings.query, { wrongKey: '1' }); + + // prettier-ignore + // @ts-expect-error - too many args + useQuery(DeptWithTypedCollection.schema.buildings.query, { departmentId: '1' }, 'extra'); +} + +// ============================================= +// schema.All +// ============================================= + +class DeptWithAll extends IDEntity { + static schema = { + allBuildings: new Lazy(new schema.All(Building)), + }; +} + +function useSchemaAll() { + const _allBuildings: Building[] | undefined = useQuery( + DeptWithAll.schema.allBuildings.query, + ['bldg-1', 'bldg-2'], + ); + + // @ts-expect-error - no args + useQuery(DeptWithAll.schema.allBuildings.query); + + // @ts-expect-error - too many args + useQuery(DeptWithAll.schema.allBuildings.query, 'a', 'b'); +} + +// ============================================= +// Union +// ============================================= + +const ownerUnion = new schema.Union({ user: User, group: Group }, 'type'); +class DeptWithUnion extends IDEntity { + readonly owner: string = ''; + static schema = { + owner: new Lazy(ownerUnion), + }; +} + +function useUnion() { + const _unionResult: User | Group | undefined = useQuery( + DeptWithUnion.schema.owner.query, + { id: 'usr-1', schema: 'user' }, + ); + + // @ts-expect-error - no args + useQuery(DeptWithUnion.schema.owner.query); + + // @ts-expect-error - wrong key (not a UnionResult field) + useQuery(DeptWithUnion.schema.owner.query, { wrong: 'user' }); + + // @ts-expect-error - too many args + useQuery(DeptWithUnion.schema.owner.query, { type: 'user' }, 'extra'); +} + +// ============================================= +// Plain object literal { key: Entity } +// ============================================= + +class DeptWithPlainObject extends IDEntity { + readonly refs: { building: string; room: string } = {} as any; + static schema = { + refs: new Lazy({ building: Building, room: Room }), + }; +} + +function usePlainObject() { + const _plainObjResult: + | { building: Building | undefined; room: Room | undefined } + | undefined = useQuery(DeptWithPlainObject.schema.refs.query, { + building: 'bldg-1', + room: 'rm-1', + }); + + // @ts-expect-error - no args + useQuery(DeptWithPlainObject.schema.refs.query); + + // @ts-expect-error - wrong key (not a member of the plain object schema) + useQuery(DeptWithPlainObject.schema.refs.query, { nonexistent: '1' }); + + // @ts-expect-error - too many args + useQuery(DeptWithPlainObject.schema.refs.query, { id: '1' }, { id: '2' }); +} diff --git a/website/src/components/Playground/editor-types/@data-client/endpoint.d.ts b/website/src/components/Playground/editor-types/@data-client/endpoint.d.ts index b13199353589..d7871129f5b9 100644 --- a/website/src/components/Playground/editor-types/@data-client/endpoint.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/endpoint.d.ts @@ -640,6 +640,65 @@ declare class Invalidate = 0 extends 1 & T ? true : false; +/** Derives strict Args for LazyQuery from the inner schema S. + * + * - Entity: [EntityFields] for queryKey delegation + * - Collection (or schema with typed queryKey + key): inner schema's Args + * - Everything else: [Normalize] — pass the parent's raw normalized value + */ +type LazySchemaArgs = S extends { + createIfValid: any; + pk: any; + key: string; + prototype: infer U; +} ? [ + EntityFields +] : S extends ({ + queryKey(args: infer Args, ...rest: any): any; + key: string; +}) ? IsAny extends true ? [ + Normalize +] : Args : [Normalize]; +/** + * 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 + */ +declare class Lazy implements SchemaSimple { + schema: S; + /** + * @param {Schema} schema - The inner schema (e.g., [Building], Building, Collection) + */ + constructor(schema: S); + normalize(input: any, parent: any, key: any, args: any[], visit: (...args: any) => any, _delegate: any): any; + denormalize(input: {}, _args: readonly any[], _unvisit: any): any; + queryKey(_args: readonly any[], _unvisit: (...args: any) => any, _delegate: any): undefined; + /** Queryable schema for use with useQuery() to resolve lazy relationships */ + get query(): LazyQuery; + private _query; + _denormalizeNullable: (input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any) => any; + _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). + */ +declare class LazyQuery> { + schema: S; + constructor(schema: S); + denormalize(input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any): Denormalize; + queryKey(args: Args, unvisit: (...args: any) => any, delegate: { + getEntity: any; + getIndex: any; + }): any; + _denormalizeNullable: (input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any) => DenormalizeNullable; +} + /** * Programmatic cache reading * @@ -1180,6 +1239,8 @@ type schema_d_EntityMap = EntityMap; declare const schema_d_EntityMixin: typeof EntityMixin; type schema_d_Invalidate | HoistablePolymorphic> = Invalidate; declare const schema_d_Invalidate: typeof Invalidate; +type schema_d_Lazy = Lazy; +declare const schema_d_Lazy: typeof Lazy; type schema_d_MergeFunction = MergeFunction; type schema_d_Query = Values; declare const schema_d_Values: typeof Values; declare const schema_d_unshift: typeof unshift; declare namespace schema_d { - export { schema_d_All as All, Array$1 as Array, schema_d_Collection as Collection, type schema_d_CollectionArrayAdder as CollectionArrayAdder, type schema_d_CollectionArrayOrValuesAdder as CollectionArrayOrValuesAdder, type schema_d_CollectionConstructor as CollectionConstructor, type schema_d_CollectionFromSchema as CollectionFromSchema, type schema_d_CollectionInterface as CollectionInterface, schema_d_CollectionRoot as CollectionRoot, type schema_d_DefaultArgs as DefaultArgs, EntityMixin as Entity, type schema_d_EntityInterface as EntityInterface, type schema_d_EntityMap as EntityMap, schema_d_EntityMixin as EntityMixin, schema_d_Invalidate as Invalidate, type schema_d_MergeFunction as MergeFunction, Object$1 as Object, schema_d_Query as Query, type schema_d_SchemaAttributeFunction as SchemaAttributeFunction, type schema_d_SchemaClass as SchemaClass, type schema_d_SchemaFunction as SchemaFunction, type schema_d_StrategyFunction as StrategyFunction, schema_d_Union as Union, type schema_d_UnionConstructor as UnionConstructor, type schema_d_UnionInstance as UnionInstance, type schema_d_UnionResult as UnionResult, schema_d_UnionRoot as UnionRoot, schema_d_Values as Values, schema_d_unshift as unshift }; + export { schema_d_All as All, Array$1 as Array, schema_d_Collection as Collection, type schema_d_CollectionArrayAdder as CollectionArrayAdder, type schema_d_CollectionArrayOrValuesAdder as CollectionArrayOrValuesAdder, type schema_d_CollectionConstructor as CollectionConstructor, type schema_d_CollectionFromSchema as CollectionFromSchema, type schema_d_CollectionInterface as CollectionInterface, schema_d_CollectionRoot as CollectionRoot, type schema_d_DefaultArgs as DefaultArgs, EntityMixin as Entity, type schema_d_EntityInterface as EntityInterface, type schema_d_EntityMap as EntityMap, schema_d_EntityMixin as EntityMixin, schema_d_Invalidate as Invalidate, schema_d_Lazy as Lazy, type schema_d_MergeFunction as MergeFunction, Object$1 as Object, schema_d_Query as Query, type schema_d_SchemaAttributeFunction as SchemaAttributeFunction, type schema_d_SchemaClass as SchemaClass, type schema_d_SchemaFunction as SchemaFunction, type schema_d_StrategyFunction as StrategyFunction, schema_d_Union as Union, type schema_d_UnionConstructor as UnionConstructor, type schema_d_UnionInstance as UnionInstance, type schema_d_UnionResult as UnionResult, schema_d_UnionRoot as UnionRoot, schema_d_Values as Values, schema_d_unshift as unshift }; } declare const Entity_base: IEntityClass { @@ -1253,4 +1314,4 @@ declare function validateRequired(processedEntity: any, requiredDefaults: Record /** https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-4.html#the-noinfer-utility-type */ type NI = NoInfer; -export { type AbstractInstanceType, All, Array$1 as Array, type CheckLoop, Collection, type DefaultArgs, type Denormalize, type DenormalizeNullable, type DenormalizeNullableObject, type DenormalizeObject, Endpoint, type EndpointExtendOptions, type EndpointExtraOptions, type EndpointInstance, type EndpointInstanceInterface, type EndpointInterface, type EndpointOptions, type EndpointParam, type EndpointToFunction, type EntitiesInterface, type EntitiesPath, Entity, type EntityFields, type EntityInterface, type EntityMap, EntityMixin, type EntityPath, type EntityTable, type ErrorTypes, type ExpiryStatusInterface, ExtendableEndpoint, type FetchFunction, type GetEntity, type GetIndex, type IEntityClass, type IEntityInstance, type INormalizeDelegate, type IQueryDelegate, type IndexPath, Invalidate, type KeyofEndpointInstance, type Mergeable, type MutateEndpoint, type NI, type NetworkError, type Normalize, type NormalizeNullable, type NormalizeObject, type NormalizedEntity, type NormalizedIndex, type NormalizedNullableObject, Object$1 as Object, type ObjectArgs, type PolymorphicInterface, Query, type Queryable, type ReadEndpoint, type RecordClass, type ResolveType, type Schema, type SchemaArgs, type SchemaClass, type SchemaSimple, type Serializable, type SnapshotInterface, Union, type UnknownError, Values, type Visit, schema_d as schema, unshift, validateRequired }; +export { type AbstractInstanceType, All, Array$1 as Array, type CheckLoop, Collection, type DefaultArgs, type Denormalize, type DenormalizeNullable, type DenormalizeNullableObject, type DenormalizeObject, Endpoint, type EndpointExtendOptions, type EndpointExtraOptions, type EndpointInstance, type EndpointInstanceInterface, type EndpointInterface, type EndpointOptions, type EndpointParam, type EndpointToFunction, type EntitiesInterface, type EntitiesPath, Entity, type EntityFields, type EntityInterface, type EntityMap, EntityMixin, type EntityPath, type EntityTable, type ErrorTypes, type ExpiryStatusInterface, ExtendableEndpoint, type FetchFunction, type GetEntity, type GetIndex, type IEntityClass, type IEntityInstance, type INormalizeDelegate, type IQueryDelegate, type IndexPath, Invalidate, type KeyofEndpointInstance, Lazy, type Mergeable, type MutateEndpoint, type NI, type NetworkError, type Normalize, type NormalizeNullable, type NormalizeObject, type NormalizedEntity, type NormalizedIndex, type NormalizedNullableObject, Object$1 as Object, type ObjectArgs, type PolymorphicInterface, Query, type Queryable, type ReadEndpoint, type RecordClass, type ResolveType, type Schema, type SchemaArgs, type SchemaClass, type SchemaSimple, type Serializable, type SnapshotInterface, Union, type UnknownError, Values, type Visit, schema_d as schema, unshift, validateRequired }; diff --git a/website/src/components/Playground/editor-types/@data-client/graphql.d.ts b/website/src/components/Playground/editor-types/@data-client/graphql.d.ts index dff9c5ad0896..56a2f0a91fa9 100644 --- a/website/src/components/Playground/editor-types/@data-client/graphql.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/graphql.d.ts @@ -640,6 +640,65 @@ declare class Invalidate = 0 extends 1 & T ? true : false; +/** Derives strict Args for LazyQuery from the inner schema S. + * + * - Entity: [EntityFields] for queryKey delegation + * - Collection (or schema with typed queryKey + key): inner schema's Args + * - Everything else: [Normalize] — pass the parent's raw normalized value + */ +type LazySchemaArgs = S extends { + createIfValid: any; + pk: any; + key: string; + prototype: infer U; +} ? [ + EntityFields +] : S extends ({ + queryKey(args: infer Args, ...rest: any): any; + key: string; +}) ? IsAny extends true ? [ + Normalize +] : Args : [Normalize]; +/** + * 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 + */ +declare class Lazy implements SchemaSimple { + schema: S; + /** + * @param {Schema} schema - The inner schema (e.g., [Building], Building, Collection) + */ + constructor(schema: S); + normalize(input: any, parent: any, key: any, args: any[], visit: (...args: any) => any, _delegate: any): any; + denormalize(input: {}, _args: readonly any[], _unvisit: any): any; + queryKey(_args: readonly any[], _unvisit: (...args: any) => any, _delegate: any): undefined; + /** Queryable schema for use with useQuery() to resolve lazy relationships */ + get query(): LazyQuery; + private _query; + _denormalizeNullable: (input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any) => any; + _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). + */ +declare class LazyQuery> { + schema: S; + constructor(schema: S); + denormalize(input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any): Denormalize; + queryKey(args: Args, unvisit: (...args: any) => any, delegate: { + getEntity: any; + getIndex: any; + }): any; + _denormalizeNullable: (input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any) => DenormalizeNullable; +} + /** * Programmatic cache reading * @@ -1180,6 +1239,8 @@ type schema_d_EntityMap = EntityMap; declare const schema_d_EntityMixin: typeof EntityMixin; type schema_d_Invalidate | HoistablePolymorphic> = Invalidate; declare const schema_d_Invalidate: typeof Invalidate; +type schema_d_Lazy = Lazy; +declare const schema_d_Lazy: typeof Lazy; type schema_d_MergeFunction = MergeFunction; type schema_d_Query = Values; declare const schema_d_Values: typeof Values; declare const schema_d_unshift: typeof unshift; declare namespace schema_d { - export { schema_d_All as All, Array$1 as Array, schema_d_Collection as Collection, type schema_d_CollectionArrayAdder as CollectionArrayAdder, type schema_d_CollectionArrayOrValuesAdder as CollectionArrayOrValuesAdder, type schema_d_CollectionConstructor as CollectionConstructor, type schema_d_CollectionFromSchema as CollectionFromSchema, type schema_d_CollectionInterface as CollectionInterface, schema_d_CollectionRoot as CollectionRoot, type schema_d_DefaultArgs as DefaultArgs, EntityMixin as Entity, type schema_d_EntityInterface as EntityInterface, type schema_d_EntityMap as EntityMap, schema_d_EntityMixin as EntityMixin, schema_d_Invalidate as Invalidate, type schema_d_MergeFunction as MergeFunction, Object$1 as Object, schema_d_Query as Query, type schema_d_SchemaAttributeFunction as SchemaAttributeFunction, type schema_d_SchemaClass as SchemaClass, type schema_d_SchemaFunction as SchemaFunction, type schema_d_StrategyFunction as StrategyFunction, schema_d_Union as Union, type schema_d_UnionConstructor as UnionConstructor, type schema_d_UnionInstance as UnionInstance, type schema_d_UnionResult as UnionResult, schema_d_UnionRoot as UnionRoot, schema_d_Values as Values, schema_d_unshift as unshift }; + export { schema_d_All as All, Array$1 as Array, schema_d_Collection as Collection, type schema_d_CollectionArrayAdder as CollectionArrayAdder, type schema_d_CollectionArrayOrValuesAdder as CollectionArrayOrValuesAdder, type schema_d_CollectionConstructor as CollectionConstructor, type schema_d_CollectionFromSchema as CollectionFromSchema, type schema_d_CollectionInterface as CollectionInterface, schema_d_CollectionRoot as CollectionRoot, type schema_d_DefaultArgs as DefaultArgs, EntityMixin as Entity, type schema_d_EntityInterface as EntityInterface, type schema_d_EntityMap as EntityMap, schema_d_EntityMixin as EntityMixin, schema_d_Invalidate as Invalidate, schema_d_Lazy as Lazy, type schema_d_MergeFunction as MergeFunction, Object$1 as Object, schema_d_Query as Query, type schema_d_SchemaAttributeFunction as SchemaAttributeFunction, type schema_d_SchemaClass as SchemaClass, type schema_d_SchemaFunction as SchemaFunction, type schema_d_StrategyFunction as StrategyFunction, schema_d_Union as Union, type schema_d_UnionConstructor as UnionConstructor, type schema_d_UnionInstance as UnionInstance, type schema_d_UnionResult as UnionResult, schema_d_UnionRoot as UnionRoot, schema_d_Values as Values, schema_d_unshift as unshift }; } declare const Entity_base: IEntityClass { @@ -1294,4 +1355,4 @@ interface GQLError { path: (string | number)[]; } -export { type AbstractInstanceType, All, Array$1 as Array, type CheckLoop, Collection, type DefaultArgs, type Denormalize, type DenormalizeNullable, type DenormalizeNullableObject, type DenormalizeObject, Endpoint, type EndpointExtendOptions, type EndpointExtraOptions, type EndpointInstance, type EndpointInstanceInterface, type EndpointInterface, type EndpointOptions, type EndpointParam, type EndpointToFunction, type EntitiesInterface, type EntitiesPath, Entity, type EntityFields, type EntityInterface, type EntityMap, EntityMixin, type EntityPath, type EntityTable, type ErrorTypes, type ExpiryStatusInterface, ExtendableEndpoint, type FetchFunction, GQLEndpoint, GQLEntity, type GQLError, GQLNetworkError, type GQLOptions, type GetEntity, type GetIndex, type IEntityClass, type IEntityInstance, type INormalizeDelegate, type IQueryDelegate, type IndexPath, Invalidate, type KeyofEndpointInstance, type Mergeable, type MutateEndpoint, type NI, type NetworkError, type Normalize, type NormalizeNullable, type NormalizeObject, type NormalizedEntity, type NormalizedIndex, type NormalizedNullableObject, Object$1 as Object, type ObjectArgs, type PolymorphicInterface, Query, type Queryable, type ReadEndpoint, type RecordClass, type ResolveType, type Schema, type SchemaArgs, type SchemaClass, type SchemaSimple, type Serializable, type SnapshotInterface, Union, type UnknownError, Values, type Visit, schema_d as schema, unshift, validateRequired }; +export { type AbstractInstanceType, All, Array$1 as Array, type CheckLoop, Collection, type DefaultArgs, type Denormalize, type DenormalizeNullable, type DenormalizeNullableObject, type DenormalizeObject, Endpoint, type EndpointExtendOptions, type EndpointExtraOptions, type EndpointInstance, type EndpointInstanceInterface, type EndpointInterface, type EndpointOptions, type EndpointParam, type EndpointToFunction, type EntitiesInterface, type EntitiesPath, Entity, type EntityFields, type EntityInterface, type EntityMap, EntityMixin, type EntityPath, type EntityTable, type ErrorTypes, type ExpiryStatusInterface, ExtendableEndpoint, type FetchFunction, GQLEndpoint, GQLEntity, type GQLError, GQLNetworkError, type GQLOptions, type GetEntity, type GetIndex, type IEntityClass, type IEntityInstance, type INormalizeDelegate, type IQueryDelegate, type IndexPath, Invalidate, type KeyofEndpointInstance, Lazy, type Mergeable, type MutateEndpoint, type NI, type NetworkError, type Normalize, type NormalizeNullable, type NormalizeObject, type NormalizedEntity, type NormalizedIndex, type NormalizedNullableObject, Object$1 as Object, type ObjectArgs, type PolymorphicInterface, Query, type Queryable, type ReadEndpoint, type RecordClass, type ResolveType, type Schema, type SchemaArgs, type SchemaClass, type SchemaSimple, type Serializable, type SnapshotInterface, Union, type UnknownError, Values, type Visit, schema_d as schema, unshift, validateRequired }; diff --git a/website/src/components/Playground/editor-types/@data-client/rest.d.ts b/website/src/components/Playground/editor-types/@data-client/rest.d.ts index 8fd2c3e755e4..88925567e3ce 100644 --- a/website/src/components/Playground/editor-types/@data-client/rest.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/rest.d.ts @@ -638,6 +638,65 @@ declare class Invalidate = 0 extends 1 & T ? true : false; +/** Derives strict Args for LazyQuery from the inner schema S. + * + * - Entity: [EntityFields] for queryKey delegation + * - Collection (or schema with typed queryKey + key): inner schema's Args + * - Everything else: [Normalize] — pass the parent's raw normalized value + */ +type LazySchemaArgs = S extends { + createIfValid: any; + pk: any; + key: string; + prototype: infer U; +} ? [ + EntityFields +] : S extends ({ + queryKey(args: infer Args, ...rest: any): any; + key: string; +}) ? IsAny extends true ? [ + Normalize +] : Args : [Normalize]; +/** + * 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 + */ +declare class Lazy implements SchemaSimple { + schema: S; + /** + * @param {Schema} schema - The inner schema (e.g., [Building], Building, Collection) + */ + constructor(schema: S); + normalize(input: any, parent: any, key: any, args: any[], visit: (...args: any) => any, _delegate: any): any; + denormalize(input: {}, _args: readonly any[], _unvisit: any): any; + queryKey(_args: readonly any[], _unvisit: (...args: any) => any, _delegate: any): undefined; + /** Queryable schema for use with useQuery() to resolve lazy relationships */ + get query(): LazyQuery; + private _query; + _denormalizeNullable: (input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any) => any; + _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). + */ +declare class LazyQuery> { + schema: S; + constructor(schema: S); + denormalize(input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any): Denormalize; + queryKey(args: Args, unvisit: (...args: any) => any, delegate: { + getEntity: any; + getIndex: any; + }): any; + _denormalizeNullable: (input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any) => DenormalizeNullable; +} + /** * Programmatic cache reading * @@ -1178,6 +1237,8 @@ type schema_d_EntityMap = EntityMap; declare const schema_d_EntityMixin: typeof EntityMixin; type schema_d_Invalidate | HoistablePolymorphic> = Invalidate; declare const schema_d_Invalidate: typeof Invalidate; +type schema_d_Lazy = Lazy; +declare const schema_d_Lazy: typeof Lazy; type schema_d_MergeFunction = MergeFunction; type schema_d_Query = Values; declare const schema_d_Values: typeof Values; declare const schema_d_unshift: typeof unshift; declare namespace schema_d { - export { schema_d_All as All, Array$1 as Array, schema_d_Collection as Collection, type schema_d_CollectionArrayAdder as CollectionArrayAdder, type schema_d_CollectionArrayOrValuesAdder as CollectionArrayOrValuesAdder, type schema_d_CollectionConstructor as CollectionConstructor, type schema_d_CollectionFromSchema as CollectionFromSchema, type schema_d_CollectionInterface as CollectionInterface, schema_d_CollectionRoot as CollectionRoot, type schema_d_DefaultArgs as DefaultArgs, EntityMixin as Entity, type schema_d_EntityInterface as EntityInterface, type schema_d_EntityMap as EntityMap, schema_d_EntityMixin as EntityMixin, schema_d_Invalidate as Invalidate, type schema_d_MergeFunction as MergeFunction, Object$1 as Object, schema_d_Query as Query, type schema_d_SchemaAttributeFunction as SchemaAttributeFunction, type schema_d_SchemaClass as SchemaClass, type schema_d_SchemaFunction as SchemaFunction, type schema_d_StrategyFunction as StrategyFunction, schema_d_Union as Union, type schema_d_UnionConstructor as UnionConstructor, type schema_d_UnionInstance as UnionInstance, type schema_d_UnionResult as UnionResult, schema_d_UnionRoot as UnionRoot, schema_d_Values as Values, schema_d_unshift as unshift }; + export { schema_d_All as All, Array$1 as Array, schema_d_Collection as Collection, type schema_d_CollectionArrayAdder as CollectionArrayAdder, type schema_d_CollectionArrayOrValuesAdder as CollectionArrayOrValuesAdder, type schema_d_CollectionConstructor as CollectionConstructor, type schema_d_CollectionFromSchema as CollectionFromSchema, type schema_d_CollectionInterface as CollectionInterface, schema_d_CollectionRoot as CollectionRoot, type schema_d_DefaultArgs as DefaultArgs, EntityMixin as Entity, type schema_d_EntityInterface as EntityInterface, type schema_d_EntityMap as EntityMap, schema_d_EntityMixin as EntityMixin, schema_d_Invalidate as Invalidate, schema_d_Lazy as Lazy, type schema_d_MergeFunction as MergeFunction, Object$1 as Object, schema_d_Query as Query, type schema_d_SchemaAttributeFunction as SchemaAttributeFunction, type schema_d_SchemaClass as SchemaClass, type schema_d_SchemaFunction as SchemaFunction, type schema_d_StrategyFunction as StrategyFunction, schema_d_Union as Union, type schema_d_UnionConstructor as UnionConstructor, type schema_d_UnionInstance as UnionInstance, type schema_d_UnionResult as UnionResult, schema_d_UnionRoot as UnionRoot, schema_d_Values as Values, schema_d_unshift as unshift }; } declare const Entity_base: IEntityClass { @@ -1942,4 +2003,4 @@ declare class NetworkError extends Error { }; } -export { type AbstractInstanceType, type AddEndpoint, All, Array$1 as Array, type CheckLoop, Collection, type CustomResource, type DefaultArgs, type Defaults, type Denormalize, type DenormalizeNullable, type DenormalizeNullableObject, type DenormalizeObject, Endpoint, type EndpointExtendOptions, type EndpointExtraOptions, type EndpointInstance, type EndpointInstanceInterface, type EndpointInterface, type EndpointOptions, type EndpointParam, type EndpointToFunction, type EntitiesInterface, type EntitiesPath, Entity, type EntityFields, type EntityInterface, type EntityMap, EntityMixin, type EntityPath, type EntityTable, type ErrorTypes, type ExpiryStatusInterface, ExtendableEndpoint, type ExtendedResource, type FetchFunction, type FetchGet, type FetchMutate, type FromFallBack, type GetEndpoint, type GetEntity, type GetIndex, type HookResource, type HookableEndpointInterface, type IEntityClass, type IEntityInstance, type INormalizeDelegate, type IQueryDelegate, type RestEndpoint$1 as IRestEndpoint, type IndexPath, Invalidate, type KeyofEndpointInstance, type KeyofRestEndpoint, type KeysToArgs, type Mergeable, type MethodToSide, type MoveEndpoint, type MutateEndpoint, type NI, NetworkError, type Normalize, type NormalizeNullable, type NormalizeObject, type NormalizedEntity, type NormalizedIndex, type NormalizedNullableObject, Object$1 as Object, type ObjectArgs, type OptionsToFunction, type PaginationEndpoint, type PaginationFieldEndpoint, type ParamFetchNoBody, type ParamFetchWithBody, type ParamToArgs, type PartialRestGenerics, type PathArgs, type PathArgsAndSearch, type PathKeys, type PolymorphicInterface, Query, type Queryable, type ReadEndpoint, type RecordClass, type RemoveEndpoint, type ResolveType, type Resource, type ResourceEndpointExtensions, type ResourceExtension, type ResourceGenerics, type ResourceInterface, type ResourceOptions, RestEndpoint, type RestEndpointConstructor, type RestEndpointConstructorOptions, type RestEndpointExtendOptions, type RestEndpointOptions, type RestExtendedEndpoint, type RestFetch, type RestGenerics, type RestInstance, type RestInstanceBase, type RestType, type RestTypeNoBody, type RestTypeWithBody, type Schema, type SchemaArgs, type SchemaClass, type SchemaSimple, type Serializable, type ShortenPath, type SnapshotInterface, Union, type UnknownError, Values, type Visit, resource as createResource, getUrlBase, getUrlTokens, hookifyResource, resource, schema_d as schema, unshift, validateRequired }; +export { type AbstractInstanceType, type AddEndpoint, All, Array$1 as Array, type CheckLoop, Collection, type CustomResource, type DefaultArgs, type Defaults, type Denormalize, type DenormalizeNullable, type DenormalizeNullableObject, type DenormalizeObject, Endpoint, type EndpointExtendOptions, type EndpointExtraOptions, type EndpointInstance, type EndpointInstanceInterface, type EndpointInterface, type EndpointOptions, type EndpointParam, type EndpointToFunction, type EntitiesInterface, type EntitiesPath, Entity, type EntityFields, type EntityInterface, type EntityMap, EntityMixin, type EntityPath, type EntityTable, type ErrorTypes, type ExpiryStatusInterface, ExtendableEndpoint, type ExtendedResource, type FetchFunction, type FetchGet, type FetchMutate, type FromFallBack, type GetEndpoint, type GetEntity, type GetIndex, type HookResource, type HookableEndpointInterface, type IEntityClass, type IEntityInstance, type INormalizeDelegate, type IQueryDelegate, type RestEndpoint$1 as IRestEndpoint, type IndexPath, Invalidate, type KeyofEndpointInstance, type KeyofRestEndpoint, type KeysToArgs, Lazy, type Mergeable, type MethodToSide, type MoveEndpoint, type MutateEndpoint, type NI, NetworkError, type Normalize, type NormalizeNullable, type NormalizeObject, type NormalizedEntity, type NormalizedIndex, type NormalizedNullableObject, Object$1 as Object, type ObjectArgs, type OptionsToFunction, type PaginationEndpoint, type PaginationFieldEndpoint, type ParamFetchNoBody, type ParamFetchWithBody, type ParamToArgs, type PartialRestGenerics, type PathArgs, type PathArgsAndSearch, type PathKeys, type PolymorphicInterface, Query, type Queryable, type ReadEndpoint, type RecordClass, type RemoveEndpoint, type ResolveType, type Resource, type ResourceEndpointExtensions, type ResourceExtension, type ResourceGenerics, type ResourceInterface, type ResourceOptions, RestEndpoint, type RestEndpointConstructor, type RestEndpointConstructorOptions, type RestEndpointExtendOptions, type RestEndpointOptions, type RestExtendedEndpoint, type RestFetch, type RestGenerics, type RestInstance, type RestInstanceBase, type RestType, type RestTypeNoBody, type RestTypeWithBody, type Schema, type SchemaArgs, type SchemaClass, type SchemaSimple, type Serializable, type ShortenPath, type SnapshotInterface, Union, type UnknownError, Values, type Visit, resource as createResource, getUrlBase, getUrlTokens, hookifyResource, resource, schema_d as schema, unshift, validateRequired }; diff --git a/website/src/components/Playground/editor-types/globals.d.ts b/website/src/components/Playground/editor-types/globals.d.ts index e08bc10b7c2b..c9db692f82eb 100644 --- a/website/src/components/Playground/editor-types/globals.d.ts +++ b/website/src/components/Playground/editor-types/globals.d.ts @@ -642,6 +642,65 @@ declare class Invalidate = 0 extends 1 & T ? true : false; +/** Derives strict Args for LazyQuery from the inner schema S. + * + * - Entity: [EntityFields] for queryKey delegation + * - Collection (or schema with typed queryKey + key): inner schema's Args + * - Everything else: [Normalize] — pass the parent's raw normalized value + */ +type LazySchemaArgs = S extends { + createIfValid: any; + pk: any; + key: string; + prototype: infer U; +} ? [ + EntityFields +] : S extends ({ + queryKey(args: infer Args, ...rest: any): any; + key: string; +}) ? IsAny extends true ? [ + Normalize +] : Args : [Normalize]; +/** + * 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 + */ +declare class Lazy implements SchemaSimple { + schema: S; + /** + * @param {Schema} schema - The inner schema (e.g., [Building], Building, Collection) + */ + constructor(schema: S); + normalize(input: any, parent: any, key: any, args: any[], visit: (...args: any) => any, _delegate: any): any; + denormalize(input: {}, _args: readonly any[], _unvisit: any): any; + queryKey(_args: readonly any[], _unvisit: (...args: any) => any, _delegate: any): undefined; + /** Queryable schema for use with useQuery() to resolve lazy relationships */ + get query(): LazyQuery; + private _query; + _denormalizeNullable: (input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any) => any; + _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). + */ +declare class LazyQuery> { + schema: S; + constructor(schema: S); + denormalize(input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any): Denormalize; + queryKey(args: Args, unvisit: (...args: any) => any, delegate: { + getEntity: any; + getIndex: any; + }): any; + _denormalizeNullable: (input: {}, args: readonly any[], unvisit: (schema: any, input: any) => any) => DenormalizeNullable; +} + /** * Programmatic cache reading * @@ -1182,6 +1241,8 @@ type schema_d_EntityMap = EntityMap; declare const schema_d_EntityMixin: typeof EntityMixin; type schema_d_Invalidate | HoistablePolymorphic> = Invalidate; declare const schema_d_Invalidate: typeof Invalidate; +type schema_d_Lazy = Lazy; +declare const schema_d_Lazy: typeof Lazy; type schema_d_MergeFunction = MergeFunction; type schema_d_Query = Values; declare const schema_d_Values: typeof Values; declare const schema_d_unshift: typeof unshift; declare namespace schema_d { - export { schema_d_All as All, Array$1 as Array, schema_d_Collection as Collection, type schema_d_CollectionArrayAdder as CollectionArrayAdder, type schema_d_CollectionArrayOrValuesAdder as CollectionArrayOrValuesAdder, type schema_d_CollectionConstructor as CollectionConstructor, type schema_d_CollectionFromSchema as CollectionFromSchema, type schema_d_CollectionInterface as CollectionInterface, schema_d_CollectionRoot as CollectionRoot, type schema_d_DefaultArgs as DefaultArgs, EntityMixin as Entity, type schema_d_EntityInterface as EntityInterface, type schema_d_EntityMap as EntityMap, schema_d_EntityMixin as EntityMixin, schema_d_Invalidate as Invalidate, type schema_d_MergeFunction as MergeFunction, Object$1 as Object, schema_d_Query as Query, type schema_d_SchemaAttributeFunction as SchemaAttributeFunction, type schema_d_SchemaClass as SchemaClass, type schema_d_SchemaFunction as SchemaFunction, type schema_d_StrategyFunction as StrategyFunction, schema_d_Union as Union, type schema_d_UnionConstructor as UnionConstructor, type schema_d_UnionInstance as UnionInstance, type schema_d_UnionResult as UnionResult, schema_d_UnionRoot as UnionRoot, schema_d_Values as Values, schema_d_unshift as unshift }; + export { schema_d_All as All, Array$1 as Array, schema_d_Collection as Collection, type schema_d_CollectionArrayAdder as CollectionArrayAdder, type schema_d_CollectionArrayOrValuesAdder as CollectionArrayOrValuesAdder, type schema_d_CollectionConstructor as CollectionConstructor, type schema_d_CollectionFromSchema as CollectionFromSchema, type schema_d_CollectionInterface as CollectionInterface, schema_d_CollectionRoot as CollectionRoot, type schema_d_DefaultArgs as DefaultArgs, EntityMixin as Entity, type schema_d_EntityInterface as EntityInterface, type schema_d_EntityMap as EntityMap, schema_d_EntityMixin as EntityMixin, schema_d_Invalidate as Invalidate, schema_d_Lazy as Lazy, type schema_d_MergeFunction as MergeFunction, Object$1 as Object, schema_d_Query as Query, type schema_d_SchemaAttributeFunction as SchemaAttributeFunction, type schema_d_SchemaClass as SchemaClass, type schema_d_SchemaFunction as SchemaFunction, type schema_d_StrategyFunction as StrategyFunction, schema_d_Union as Union, type schema_d_UnionConstructor as UnionConstructor, type schema_d_UnionInstance as UnionInstance, type schema_d_UnionResult as UnionResult, schema_d_UnionRoot as UnionRoot, schema_d_Values as Values, schema_d_unshift as unshift }; } declare const Entity_base: IEntityClass { @@ -2129,4 +2190,4 @@ declare function useController(): Controller; declare function useLive>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType$1 : Denormalize$1; declare function useLive>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType$1 | undefined : DenormalizeNullable$1; -export { type AbstractInstanceType, type AddEndpoint, All, Array$1 as Array, _default as AsyncBoundary, type CheckLoop, Collection, type CustomResource, DataProvider, type DefaultArgs, type Defaults, type Denormalize, type DenormalizeNullable, type DenormalizeNullableObject, type DenormalizeObject, Endpoint, type EndpointExtendOptions, type EndpointExtraOptions, type EndpointInstance, type EndpointInstanceInterface, type EndpointInterface, type EndpointOptions, type EndpointParam, type EndpointToFunction, type EntitiesInterface, type EntitiesPath, Entity, type EntityFields, type EntityInterface, type EntityMap, EntityMixin, type EntityPath, type EntityTable, type ErrorTypes$1 as ErrorTypes, type ExpiryStatusInterface, ExtendableEndpoint, type ExtendedResource, type FetchFunction, type FetchGet, type FetchMutate, type FromFallBack, type GetEndpoint, type GetEntity, type GetIndex, type HookResource, type HookableEndpointInterface, type IEntityClass, type IEntityInstance, type INormalizeDelegate, type IQueryDelegate, type RestEndpoint$1 as IRestEndpoint, type IndexPath, Invalidate, type KeyofEndpointInstance, type KeyofRestEndpoint, type KeysToArgs, type Mergeable, type MethodToSide, type MoveEndpoint, type MutateEndpoint, type NI, NetworkError, ErrorBoundary as NetworkErrorBoundary, type Normalize, type NormalizeNullable, type NormalizeObject, type NormalizedEntity, type NormalizedIndex, type NormalizedNullableObject, Object$1 as Object, type ObjectArgs, type OptionsToFunction, type PaginationEndpoint, type PaginationFieldEndpoint, type ParamFetchNoBody, type ParamFetchWithBody, type ParamToArgs, type PartialRestGenerics, type PathArgs, type PathArgsAndSearch, type PathKeys, type PolymorphicInterface, Query, type Queryable, type ReadEndpoint, type RecordClass, type RemoveEndpoint, type ResolveType, type Resource, type ResourceEndpointExtensions, type ResourceExtension, type ResourceGenerics, type ResourceInterface, type ResourceOptions, RestEndpoint, type RestEndpointConstructor, type RestEndpointConstructorOptions, type RestEndpointExtendOptions, type RestEndpointOptions, type RestExtendedEndpoint, type RestFetch, type RestGenerics, type RestInstance, type RestInstanceBase, type RestType, type RestTypeNoBody, type RestTypeWithBody, type Schema, type SchemaArgs, type SchemaClass, type SchemaSimple, type Serializable, type ShortenPath, type SnapshotInterface, Union, type UnknownError, Values, type Visit, resource as createResource, getUrlBase, getUrlTokens, hookifyResource, resource, schema_d as schema, unshift, useCache, useController, useDLE, useError, useFetch, useLive, useQuery, useSubscription, useSuspense, validateRequired }; +export { type AbstractInstanceType, type AddEndpoint, All, Array$1 as Array, _default as AsyncBoundary, type CheckLoop, Collection, type CustomResource, DataProvider, type DefaultArgs, type Defaults, type Denormalize, type DenormalizeNullable, type DenormalizeNullableObject, type DenormalizeObject, Endpoint, type EndpointExtendOptions, type EndpointExtraOptions, type EndpointInstance, type EndpointInstanceInterface, type EndpointInterface, type EndpointOptions, type EndpointParam, type EndpointToFunction, type EntitiesInterface, type EntitiesPath, Entity, type EntityFields, type EntityInterface, type EntityMap, EntityMixin, type EntityPath, type EntityTable, type ErrorTypes$1 as ErrorTypes, type ExpiryStatusInterface, ExtendableEndpoint, type ExtendedResource, type FetchFunction, type FetchGet, type FetchMutate, type FromFallBack, type GetEndpoint, type GetEntity, type GetIndex, type HookResource, type HookableEndpointInterface, type IEntityClass, type IEntityInstance, type INormalizeDelegate, type IQueryDelegate, type RestEndpoint$1 as IRestEndpoint, type IndexPath, Invalidate, type KeyofEndpointInstance, type KeyofRestEndpoint, type KeysToArgs, Lazy, type Mergeable, type MethodToSide, type MoveEndpoint, type MutateEndpoint, type NI, NetworkError, ErrorBoundary as NetworkErrorBoundary, type Normalize, type NormalizeNullable, type NormalizeObject, type NormalizedEntity, type NormalizedIndex, type NormalizedNullableObject, Object$1 as Object, type ObjectArgs, type OptionsToFunction, type PaginationEndpoint, type PaginationFieldEndpoint, type ParamFetchNoBody, type ParamFetchWithBody, type ParamToArgs, type PartialRestGenerics, type PathArgs, type PathArgsAndSearch, type PathKeys, type PolymorphicInterface, Query, type Queryable, type ReadEndpoint, type RecordClass, type RemoveEndpoint, type ResolveType, type Resource, type ResourceEndpointExtensions, type ResourceExtension, type ResourceGenerics, type ResourceInterface, type ResourceOptions, RestEndpoint, type RestEndpointConstructor, type RestEndpointConstructorOptions, type RestEndpointExtendOptions, type RestEndpointOptions, type RestExtendedEndpoint, type RestFetch, type RestGenerics, type RestInstance, type RestInstanceBase, type RestType, type RestTypeNoBody, type RestTypeWithBody, type Schema, type SchemaArgs, type SchemaClass, type SchemaSimple, type Serializable, type ShortenPath, type SnapshotInterface, Union, type UnknownError, Values, type Visit, resource as createResource, getUrlBase, getUrlTokens, hookifyResource, resource, schema_d as schema, unshift, useCache, useController, useDLE, useError, useFetch, useLive, useQuery, useSubscription, useSuspense, validateRequired }; From 054d0457231bf406adbcc1a454316fa25198c8c4 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 29 Mar 2026 15:24:35 -0400 Subject: [PATCH 9/9] docs: Docs updates --- docs/core/api/useQuery.md | 5 +- docs/core/shared/_schema_table.mdx | 8 ++- docs/rest/api/Entity.md | 3 + docs/rest/api/Lazy.md | 10 +-- docs/rest/api/Query.md | 1 + docs/rest/api/schema.md | 2 +- docs/rest/guides/relational-data.md | 9 +++ packages/normalizr/src/denormalize/unvisit.ts | 5 +- .../2026-01-19-v0.16-release-announcement.md | 69 ++++++++++++++++--- website/sidebars-endpoint.json | 4 ++ 10 files changed, 96 insertions(+), 20 deletions(-) diff --git a/docs/core/api/useQuery.md b/docs/core/api/useQuery.md index e476a0c28468..6dce37f2e103 100644 --- a/docs/core/api/useQuery.md +++ b/docs/core/api/useQuery.md @@ -16,7 +16,8 @@ import VoteDemo from '../shared/\_VoteDemo.mdx'; Data rendering without the fetch. Access any [Queryable Schema](/rest/api/schema#queryable)'s store value; like [Entity](/rest/api/Entity), [All](/rest/api/All), [Collection](/rest/api/Collection), [Query](/rest/api/Query), -and [Union](/rest/api/Union). If the value does not exist, returns `undefined`. +and [Union](/rest/api/Union). [Lazy](/rest/api/Lazy) fields also work via their [`.query`](/rest/api/Lazy#query) accessor. +If the value does not exist, returns `undefined`. `useQuery()` is reactive to data [mutations](../getting-started/mutations.md); rerendering only when necessary. Returns `undefined` when data is [Invalid](../concepts/expiry-policy#invalid). @@ -59,7 +60,7 @@ function useQuery( [Queryable](/rest/api/schema#queryable) schemas require an `queryKey()` method that returns something. These include [Entity](/rest/api/Entity), [All](/rest/api/All), [Collection](/rest/api/Collection), [Query](/rest/api/Query), -and [Union](/rest/api/Union). +and [Union](/rest/api/Union). [Lazy](/rest/api/Lazy) fields produce a Queryable via their [`.query`](/rest/api/Lazy#query) accessor. ```ts interface Queryable { diff --git a/docs/core/shared/_schema_table.mdx b/docs/core/shared/_schema_table.mdx index 2ea342a62566..4d20e11f1ac6 100644 --- a/docs/core/shared/_schema_table.mdx +++ b/docs/core/shared/_schema_table.mdx @@ -66,10 +66,16 @@ 🛑 -any +any [Query(Queryable)](/rest/api/Query) memoized custom transforms ✅ + + +[Lazy(Schema)](/rest/api/Lazy) +deferred denormalization +🛑 + diff --git a/docs/rest/api/Entity.md b/docs/rest/api/Entity.md index 3e19d296307b..7797775da183 100644 --- a/docs/rest/api/Entity.md +++ b/docs/rest/api/Entity.md @@ -451,6 +451,9 @@ class Department extends Entity { Set this on entities that participate in deep or wide bidirectional relationships. Normal entity graphs (depth < 10) never approach the default limit. +For relationships that don't need eager denormalization, [Lazy](/rest/api/Lazy) +skips resolution entirely and lets you resolve on demand via [useQuery](/docs/api/useQuery). + ::: ## Lifecycle diff --git a/docs/rest/api/Lazy.md b/docs/rest/api/Lazy.md index 58fb3a140977..e8b1e50447a1 100644 --- a/docs/rest/api/Lazy.md +++ b/docs/rest/api/Lazy.md @@ -15,7 +15,7 @@ This is useful for: ## Constructor ```typescript -new schema.Lazy(innerSchema) +new Lazy(innerSchema) ``` - `innerSchema`: Any [Schema](/rest/api/schema) — an [Entity](./Entity.md), an array shorthand like `[MyEntity]`, a [Collection](./Collection.md), etc. @@ -25,7 +25,7 @@ new schema.Lazy(innerSchema) ### Array relationship (most common) ```typescript -import { Entity, schema } from '@data-client/rest'; +import { Entity, Lazy } from '@data-client/rest'; class Building extends Entity { id = ''; @@ -38,7 +38,7 @@ class Department extends Entity { buildings: string[] = []; static schema = { - buildings: new schema.Lazy([Building]), + buildings: new Lazy([Building]), }; } ``` @@ -71,7 +71,7 @@ class Department extends Entity { mainBuilding = ''; static schema = { - mainBuilding: new schema.Lazy(Building), + mainBuilding: new Lazy(Building), }; } ``` @@ -92,7 +92,7 @@ When the inner schema is an [Entity](./Entity.md) (or any schema with `queryKey` class Department extends Entity { id = ''; static schema = { - buildings: new schema.Lazy(buildingsCollection), + buildings: new Lazy(buildingsCollection), }; } ``` diff --git a/docs/rest/api/Query.md b/docs/rest/api/Query.md index 1b7d6b2c8b4f..1f305840e970 100644 --- a/docs/rest/api/Query.md +++ b/docs/rest/api/Query.md @@ -25,6 +25,7 @@ the same high performance and referential equality guarantees expected of Reacti [Schema](./schema.md) used to retrieve/denormalize data from the Reactive Data Client cache. This accepts any [Queryable](/rest/api/schema#queryable) schema: [Entity](./Entity.md), [All](./All.md), [Collection](./Collection.md), [Query](./Query.md), [Union](./Union.md), and [Object](./Object.md) schemas for joining multiple entities. +[Lazy](./Lazy.md) fields produce a Queryable via their [`.query`](./Lazy.md#query) accessor. ### process(entries, ...args) {#process} diff --git a/docs/rest/api/schema.md b/docs/rest/api/schema.md index c0fcade20249..8a54cec6e5cc 100644 --- a/docs/rest/api/schema.md +++ b/docs/rest/api/schema.md @@ -244,7 +244,7 @@ This enables their use in these additional cases: - Improve performance of [useSuspense](/docs/api/useSuspense), [useDLE](/docs/api/useDLE) by rendering before endpoint resolution `Querables` include [Entity](./Entity.md), [All](./All.md), [Collection](./Collection.md), [Query](./Query.md), -and [Union](./Union.md). +and [Union](./Union.md). [Lazy](./Lazy.md) fields produce a Queryable via their [`.query`](./Lazy.md#query) accessor. ```ts interface Queryable { diff --git a/docs/rest/guides/relational-data.md b/docs/rest/guides/relational-data.md index 7c5370ea110f..c621a4f52995 100644 --- a/docs/rest/guides/relational-data.md +++ b/docs/rest/guides/relational-data.md @@ -600,6 +600,15 @@ export class User extends Entity { } ``` +:::tip + +For bidirectional relationships that don't need eager denormalization, +[Lazy](../api/Lazy.md) defers resolution and lets you resolve on demand +via [useQuery](/docs/api/useQuery), avoiding deep recursion and improving +memoization isolation. + +::: + [1]: ../api/Entity.md [2]: /docs/api/useCache [3]: ../api/Entity.md#schema diff --git a/packages/normalizr/src/denormalize/unvisit.ts b/packages/normalizr/src/denormalize/unvisit.ts index 6fdd00efc706..d92a3201ddaf 100644 --- a/packages/normalizr/src/denormalize/unvisit.ts +++ b/packages/normalizr/src/denormalize/unvisit.ts @@ -137,9 +137,10 @@ const getUnvisit = ( console.error( `Entity depth limit of ${limit} reached for "${schema.key}" entity. ` + `This usually means your schema has very deep or wide bidirectional relationships. ` + - `Nested entities beyond this depth are returned with unresolved ids.` + + `Nested entities beyond this depth are returned with unresolved ids. ` + + `Consider using Lazy for recursive schemas to avoid depth limits with better performance: https://dataclient.io/rest/api/Lazy` + (schema.maxEntityDepth === undefined ? - ` Set static maxEntityDepth on your Entity to configure this limit.` + ` Alternatively, set static maxEntityDepth on your Entity to configure this limit.` : ''), ); } diff --git a/website/blog/2026-01-19-v0.16-release-announcement.md b/website/blog/2026-01-19-v0.16-release-announcement.md index 45f8b87e536b..aa7d1ad84283 100644 --- a/website/blog/2026-01-19-v0.16-release-announcement.md +++ b/website/blog/2026-01-19-v0.16-release-announcement.md @@ -17,9 +17,12 @@ import { parallelFetchFixtures } from '@site/src/fixtures/post-comments'; - [Collection.move](/blog/2026/01/19/v0.16-release-announcement#collection-move) - Move entities between [Collections](/rest/api/Collection) with a single operation - [Collection.moveWith()](/blog/2026/01/19/v0.16-release-announcement#collection-move) - Customize move behavior (e.g., prepend instead of append) +- [Lazy](/blog/2026/01/19/v0.16-release-announcement#lazy) - Deferred relationship denormalization for performance and memoization isolation + **Performance:** **Other Improvements:** +- [Denormalization depth limit](/blog/2026/01/19/v0.16-release-announcement#denormalization-depth-limit) - Prevent stack overflow in large bidirectional entity graphs; configurable via [`Entity.maxEntityDepth`](/rest/api/Entity#maxEntityDepth) ([#3822](https://github.com/reactive/data-client/issues/3822)) - Remove misleading 'Uncaught Suspense' warning during Next.js SSR - Fix `sideEffect: false` type being lost with `method: 'POST'` in [RestEndpoint](/rest/api/RestEndpoint) - [renderDataHook()](/docs/api/renderDataHook) automatic cleanup after each test — no manual `cleanup()` calls needed @@ -222,17 +225,65 @@ render(); -## Denormalization depth limit - -Denormalization now enforces a depth limit of 128 entity hops to prevent -`RangeError: Maximum call stack size exceeded` when schemas have deep or wide -bidirectional relationships (e.g., `Department → Building → Department → ...` -with thousands of unique entities). Entities beyond the depth limit are returned -with unresolved ids. A `console.error` is emitted in development mode when the -limit is reached. +## Denormalization depth limit & Lazy +When schemas have bidirectional relationships across entity types +(e.g., `Department → Building → Department → ...`), denormalization traverses every unique +entity in the connected graph. With small datasets this is invisible, but at scale — thousands +of interconnected entities — the recursion exceeds the JS call stack and throws +`RangeError: Maximum call stack size exceeded`. [#3822](https://github.com/reactive/data-client/issues/3822) +Two complementary features address this: a **depth limit** as a safety net, and +[**Lazy**](/rest/api/Lazy) for precise, per-field control over which relationships are resolved eagerly. + +### Denormalization depth limit + +Denormalization now enforces a default depth limit of 64 entity hops. Entities beyond +the limit are returned with unresolved ids instead of fully denormalized objects. +A `console.error` is emitted in development mode when the limit is reached. + +The limit can be configured per-Entity with [`static maxEntityDepth`](/rest/api/Entity#maxEntityDepth): + +```ts +class Department extends Entity { + static maxEntityDepth = 16; +} +``` + +This prevents crashes, but is a blunt instrument — it cuts off *all* deep paths, +including legitimate non-cyclic ones. For fine-grained control, use [Lazy](#lazy). + +### Lazy + +[Lazy](/rest/api/Lazy) wraps a schema to skip eager denormalization of specific relationship fields. +During parent entity denormalization, the field retains its raw normalized value (primary keys). +The relationship can then be resolved on demand via [useQuery](/docs/api/useQuery) using the `.query` accessor. + +```ts +import { Entity, Lazy } from '@data-client/rest'; + +class Department extends Entity { + id = ''; + name = ''; + buildings: string[] = []; + + static schema = { + // highlight-next-line + buildings: new Lazy([Building]), + }; +} +``` + +Unlike the depth limit, `Lazy` is a targeted opt-in per relationship. Only the +fields you mark as lazy skip eager resolution — the rest of the schema denormalizes normally. +This also improves performance by deferring work for relationships that aren't always +needed, and provides **memoization isolation** — changes to lazy entities don't invalidate +the parent's denormalized form. + +See [Lazy documentation](/rest/api/Lazy) for full usage details and +[#3828](https://github.com/reactive/data-client/discussions/3828) for design discussion. + ## Migration guide This upgrade requires updating all package versions simultaneously. @@ -450,7 +501,7 @@ const myUnion = new Union( -All schema classes are available as direct exports: [Union](/rest/api/Union), [Invalidate](/rest/api/Invalidate), [Collection](/rest/api/Collection), [Query](/rest/api/Query), [Values](/rest/api/Values), and [All](/rest/api/All). The `schema` namespace export remains available for backward compatibility. +All schema classes are available as direct exports: [Union](/rest/api/Union), [Invalidate](/rest/api/Invalidate), [Collection](/rest/api/Collection), [Query](/rest/api/Query), [Values](/rest/api/Values), [All](/rest/api/All), and [Lazy](/rest/api/Lazy). The `schema` namespace export remains available for backward compatibility. ### Upgrade support diff --git a/website/sidebars-endpoint.json b/website/sidebars-endpoint.json index 8606ed5870e0..2d4bcdc9c6cc 100644 --- a/website/sidebars-endpoint.json +++ b/website/sidebars-endpoint.json @@ -47,6 +47,10 @@ "type": "doc", "id": "api/Invalidate" }, + { + "type": "doc", + "id": "api/Lazy" + }, { "type": "doc", "id": "api/validateRequired"