Skip to content

Commit 37c3e9e

Browse files
committed
feat(normalizr): Add configurable maxEntityDepth on Entity
Allow per-Entity configuration of the denormalization depth limit via `static maxEntityDepth`. This lets users lower the limit (default 128) on entities that participate in deep bidirectional relationships, without requiring any provider or controller configuration. The depth check in `getUnvisit()` now reads `schema.maxEntityDepth` with a fallback to the existing 128 default. Made-with: Cursor
1 parent 83c77a6 commit 37c3e9e

13 files changed

Lines changed: 184 additions & 26 deletions

File tree

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
---
22
'@data-client/normalizr': patch
3+
'@data-client/endpoint': patch
34
'@data-client/core': patch
45
'@data-client/react': patch
56
'@data-client/vue': patch
67
---
78

89
Fix stack overflow during denormalization of large bidirectional entity graphs.
910

10-
Add entity depth limit (128) to prevent `RangeError: Maximum call stack size exceeded`
11+
Add entity depth limit (64) to prevent `RangeError: Maximum call stack size exceeded`
1112
when denormalizing cross-type chains with thousands of unique entities
1213
(e.g., Department → Building → Department → ...). Entities beyond the depth limit
1314
are returned with unresolved ids instead of fully denormalized nested objects.
15+
16+
The limit can be configured per-Entity with [`static maxEntityDepth`](/rest/api/Entity#maxEntityDepth):
17+
18+
```ts
19+
class Department extends Entity {
20+
static maxEntityDepth = 16;
21+
}
22+
```

docs/rest/api/Entity.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,41 @@ Nested below:
418418
const price = useQuery(LatestPrice, { symbol: 'BTC' });
419419
```
420420

421+
### static maxEntityDepth?: number {#maxEntityDepth}
422+
423+
Limits entity nesting depth during denormalization to prevent stack overflow
424+
in large bidirectional entity graphs. **Default: 128**
425+
426+
When bidirectional relationships create chains with many unique entities
427+
(e.g., `Department → Building → Department → ...`), denormalization can recurse
428+
thousands of levels deep. `maxEntityDepth` truncates resolution at the specified
429+
depth — entities beyond the limit are returned with nested foreign keys left as
430+
unresolved ids rather than fully denormalized objects.
431+
432+
```typescript
433+
class Department extends Entity {
434+
id = '';
435+
name = '';
436+
buildings: Building[] = [];
437+
438+
pk() { return this.id; }
439+
static key = 'Department';
440+
// highlight-next-line
441+
static maxEntityDepth = 16;
442+
443+
static schema = {
444+
buildings: [Building],
445+
};
446+
}
447+
```
448+
449+
:::tip
450+
451+
Set this on entities that participate in deep or wide bidirectional relationships.
452+
Normal entity graphs (depth < 10) never approach the default limit.
453+
454+
:::
455+
421456
## Lifecycle
422457

423458
import Lifecycle from '../diagrams/\_entity_lifecycle.mdx';

packages/endpoint/src/schemas/EntityMixin.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ export default function EntityMixin<TBase extends Constructor>(
8080
/** Defines indexes to enable lookup by */
8181
declare static indexes?: readonly string[];
8282

83+
/** Maximum entity nesting depth for denormalization (default: 128)
84+
*
85+
* Set a lower value to truncate deep bidirectional entity graphs earlier.
86+
* @see https://dataclient.io/rest/api/Entity#maxEntityDepth
87+
*/
88+
declare static maxEntityDepth?: number;
89+
8390
/**
8491
* A unique identifier for each Entity
8592
*

packages/endpoint/src/schemas/EntityTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export interface IEntityClass<TBase extends Constructor = any> {
3030
* @see https://dataclient.io/rest/api/Entity#indexes
3131
*/
3232
indexes?: readonly string[] | undefined;
33+
/** Maximum entity nesting depth for denormalization (default: 128)
34+
*
35+
* Set a lower value to truncate deep bidirectional entity graphs earlier.
36+
* @see https://dataclient.io/rest/api/Entity#maxEntityDepth
37+
*/
38+
maxEntityDepth?: number | undefined;
3339
/**
3440
* A unique identifier for each Entity
3541
*

packages/endpoint/src/schemas/__tests__/Entity.test.ts

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,31 +1011,32 @@ describe(`${Entity.name} denormalization`, () => {
10111011
expect(result).toBeDefined();
10121012
if (!result) return;
10131013

1014-
// walk to a depth-limited entity: each hop is 1 entity depth
1015-
// dept-0(1) -> bldg-0(2) -> dept-1(3) -> bldg-1(4) -> ...
1016-
// At depth 128 we should find truncated entities with unresolved FK ids
1014+
// walk to a depth-limited entity: each dept→bldg→dept step is 2 entity depths
1015+
// dept-k is entered at depth 2k (default MAX_ENTITY_DEPTH is 64)
10171016
let node: any = result;
1018-
for (let i = 0; i < 60; i++) {
1017+
for (let i = 0; i < 30; i++) {
10191018
expect(node.buildings).toBeDefined();
10201019
expect(node.buildings.length).toBe(1);
10211020
node = node.buildings[0].departments[0];
10221021
}
10231022
// node should still be a Department instance (well within limit)
10241023
expect(node).toBeInstanceOf(Department);
10251024

1026-
expect(consoleSpy).toHaveBeenCalledTimes(1);
1027-
expect(consoleSpy).toHaveBeenCalledWith(
1028-
expect.stringContaining('Entity depth limit'),
1029-
);
1025+
expect(consoleSpy).toHaveBeenCalled();
1026+
expect(
1027+
consoleSpy.mock.calls.some(args =>
1028+
String(args[0]).includes('Entity depth limit'),
1029+
),
1030+
).toBe(true);
10301031

10311032
consoleSpy.mockRestore();
10321033
});
10331034

10341035
test('depth-limited entity that is missing from store', () => {
10351036
const entities = buildChain(200);
1036-
// dept-64 is the first entity hit by the depth limit (checked at depth=128).
1037+
// dept-32 is the first Department hit by the default depth limit (depth 64).
10371038
// Removing it exercises the "entity not found" branch in depthLimitEntity.
1038-
delete (entities.Department as any)['dept-64'];
1039+
delete (entities.Department as any)['dept-32'];
10391040

10401041
const consoleSpy = jest
10411042
.spyOn(console, 'error')
@@ -1073,8 +1074,8 @@ describe(`${Entity.name} denormalization`, () => {
10731074
for (let i = 0; i < 200; i++) {
10741075
deptEntities[`dept-${i}`] = {
10751076
id: `dept-${i}`,
1076-
// dept-64 is the first depth-limited entity; mark it invalid
1077-
name: i === 64 ? 'INVALID' : `Department ${i}`,
1077+
// dept-32 is the first depth-limited Department; mark it invalid
1078+
name: i === 32 ? 'INVALID' : `Department ${i}`,
10781079
buildings: [`bldg-${i}`],
10791080
};
10801081
bldgEntities[`bldg-${i}`] = {
@@ -1099,10 +1100,10 @@ describe(`${Entity.name} denormalization`, () => {
10991100

11001101
test('depth-limited entity with inline object input', () => {
11011102
const entities = buildChain(200);
1102-
// bldg-63's departments are processed when depth=128, hitting depthLimitEntity.
1103+
// bldg-31's departments are processed when depth=64, hitting depthLimitEntity.
11031104
// Use inline object instead of string pk to exercise the object-input branch.
1104-
(entities.Building as any)['bldg-63'].departments = [
1105-
{ id: 'dept-64', name: 'Inline Department 64', buildings: [] },
1105+
(entities.Building as any)['bldg-31'].departments = [
1106+
{ id: 'dept-32', name: 'Inline Department 32', buildings: [] },
11061107
];
11071108

11081109
const consoleSpy = jest
@@ -1117,17 +1118,86 @@ describe(`${Entity.name} denormalization`, () => {
11171118

11181119
// walk to the depth-limited inline entity
11191120
let node: any = result;
1120-
for (let i = 0; i < 63; i++) {
1121+
for (let i = 0; i < 31; i++) {
11211122
node = node.buildings[0].departments[0];
11221123
}
1123-
// node is dept-63, its building is bldg-63
1124-
expect(node.id).toBe('dept-63');
1124+
// node is dept-31, its building is bldg-31
1125+
expect(node.id).toBe('dept-31');
11251126
const depthLimitedBldg = node.buildings[0];
1126-
expect(depthLimitedBldg.id).toBe('bldg-63');
1127-
// dept-64 was provided as an inline object, so depthLimitEntity used it directly
1127+
expect(depthLimitedBldg.id).toBe('bldg-31');
1128+
// dept-32 was provided as an inline object, so depthLimitEntity used it directly
11281129
const inlineDept = depthLimitedBldg.departments[0];
11291130
expect(inlineDept).toBeInstanceOf(Department);
1130-
expect(inlineDept.id).toBe('dept-64');
1131+
expect(inlineDept.id).toBe('dept-32');
1132+
1133+
consoleSpy.mockRestore();
1134+
});
1135+
1136+
test('maxEntityDepth on Entity lowers the limit', () => {
1137+
class LimitedDept extends IDEntity {
1138+
readonly name: string = '';
1139+
readonly buildings: LimitedBldg[] = [];
1140+
static maxEntityDepth = 10;
1141+
}
1142+
class LimitedBldg extends IDEntity {
1143+
readonly name: string = '';
1144+
readonly departments: LimitedDept[] = [];
1145+
1146+
static schema = {
1147+
departments: [LimitedDept],
1148+
};
1149+
1150+
static maxEntityDepth = 10;
1151+
}
1152+
LimitedDept.schema = {
1153+
buildings: new schema.Array(LimitedBldg),
1154+
};
1155+
1156+
const deptEntities: Record<string, any> = {};
1157+
const bldgEntities: Record<string, any> = {};
1158+
for (let i = 0; i < 50; i++) {
1159+
deptEntities[`dept-${i}`] = {
1160+
id: `dept-${i}`,
1161+
name: `Department ${i}`,
1162+
buildings: [`bldg-${i}`],
1163+
};
1164+
bldgEntities[`bldg-${i}`] = {
1165+
id: `bldg-${i}`,
1166+
name: `Building ${i}`,
1167+
departments: i < 49 ? [`dept-${i + 1}`] : [],
1168+
};
1169+
}
1170+
1171+
const consoleSpy = jest
1172+
.spyOn(console, 'error')
1173+
.mockImplementation(() => {});
1174+
1175+
const result = plainDenormalize(LimitedDept, 'dept-0', {
1176+
LimitedDept: deptEntities,
1177+
LimitedBldg: bldgEntities,
1178+
});
1179+
1180+
expect(result).not.toEqual(expect.any(Symbol));
1181+
if (typeof result === 'symbol') return;
1182+
expect(result).toBeDefined();
1183+
if (!result) return;
1184+
1185+
// depth 10 means 5 full hops (dept→bldg = 2 entity levels per hop)
1186+
// walk 4 hops safely
1187+
let node: any = result;
1188+
for (let i = 0; i < 4; i++) {
1189+
expect(node.buildings).toBeDefined();
1190+
expect(node.buildings.length).toBe(1);
1191+
node = node.buildings[0].departments[0];
1192+
}
1193+
expect(node).toBeInstanceOf(LimitedDept);
1194+
1195+
expect(consoleSpy).toHaveBeenCalled();
1196+
expect(
1197+
consoleSpy.mock.calls.some(args =>
1198+
String(args[0]).includes('Entity depth limit of 10'),
1199+
),
1200+
).toBe(true);
11311201

11321202
consoleSpy.mockRestore();
11331203
});

packages/normalizr/src/denormalize/unvisit.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ function noCacheGetEntity(
9797
return localCacheKey.get('');
9898
}
9999

100-
const MAX_ENTITY_DEPTH = 128;
100+
const MAX_ENTITY_DEPTH = 64;
101101

102102
const getUnvisit = (
103103
getEntity: DenormGetEntity,
@@ -129,14 +129,18 @@ const getUnvisit = (
129129
}
130130
} else {
131131
if (isEntity(schema)) {
132-
if (depth >= MAX_ENTITY_DEPTH) {
132+
if (depth >= (schema.maxEntityDepth ?? MAX_ENTITY_DEPTH)) {
133133
/* istanbul ignore if */
134134
if (process.env.NODE_ENV !== 'production' && !depthLimitHit) {
135135
depthLimitHit = true;
136+
const limit = schema.maxEntityDepth ?? MAX_ENTITY_DEPTH;
136137
console.error(
137-
`Entity depth limit of ${MAX_ENTITY_DEPTH} reached for "${schema.key}" entity. ` +
138+
`Entity depth limit of ${limit} reached for "${schema.key}" entity. ` +
138139
`This usually means your schema has very deep or wide bidirectional relationships. ` +
139-
`Nested entities beyond this depth are returned with unresolved ids.`,
140+
`Nested entities beyond this depth are returned with unresolved ids.` +
141+
(schema.maxEntityDepth === undefined ?
142+
` Set static maxEntityDepth on your Entity to configure this limit.`
143+
: ''),
140144
);
141145
}
142146
return depthLimitEntity(getEntity, schema, input);

packages/normalizr/src/interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface EntityInterface<T = any> extends SchemaSimple {
6363
schema: Record<string, Schema>;
6464
prototype: T;
6565
cacheWith?: object;
66+
maxEntityDepth?: number;
6667
}
6768

6869
export interface Mergeable {

website/src/components/Playground/editor-types/@data-client/core.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ interface EntityInterface<T = any> extends SchemaSimple {
3535
schema: Record<string, Schema>;
3636
prototype: T;
3737
cacheWith?: object;
38+
maxEntityDepth?: number;
3839
}
3940
interface Mergeable {
4041
key: string;

website/src/components/Playground/editor-types/@data-client/endpoint.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,12 @@ interface IEntityClass<TBase extends Constructor = any> {
443443
* @see https://dataclient.io/rest/api/Entity#indexes
444444
*/
445445
indexes?: readonly string[] | undefined;
446+
/** Maximum entity nesting depth for denormalization (default: 128)
447+
*
448+
* Set a lower value to truncate deep bidirectional entity graphs earlier.
449+
* @see https://dataclient.io/rest/api/Entity#maxEntityDepth
450+
*/
451+
maxEntityDepth?: number | undefined;
446452
/**
447453
* A unique identifier for each Entity
448454
*

website/src/components/Playground/editor-types/@data-client/graphql.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,12 @@ interface IEntityClass<TBase extends Constructor = any> {
443443
* @see https://dataclient.io/rest/api/Entity#indexes
444444
*/
445445
indexes?: readonly string[] | undefined;
446+
/** Maximum entity nesting depth for denormalization (default: 128)
447+
*
448+
* Set a lower value to truncate deep bidirectional entity graphs earlier.
449+
* @see https://dataclient.io/rest/api/Entity#maxEntityDepth
450+
*/
451+
maxEntityDepth?: number | undefined;
446452
/**
447453
* A unique identifier for each Entity
448454
*

0 commit comments

Comments
 (0)