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/docs/rest/api/Lazy.md b/docs/rest/api/Lazy.md
new file mode 100644
index 000000000000..58fb3a140977
--- /dev/null
+++ b/docs/rest/api/Lazy.md
@@ -0,0 +1,134 @@
+---
+title: Lazy Schema - Deferred Relationship Denormalization
+sidebar_label: Lazy
+---
+
+# Lazy
+
+`Lazy` wraps a schema to skip eager denormalization of relationship fields. During parent entity denormalization, the field retains its raw normalized value (primary keys/IDs). The relationship can then be resolved on demand via [useQuery](/docs/api/useQuery) using the `.query` accessor.
+
+This is useful for:
+- **Large bidirectional graphs** that would overflow the call stack during recursive denormalization
+- **Performance optimization** by deferring resolution of relationships that aren't always needed
+- **Memoization isolation** — changes to lazy entities don't invalidate the parent's denormalized form
+
+## Constructor
+
+```typescript
+new schema.Lazy(innerSchema)
+```
+
+- `innerSchema`: Any [Schema](/rest/api/schema) — an [Entity](./Entity.md), an array shorthand like `[MyEntity]`, a [Collection](./Collection.md), etc.
+
+## Usage
+
+### Array relationship (most common)
+
+```typescript
+import { Entity, schema } from '@data-client/rest';
+
+class Building extends Entity {
+ id = '';
+ name = '';
+}
+
+class Department extends Entity {
+ id = '';
+ name = '';
+ buildings: string[] = [];
+
+ static schema = {
+ buildings: new schema.Lazy([Building]),
+ };
+}
+```
+
+When a `Department` is denormalized, `dept.buildings` will contain raw primary keys (e.g., `['bldg-1', 'bldg-2']`) instead of resolved `Building` instances.
+
+To resolve the buildings, use [useQuery](/docs/api/useQuery) with the `.query` accessor:
+
+```tsx
+function DepartmentBuildings({ dept }: { dept: Department }) {
+ // dept.buildings contains raw IDs: ['bldg-1', 'bldg-2']
+ const buildings = useQuery(Department.schema.buildings.query, dept.buildings);
+ // buildings: Building[] | undefined
+
+ if (!buildings) return null;
+ return (
+
+ {buildings.map(b => - {b.name}
)}
+
+ );
+}
+```
+
+### Single entity relationship
+
+```typescript
+class Department extends Entity {
+ id = '';
+ name = '';
+ mainBuilding = '';
+
+ static schema = {
+ mainBuilding: new schema.Lazy(Building),
+ };
+}
+```
+
+```tsx
+// dept.mainBuilding is a raw PK string: 'bldg-1'
+const building = useQuery(
+ Department.schema.mainBuilding.query,
+ { id: dept.mainBuilding },
+);
+```
+
+When the inner schema is an [Entity](./Entity.md) (or any schema with `queryKey`), `LazyQuery` delegates to its `queryKey` — so you pass the same args you'd use to query that entity directly.
+
+### Collection relationship
+
+```typescript
+class Department extends Entity {
+ id = '';
+ static schema = {
+ buildings: new schema.Lazy(buildingsCollection),
+ };
+}
+```
+
+```tsx
+const buildings = useQuery(
+ Department.schema.buildings.query,
+ ...collectionArgs,
+);
+```
+
+## `.query`
+
+Returns a `LazyQuery` instance suitable for [useQuery](/docs/api/useQuery). The `LazyQuery`:
+
+- **`queryKey(args)`** — If the inner schema has a `queryKey` (Entity, Collection, etc.), delegates to it. Otherwise returns `args[0]` directly (for array/object schemas where you pass the raw normalized value).
+- **`denormalize(input, args, unvisit)`** — Delegates to the inner schema, resolving IDs into full entity instances.
+
+The `.query` getter always returns the same instance (cached).
+
+## How it works
+
+### Normalization
+
+`Lazy.normalize` delegates to the inner schema. Entities are stored in the normalized entity tables as usual — `Lazy` has no effect on normalization.
+
+### Denormalization (parent path)
+
+`Lazy.denormalize` is a **no-op** — it returns the input unchanged. When `EntityMixin.denormalize` iterates over schema fields and encounters a `Lazy` field, the `unvisit` dispatch calls `Lazy.denormalize`, which simply passes through the raw PKs. No nested entities are visited, no dependencies are registered in the cache.
+
+### Denormalization (useQuery path)
+
+When using `useQuery(lazyField.query, ...)`, `LazyQuery.denormalize` delegates to the inner schema via `unvisit`, resolving IDs into full entity instances through the normal denormalization pipeline. This runs in its own `MemoCache.query()` scope with independent dependency tracking and GC.
+
+## Performance characteristics
+
+- **Parent denormalization**: Fewer dependency hops (lazy entities excluded from deps). Faster cache hits. No invalidation when lazy entities change.
+- **useQuery access**: Own memo scope with own `paths` and `countRef`. Changes to lazy entities only re-render components that called `useQuery`, not the parent.
+- **No Proxy/getter overhead**: Raw IDs are plain values. Full resolution only happens through `useQuery`, using the normal denormalization path.
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/Lazy.ts b/packages/endpoint/src/schemas/Lazy.ts
new file mode 100644
index 000000000000..e9948cdd976d
--- /dev/null
+++ b/packages/endpoint/src/schemas/Lazy.ts
@@ -0,0 +1,121 @@
+import type { Schema, SchemaSimple } from '../interface.js';
+import type {
+ Denormalize,
+ DenormalizeNullable,
+ NormalizeNullable,
+} from '../normal.js';
+
+/**
+ * Skips eager denormalization of a relationship field.
+ * Raw normalized values (PKs/IDs) pass through unchanged.
+ * Use `.query` with `useQuery` to resolve lazily.
+ *
+ * @see https://dataclient.io/rest/api/Lazy
+ */
+export default class Lazy implements SchemaSimple {
+ declare schema: S;
+
+ /**
+ * @param {Schema} schema - The inner schema (e.g., [Building], Building, Collection)
+ */
+ constructor(schema: S) {
+ this.schema = schema;
+ }
+
+ normalize(
+ input: any,
+ parent: any,
+ key: any,
+ args: any[],
+ visit: (...args: any) => any,
+ _delegate: any,
+ ): any {
+ return visit(this.schema, input, parent, key, args);
+ }
+
+ denormalize(input: {}, _args: readonly any[], _unvisit: any): any {
+ return input;
+ }
+
+ queryKey(
+ _args: readonly any[],
+ _unvisit: (...args: any) => any,
+ _delegate: any,
+ ): undefined {
+ return undefined;
+ }
+
+ /** Queryable schema for use with useQuery() to resolve lazy relationships */
+ get query(): LazyQuery {
+ if (!this._query) {
+ this._query = new LazyQuery(this.schema);
+ }
+ return this._query;
+ }
+
+ private _query: LazyQuery | undefined;
+
+ declare _denormalizeNullable: (
+ input: {},
+ args: readonly any[],
+ unvisit: (schema: any, input: any) => any,
+ ) => any;
+
+ declare _normalizeNullable: () => NormalizeNullable;
+}
+
+/**
+ * Resolves lazy relationships via useQuery().
+ *
+ * queryKey delegates to inner schema's queryKey if available,
+ * otherwise passes through args[0] (the raw normalized value).
+ */
+export class LazyQuery implements SchemaSimple<
+ Denormalize,
+ readonly any[]
+> {
+ declare schema: S;
+
+ constructor(schema: S) {
+ this.schema = schema;
+ }
+
+ normalize(
+ input: any,
+ _parent: any,
+ _key: any,
+ _args: any[],
+ _visit: (...args: any) => any,
+ _delegate: any,
+ ): any {
+ return input;
+ }
+
+ denormalize(
+ input: {},
+ args: readonly any[],
+ unvisit: (schema: any, input: any) => any,
+ ): Denormalize {
+ return unvisit(this.schema, input);
+ }
+
+ queryKey(
+ args: readonly any[],
+ unvisit: (...args: any) => any,
+ delegate: { getEntity: any; getIndex: any },
+ ): any {
+ const schema = this.schema as any;
+ if (typeof schema.queryKey === 'function' && schema.key) {
+ return schema.queryKey(args, unvisit, delegate);
+ }
+ return args[0];
+ }
+
+ declare _denormalizeNullable: (
+ input: {},
+ args: readonly any[],
+ unvisit: (schema: any, input: any) => any,
+ ) => DenormalizeNullable;
+
+ declare _normalizeNullable: () => NormalizeNullable;
+}
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();
+ });
+ });
+});