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/.changeset/fix-denorm-depth-limit.md b/.changeset/fix-denorm-depth-limit.md index 9c6172c4f3dc..1c313600f2a7 100644 --- a/.changeset/fix-denorm-depth-limit.md +++ b/.changeset/fix-denorm-depth-limit.md @@ -1,5 +1,6 @@ --- '@data-client/normalizr': patch +'@data-client/endpoint': patch '@data-client/core': patch '@data-client/react': patch '@data-client/vue': patch @@ -7,7 +8,15 @@ Fix stack overflow during denormalization of large bidirectional entity graphs. -Add entity depth limit (128) to prevent `RangeError: Maximum call stack size exceeded` +Add entity depth limit (64) to prevent `RangeError: Maximum call stack size exceeded` when denormalizing cross-type chains with thousands of unique entities (e.g., Department β†’ Building β†’ Department β†’ ...). Entities beyond the depth limit are returned with unresolved ids instead of fully denormalized nested objects. + +The limit can be configured per-Entity with [`static maxEntityDepth`](/rest/api/Entity#maxEntityDepth): + +```ts +class Department extends Entity { + static maxEntityDepth = 16; +} +``` 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 2fcbadf87359..7797775da183 100644 --- a/docs/rest/api/Entity.md +++ b/docs/rest/api/Entity.md @@ -418,6 +418,44 @@ Nested below: const price = useQuery(LatestPrice, { symbol: 'BTC' }); ``` +### static maxEntityDepth?: number {#maxEntityDepth} + +Limits entity nesting depth during denormalization to prevent stack overflow +in large bidirectional entity graphs. **Default: 128** + +When bidirectional relationships create chains with many unique entities +(e.g., `Department β†’ Building β†’ Department β†’ ...`), denormalization can recurse +thousands of levels deep. `maxEntityDepth` truncates resolution at the specified +depth β€” entities beyond the limit are returned with nested foreign keys left as +unresolved ids rather than fully denormalized objects. + +```typescript +class Department extends Entity { + id = ''; + name = ''; + buildings: Building[] = []; + + pk() { return this.id; } + static key = 'Department'; + // highlight-next-line + static maxEntityDepth = 16; + + static schema = { + buildings: [Building], + }; +} +``` + +:::tip + +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 import Lifecycle from '../diagrams/\_entity_lifecycle.mdx'; diff --git a/docs/rest/api/Lazy.md b/docs/rest/api/Lazy.md new file mode 100644 index 000000000000..e8b1e50447a1 --- /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 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, Lazy } from '@data-client/rest'; + +class Building extends Entity { + id = ''; + name = ''; +} + +class Department extends Entity { + id = ''; + name = ''; + buildings: string[] = []; + + static schema = { + buildings: new 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 ( + + ); +} +``` + +### Single entity relationship + +```typescript +class Department extends Entity { + id = ''; + name = ''; + mainBuilding = ''; + + static schema = { + mainBuilding: new 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 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. 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/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..5a0c06507d3d 100644 --- a/packages/endpoint/src/schema.d.ts +++ b/packages/endpoint/src/schema.d.ts @@ -25,6 +25,7 @@ import { default as Entity, } from './schemas/EntityMixin.js'; import { default as Invalidate } from './schemas/Invalidate.js'; +import { default as Lazy } from './schemas/Lazy.js'; import { default as Query } from './schemas/Query.js'; import type { CollectionConstructor, @@ -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/EntityMixin.ts b/packages/endpoint/src/schemas/EntityMixin.ts index 6c7886e561d8..221f4c282d78 100644 --- a/packages/endpoint/src/schemas/EntityMixin.ts +++ b/packages/endpoint/src/schemas/EntityMixin.ts @@ -80,6 +80,13 @@ export default function EntityMixin( /** Defines indexes to enable lookup by */ declare static indexes?: readonly string[]; + /** Maximum entity nesting depth for denormalization (default: 128) + * + * Set a lower value to truncate deep bidirectional entity graphs earlier. + * @see https://dataclient.io/rest/api/Entity#maxEntityDepth + */ + declare static maxEntityDepth?: number; + /** * A unique identifier for each Entity * diff --git a/packages/endpoint/src/schemas/EntityTypes.ts b/packages/endpoint/src/schemas/EntityTypes.ts index ff17b524fdce..8e5f0d0179d5 100644 --- a/packages/endpoint/src/schemas/EntityTypes.ts +++ b/packages/endpoint/src/schemas/EntityTypes.ts @@ -30,6 +30,12 @@ export interface IEntityClass { * @see https://dataclient.io/rest/api/Entity#indexes */ indexes?: readonly string[] | undefined; + /** Maximum entity nesting depth for denormalization (default: 128) + * + * Set a lower value to truncate deep bidirectional entity graphs earlier. + * @see https://dataclient.io/rest/api/Entity#maxEntityDepth + */ + maxEntityDepth?: number | undefined; /** * A unique identifier for each Entity * diff --git a/packages/endpoint/src/schemas/Lazy.ts b/packages/endpoint/src/schemas/Lazy.ts new file mode 100644 index 000000000000..65a4d44fef09 --- /dev/null +++ b/packages/endpoint/src/schemas/Lazy.ts @@ -0,0 +1,131 @@ +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. + * 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 { + // 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; + } + + queryKey( + _args: readonly any[], + _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; + } + + /** 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> { + declare schema: S; + + constructor(schema: S) { + this.schema = schema; + } + + denormalize( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ): Denormalize { + return unvisit(this.schema, input); + } + + queryKey( + args: Args, + unvisit: (...args: any) => any, + delegate: { getEntity: any; getIndex: any }, + ): any { + const schema = this.schema as any; + if (typeof schema.queryKey === 'function' && schema.key) { + return schema.queryKey(args, unvisit, delegate); + } + return (args as readonly any[])[0]; + } + + declare _denormalizeNullable: ( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ) => DenormalizeNullable; +} diff --git a/packages/endpoint/src/schemas/__tests__/Entity.test.ts b/packages/endpoint/src/schemas/__tests__/Entity.test.ts index 3aef958c0293..3e49e0a55ce2 100644 --- a/packages/endpoint/src/schemas/__tests__/Entity.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Entity.test.ts @@ -1011,11 +1011,10 @@ describe(`${Entity.name} denormalization`, () => { expect(result).toBeDefined(); if (!result) return; - // walk to a depth-limited entity: each hop is 1 entity depth - // dept-0(1) -> bldg-0(2) -> dept-1(3) -> bldg-1(4) -> ... - // At depth 128 we should find truncated entities with unresolved FK ids + // walk to a depth-limited entity: each deptβ†’bldgβ†’dept step is 2 entity depths + // dept-k is entered at depth 2k (default MAX_ENTITY_DEPTH is 64) let node: any = result; - for (let i = 0; i < 60; i++) { + for (let i = 0; i < 30; i++) { expect(node.buildings).toBeDefined(); expect(node.buildings.length).toBe(1); node = node.buildings[0].departments[0]; @@ -1023,19 +1022,21 @@ describe(`${Entity.name} denormalization`, () => { // node should still be a Department instance (well within limit) expect(node).toBeInstanceOf(Department); - expect(consoleSpy).toHaveBeenCalledTimes(1); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Entity depth limit'), - ); + expect(consoleSpy).toHaveBeenCalled(); + expect( + consoleSpy.mock.calls.some(args => + String(args[0]).includes('Entity depth limit'), + ), + ).toBe(true); consoleSpy.mockRestore(); }); test('depth-limited entity that is missing from store', () => { const entities = buildChain(200); - // dept-64 is the first entity hit by the depth limit (checked at depth=128). + // dept-32 is the first Department hit by the default depth limit (depth 64). // Removing it exercises the "entity not found" branch in depthLimitEntity. - delete (entities.Department as any)['dept-64']; + delete (entities.Department as any)['dept-32']; const consoleSpy = jest .spyOn(console, 'error') @@ -1073,8 +1074,8 @@ describe(`${Entity.name} denormalization`, () => { for (let i = 0; i < 200; i++) { deptEntities[`dept-${i}`] = { id: `dept-${i}`, - // dept-64 is the first depth-limited entity; mark it invalid - name: i === 64 ? 'INVALID' : `Department ${i}`, + // dept-32 is the first depth-limited Department; mark it invalid + name: i === 32 ? 'INVALID' : `Department ${i}`, buildings: [`bldg-${i}`], }; bldgEntities[`bldg-${i}`] = { @@ -1099,10 +1100,10 @@ describe(`${Entity.name} denormalization`, () => { test('depth-limited entity with inline object input', () => { const entities = buildChain(200); - // bldg-63's departments are processed when depth=128, hitting depthLimitEntity. + // bldg-31's departments are processed when depth=64, hitting depthLimitEntity. // Use inline object instead of string pk to exercise the object-input branch. - (entities.Building as any)['bldg-63'].departments = [ - { id: 'dept-64', name: 'Inline Department 64', buildings: [] }, + (entities.Building as any)['bldg-31'].departments = [ + { id: 'dept-32', name: 'Inline Department 32', buildings: [] }, ]; const consoleSpy = jest @@ -1117,17 +1118,86 @@ describe(`${Entity.name} denormalization`, () => { // walk to the depth-limited inline entity let node: any = result; - for (let i = 0; i < 63; i++) { + for (let i = 0; i < 31; i++) { node = node.buildings[0].departments[0]; } - // node is dept-63, its building is bldg-63 - expect(node.id).toBe('dept-63'); + // node is dept-31, its building is bldg-31 + expect(node.id).toBe('dept-31'); const depthLimitedBldg = node.buildings[0]; - expect(depthLimitedBldg.id).toBe('bldg-63'); - // dept-64 was provided as an inline object, so depthLimitEntity used it directly + expect(depthLimitedBldg.id).toBe('bldg-31'); + // dept-32 was provided as an inline object, so depthLimitEntity used it directly const inlineDept = depthLimitedBldg.departments[0]; expect(inlineDept).toBeInstanceOf(Department); - expect(inlineDept.id).toBe('dept-64'); + expect(inlineDept.id).toBe('dept-32'); + + consoleSpy.mockRestore(); + }); + + test('maxEntityDepth on Entity lowers the limit', () => { + class LimitedDept extends IDEntity { + readonly name: string = ''; + readonly buildings: LimitedBldg[] = []; + static maxEntityDepth = 10; + } + class LimitedBldg extends IDEntity { + readonly name: string = ''; + readonly departments: LimitedDept[] = []; + + static schema = { + departments: [LimitedDept], + }; + + static maxEntityDepth = 10; + } + LimitedDept.schema = { + buildings: new schema.Array(LimitedBldg), + }; + + const deptEntities: Record = {}; + const bldgEntities: Record = {}; + for (let i = 0; i < 50; i++) { + deptEntities[`dept-${i}`] = { + id: `dept-${i}`, + name: `Department ${i}`, + buildings: [`bldg-${i}`], + }; + bldgEntities[`bldg-${i}`] = { + id: `bldg-${i}`, + name: `Building ${i}`, + departments: i < 49 ? [`dept-${i + 1}`] : [], + }; + } + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = plainDenormalize(LimitedDept, 'dept-0', { + LimitedDept: deptEntities, + LimitedBldg: bldgEntities, + }); + + expect(result).not.toEqual(expect.any(Symbol)); + if (typeof result === 'symbol') return; + expect(result).toBeDefined(); + if (!result) return; + + // depth 10 means 5 full hops (deptβ†’bldg = 2 entity levels per hop) + // walk 4 hops safely + let node: any = result; + for (let i = 0; i < 4; i++) { + expect(node.buildings).toBeDefined(); + expect(node.buildings.length).toBe(1); + node = node.buildings[0].departments[0]; + } + expect(node).toBeInstanceOf(LimitedDept); + + expect(consoleSpy).toHaveBeenCalled(); + expect( + consoleSpy.mock.calls.some(args => + String(args[0]).includes('Entity depth limit of 10'), + ), + ).toBe(true); consoleSpy.mockRestore(); }); 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..38bf01b8a268 --- /dev/null +++ b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts @@ -0,0 +1,792 @@ +// eslint-env jest +/// +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 '../..'; + +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 = ''; + readonly floors: number = 1; +} + +class Manager extends IDEntity { + readonly name: string = ''; +} + +class Department extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + readonly manager: Manager = {} as any; + + static schema = { + buildings: new schema.Lazy([Building]), + manager: Manager, + }; +} + +class SingleRefDepartment extends IDEntity { + readonly name: string = ''; + readonly mainBuilding: string = ''; + + static schema = { + mainBuilding: new schema.Lazy(Building), + }; +} + +describe('Lazy schema', () => { + 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, []); + + expect(result.result).toBe('dept-1'); + 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('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', floors: 10 }, + }, + [], + ); + 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 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', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + Manager: { + 'mgr-1': { id: 'mgr-1', name: 'Alice' }, + }, + }; + + test('plainDenormalize keeps Lazy array as string IDs', () => { + const dept: any = plainDenormalize(Department, 'dept-1', entities); + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(dept.buildings[0]).not.toBeInstanceOf(Building); + // non-Lazy Manager IS resolved + expect(dept.manager).toBeInstanceOf(Manager); + expect(dept.manager.name).toBe('Alice'); + }); + + test('SimpleMemoCache keeps Lazy array as string IDs', () => { + const memo = new SimpleMemoCache(); + const dept: any = memo.denormalize(Department, 'dept-1', entities); + expect(typeof dept).toBe('object'); + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(dept.manager).toBeInstanceOf(Manager); + }); + + test('single entity Lazy field stays as string PK', () => { + const singleEntities = { + SingleRefDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + mainBuilding: 'bldg-1', + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'HQ', floors: 10 }, + }, + }; + const dept: any = plainDenormalize( + SingleRefDepartment, + 'dept-1', + singleEntities, + ); + expect(dept.mainBuilding).toBe('bldg-1'); + expect(typeof dept.mainBuilding).toBe('string'); + }); + + test('parent paths exclude lazy entity dependencies', () => { + const memo = new MemoCache(); + 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('LazyQuery resolution via .query', () => { + const state = { + 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', 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 getter always returns the same instance', () => { + const lazy = Department.schema.buildings; + expect(lazy.query).toBe(lazy.query); + }); + + test('resolves array of IDs into Building instances', () => { + const memo = new MemoCache(); + 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('resolved entities track Building dependencies', () => { + const memo = new MemoCache(); + const result = memo.query( + Department.schema.buildings.query, + [['bldg-1', 'bldg-2']], + state, + ); + const buildingPaths = result.paths.filter(p => p.key === 'Building'); + 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('subset of IDs resolves only those buildings', () => { + const memo = new MemoCache(); + 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('empty IDs array resolves to empty array', () => { + const memo = new MemoCache(); + const result = memo.query(Department.schema.buildings.query, [[]], state); + expect(result.data).toEqual([]); + expect(result.paths).toEqual([]); + }); + + test('IDs referencing missing entities are filtered out', () => { + const memo = new MemoCache(); + 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 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', 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 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('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]), + }; + } + + 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 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 as any).schema = { + buildings: new schema.Lazy([BidirBuilding]), + }; + (BidirBuilding as any).schema = { + departments: new schema.Lazy([BidirDepartment]), + }; + + function buildChain(length: number) { + const departmentEntities: Record = {}; + const buildingEntities: Record = {}; + for (let i = 0; i < 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 < length - 1 ? [`dept-${i + 1}`] : [], + }; + } + return { + BidirDepartment: departmentEntities, + BidirBuilding: buildingEntities, + }; + } + + test('1500-node chain does not overflow (plainDenormalize)', () => { + const entities = buildChain(1500); + expect(() => + 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(BidirDepartment, 'dept-0', entities), + ).not.toThrow(); + }); + + 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 as any).schema.buildings as schema.Lazy + ).query; + const bldgDeptsQuery = ( + (BidirBuilding as any).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('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]); + // eslint-disable-next-line @typescript-eslint/no-empty-function + expect(lazy.queryKey([], () => {}, {} as any)).toBeUndefined(); + }); + }); +}); 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/packages/normalizr/src/denormalize/unvisit.ts b/packages/normalizr/src/denormalize/unvisit.ts index 83cfd082f200..d92a3201ddaf 100644 --- a/packages/normalizr/src/denormalize/unvisit.ts +++ b/packages/normalizr/src/denormalize/unvisit.ts @@ -97,7 +97,7 @@ function noCacheGetEntity( return localCacheKey.get(''); } -const MAX_ENTITY_DEPTH = 128; +const MAX_ENTITY_DEPTH = 64; const getUnvisit = ( getEntity: DenormGetEntity, @@ -129,14 +129,19 @@ const getUnvisit = ( } } else { if (isEntity(schema)) { - if (depth >= MAX_ENTITY_DEPTH) { + if (depth >= (schema.maxEntityDepth ?? MAX_ENTITY_DEPTH)) { /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && !depthLimitHit) { depthLimitHit = true; + const limit = schema.maxEntityDepth ?? MAX_ENTITY_DEPTH; console.error( - `Entity depth limit of ${MAX_ENTITY_DEPTH} reached for "${schema.key}" entity. ` + + `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 ? + ` Alternatively, set static maxEntityDepth on your Entity to configure this limit.` + : ''), ); } return depthLimitEntity(getEntity, schema, input); diff --git a/packages/normalizr/src/interface.ts b/packages/normalizr/src/interface.ts index 410bec3a5dc0..e24a34f4bfd0 100644 --- a/packages/normalizr/src/interface.ts +++ b/packages/normalizr/src/interface.ts @@ -63,6 +63,7 @@ export interface EntityInterface extends SchemaSimple { schema: Record; prototype: T; cacheWith?: object; + maxEntityDepth?: number; } export interface Mergeable { 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" diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index 4edf2d63aa3f..f3f0bc0b4af0 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -35,6 +35,7 @@ interface EntityInterface extends SchemaSimple { schema: Record; prototype: T; cacheWith?: object; + maxEntityDepth?: number; } interface Mergeable { key: string; 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 9dde0bb92766..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 @@ -443,6 +443,12 @@ interface IEntityClass { * @see https://dataclient.io/rest/api/Entity#indexes */ indexes?: readonly string[] | undefined; + /** Maximum entity nesting depth for denormalization (default: 128) + * + * Set a lower value to truncate deep bidirectional entity graphs earlier. + * @see https://dataclient.io/rest/api/Entity#maxEntityDepth + */ + maxEntityDepth?: number | undefined; /** * A unique identifier for each Entity * @@ -634,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 * @@ -1174,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 { @@ -1247,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 b84e2869e564..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 @@ -443,6 +443,12 @@ interface IEntityClass { * @see https://dataclient.io/rest/api/Entity#indexes */ indexes?: readonly string[] | undefined; + /** Maximum entity nesting depth for denormalization (default: 128) + * + * Set a lower value to truncate deep bidirectional entity graphs earlier. + * @see https://dataclient.io/rest/api/Entity#maxEntityDepth + */ + maxEntityDepth?: number | undefined; /** * A unique identifier for each Entity * @@ -634,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 * @@ -1174,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 { @@ -1288,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/normalizr.d.ts b/website/src/components/Playground/editor-types/@data-client/normalizr.d.ts index d32337a9545e..46117835449a 100644 --- a/website/src/components/Playground/editor-types/@data-client/normalizr.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/normalizr.d.ts @@ -35,6 +35,7 @@ interface EntityInterface extends SchemaSimple { schema: Record; prototype: T; cacheWith?: object; + maxEntityDepth?: number; } interface Mergeable { key: string; 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 b5c172ec1633..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 @@ -441,6 +441,12 @@ interface IEntityClass { * @see https://dataclient.io/rest/api/Entity#indexes */ indexes?: readonly string[] | undefined; + /** Maximum entity nesting depth for denormalization (default: 128) + * + * Set a lower value to truncate deep bidirectional entity graphs earlier. + * @see https://dataclient.io/rest/api/Entity#maxEntityDepth + */ + maxEntityDepth?: number | undefined; /** * A unique identifier for each Entity * @@ -632,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 * @@ -1172,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 { @@ -1936,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 e76eca2b0ae4..c9db692f82eb 100644 --- a/website/src/components/Playground/editor-types/globals.d.ts +++ b/website/src/components/Playground/editor-types/globals.d.ts @@ -445,6 +445,12 @@ interface IEntityClass { * @see https://dataclient.io/rest/api/Entity#indexes */ indexes?: readonly string[] | undefined; + /** Maximum entity nesting depth for denormalization (default: 128) + * + * Set a lower value to truncate deep bidirectional entity graphs earlier. + * @see https://dataclient.io/rest/api/Entity#maxEntityDepth + */ + maxEntityDepth?: number | undefined; /** * A unique identifier for each Entity * @@ -636,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 * @@ -1176,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 { @@ -2123,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 };