Skip to content

Commit 28d7641

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 28d7641

7 files changed

Lines changed: 134 additions & 5 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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,73 @@ describe(`${Entity.name} denormalization`, () => {
11321132
consoleSpy.mockRestore();
11331133
});
11341134

1135+
test('maxEntityDepth on Entity lowers the limit', () => {
1136+
class LimitedDept extends IDEntity {
1137+
readonly name: string = '';
1138+
readonly buildings: LimitedBldg[] = [];
1139+
static maxEntityDepth = 10;
1140+
}
1141+
class LimitedBldg extends IDEntity {
1142+
readonly name: string = '';
1143+
readonly departments: LimitedDept[] = [];
1144+
1145+
static schema = {
1146+
departments: [LimitedDept],
1147+
};
1148+
1149+
static maxEntityDepth = 10;
1150+
}
1151+
LimitedDept.schema = {
1152+
buildings: new schema.Array(LimitedBldg),
1153+
};
1154+
1155+
const deptEntities: Record<string, any> = {};
1156+
const bldgEntities: Record<string, any> = {};
1157+
for (let i = 0; i < 50; i++) {
1158+
deptEntities[`dept-${i}`] = {
1159+
id: `dept-${i}`,
1160+
name: `Department ${i}`,
1161+
buildings: [`bldg-${i}`],
1162+
};
1163+
bldgEntities[`bldg-${i}`] = {
1164+
id: `bldg-${i}`,
1165+
name: `Building ${i}`,
1166+
departments: i < 49 ? [`dept-${i + 1}`] : [],
1167+
};
1168+
}
1169+
1170+
const consoleSpy = jest
1171+
.spyOn(console, 'error')
1172+
.mockImplementation(() => {});
1173+
1174+
const result = plainDenormalize(LimitedDept, 'dept-0', {
1175+
LimitedDept: deptEntities,
1176+
LimitedBldg: bldgEntities,
1177+
});
1178+
1179+
expect(result).not.toEqual(expect.any(Symbol));
1180+
if (typeof result === 'symbol') return;
1181+
expect(result).toBeDefined();
1182+
if (!result) return;
1183+
1184+
// depth 10 means 5 full hops (dept→bldg = 2 entity levels per hop)
1185+
// walk 4 hops safely
1186+
let node: any = result;
1187+
for (let i = 0; i < 4; i++) {
1188+
expect(node.buildings).toBeDefined();
1189+
expect(node.buildings.length).toBe(1);
1190+
node = node.buildings[0].departments[0];
1191+
}
1192+
expect(node).toBeInstanceOf(LimitedDept);
1193+
1194+
expect(consoleSpy).toHaveBeenCalledTimes(1);
1195+
expect(consoleSpy).toHaveBeenCalledWith(
1196+
expect.stringContaining('Entity depth limit of 10'),
1197+
);
1198+
1199+
consoleSpy.mockRestore();
1200+
});
1201+
11351202
test('depth limit with MemoCache does not cache truncated results', () => {
11361203
const entities = buildChain(1500);
11371204
const consoleSpy = jest

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 {

0 commit comments

Comments
 (0)