0.33.0: reads inside resolveOutput recurse without bound — server OOMs on any access-scoped read
Regression in 0.33.0 (works on 0.32.0). Introduced by #838 / ADR-0022 (fix for #830).
Upgrading @opensaas/stack-core 0.32.0 → 0.33.0 makes every authenticated page in our app hang and drives the Next.js server to the V8 heap limit. In CI our Playwright server died ~3 minutes into the run:
[2815:0x1ee51000] 185775 ms: Mark-Compact 4073.9 (4105.2) -> 4062.2 (4106.0) MB, pooled: 0 MB,
769.07 / 0.01 ms (average mu = 0.304, current mu = 0.237) task;
scavenge might not succeed
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
1: 0x74eae8 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [next-server (v16.2.10)]
59 of 71 e2e tests failed, almost all as ERR_CONNECTION_REFUSED after the process was gone.
Cause
Two changes in #838 interact:
1. access/access-filter.ts — buildIncludeWithAccessControl
0.32 bailed out entirely inside a resolveOutput context:
if (args.context._resolveOutputCounter.depth > 0) {
return undefined // no auto-include: the read fetched scalar columns only
}
0.33 replaced that with a flag that still emits a one-level include for every readable relation (access-filter.ts:126):
const insideResolveOutput = args.context._resolveOutputCounter.depth > 0
// …
if (!insideResolveOutput && !visitedLists.includes(relatedListName)) {
nested = await buildIncludeWithAccessControl(/* … */)
}
include[fieldName] = { where, nested } // ← relation is included either way
2. access/field-visibility.ts — filterReadableFields
The depth < MAX_DEPTH guard on the relationship recursion was removed, so every returned relation row now runs its own resolveOutput hooks at any depth.
Why the guards don't hold
_resolveOutputCounter.depth is only ever read as a boolean — it is incremented/decremented in filterReadableFields but never compared against a cap. So it flags "am I inside a hook?" and never bounds how deep the nesting goes.
The two code comments justifying the change each rely on an assumption that the other change invalidates:
access-filter.ts: "no recursive call is made, so a self-referential relation terminates after one level regardless of cycles"
True of buildIncludeWithAccessControl's own recursion — but the loop doesn't run through it. It runs through the hook↔read cycle: the fetched relation rows re-enter filterReadableFields, which executes their resolveOutput hooks, each of which issues a brand-new top-level read that builds a fresh one-level include. Every turn of that cycle is a new query and a new row set.
field-visibility.ts: "by the time a result reaches this function it is already a finite, acyclic tree whose depth was decided at the pre-query phase"
True for the tree produced by one query. But the resolveOutput hooks executed during the walk issue new queries producing new trees, so the total depth is not bounded by the pre-query phase.
On a cyclic readable-relationship graph the cycle never closes, and each turn allocates another row set.
Minimal reproduction
Sketch, reduced from the real failure — I confirmed the mechanism against our app (see below), not against this exact schema in the stack test suite.
User: list({
fields: {
accounts: relationship({ ref: 'Account.user', many: true }),
name: virtual({
type: 'string',
hooks: {
resolveOutput: async ({ item, context }) => {
const [a] = await context.db.account.findMany({ where: { userId: item.id }, take: 1 })
return `${a?.firstName}`
},
},
}),
},
}),
Account: list({
fields: {
firstName: text(),
user: relationship({ ref: 'User.accounts' }),
students: relationship({ ref: 'Student.account', many: true }),
},
}),
Student: list({
fields: {
account: relationship({ ref: 'Account.students' }),
label: virtual({
type: 'string',
hooks: {
resolveOutput: async ({ item, context }) => {
const a = await context.db.account.findUnique({ where: { id: item.accountId } })
return `${a?.firstName}`
},
},
}),
},
}),
Then, on an access-scoped (non-sudo) context:
await context.db.user.findMany({})
Trace:
user.findMany auto-includes to depth 5 → returns users with accounts → students.
filterReadableFields walks the rows and runs User.name → account.findMany. Now insideResolveOutput, so that read returns the Account plus user and students.
- Those Student rows are walked (no depth cap now) →
Student.label → account.findUnique → again returns Account + user + students.
- → step 3, forever.
Sudo contexts are unaffected — context/index.ts uses the caller's include as-is and builds no auto-include.
Evidence
Measured on a real app (~40 lists, cyclic relationship graph), Next 16.2.10, in-process PGlite:
| stack-core |
GET /dashboard (one request) |
| 0.33.0, stock |
never returns; RSS 835 MB → 1.5 GB → 3.2 GB → 4.4 GB, still 290% CPU after the client disconnects |
| 0.33.0 + one-line patch below |
200 in 5 s; steady state 1.8 s, memory flat |
| 0.32.0 |
unaffected — same suite, 70/70 e2e green |
The patch, applied to node_modules purely to isolate the cause (restores the 0.32 behaviour):
const insideResolveOutput = args.context._resolveOutputCounter.depth > 0
if (insideResolveOutput) return { kind: 'nothing-to-scope' } // ← added
Workaround for consumers
Pass an explicit empty include on every read issued inside a resolveOutput hook:
await context.db.account.findMany({ where: { userId: item.id }, take: 1, include: {} })
mergeIncludeWithAccessControl only walks caller-named keys, so {} fetches scalars and nothing else, and the chain terminates. Verified against stock 0.33.0.
This is per-call-site, though — one unguarded read inside a resolveOutput anywhere in the app brings the runaway back.
Suggested fix
The #830 goal (row-scope a relation read from inside a hook rather than skipping scoping) seems compatible with terminating, if the recursion is bounded somewhere it can actually see the hook↔read cycle. Options, roughly in order of how surgical they are:
- Use
_resolveOutputCounter.depth as a real cap rather than a boolean — refuse to auto-include (or to run nested resolveOutput hooks) past depth 1.
- Don't run
resolveOutput hooks on rows fetched inside a resolveOutput context — scope and return them, but don't recurse into their virtuals.
- Keep
filterReadableFields's recursion bounded by a cap tied to the cumulative hook depth, not per-query depth.
Happy to test a patch against our suite — it reproduces reliably and fails within seconds.
Versions
@opensaas/stack-core 0.33.0 (also stack-auth / stack-ui / stack-storage 0.33.0)
next 16.2.10, react 19.2.4, @prisma/client 7.9.1, Node 24
0.33.0: reads inside
resolveOutputrecurse without bound — server OOMs on any access-scoped readRegression in 0.33.0 (works on 0.32.0). Introduced by #838 / ADR-0022 (fix for #830).
Upgrading
@opensaas/stack-core0.32.0 → 0.33.0 makes every authenticated page in our app hang and drives the Next.js server to the V8 heap limit. In CI our Playwright server died ~3 minutes into the run:59 of 71 e2e tests failed, almost all as
ERR_CONNECTION_REFUSEDafter the process was gone.Cause
Two changes in #838 interact:
1.
access/access-filter.ts—buildIncludeWithAccessControl0.32 bailed out entirely inside a
resolveOutputcontext:0.33 replaced that with a flag that still emits a one-level include for every readable relation (
access-filter.ts:126):2.
access/field-visibility.ts—filterReadableFieldsThe
depth < MAX_DEPTHguard on the relationship recursion was removed, so every returned relation row now runs its ownresolveOutputhooks at any depth.Why the guards don't hold
_resolveOutputCounter.depthis only ever read as a boolean — it is incremented/decremented infilterReadableFieldsbut never compared against a cap. So it flags "am I inside a hook?" and never bounds how deep the nesting goes.The two code comments justifying the change each rely on an assumption that the other change invalidates:
True of
buildIncludeWithAccessControl's own recursion — but the loop doesn't run through it. It runs through the hook↔read cycle: the fetched relation rows re-enterfilterReadableFields, which executes theirresolveOutputhooks, each of which issues a brand-new top-level read that builds a fresh one-level include. Every turn of that cycle is a new query and a new row set.True for the tree produced by one query. But the
resolveOutputhooks executed during the walk issue new queries producing new trees, so the total depth is not bounded by the pre-query phase.On a cyclic readable-relationship graph the cycle never closes, and each turn allocates another row set.
Minimal reproduction
Then, on an access-scoped (non-sudo) context:
Trace:
user.findManyauto-includes to depth 5 → returns users withaccounts→students.filterReadableFieldswalks the rows and runsUser.name→account.findMany. NowinsideResolveOutput, so that read returns the Account plususerandstudents.Student.label→account.findUnique→ again returns Account +user+students.Sudo contexts are unaffected —
context/index.tsuses the caller'sincludeas-is and builds no auto-include.Evidence
Measured on a real app (~40 lists, cyclic relationship graph), Next 16.2.10, in-process PGlite:
GET /dashboard(one request)The patch, applied to
node_modulespurely to isolate the cause (restores the 0.32 behaviour):Workaround for consumers
Pass an explicit empty include on every read issued inside a
resolveOutputhook:mergeIncludeWithAccessControlonly walks caller-named keys, so{}fetches scalars and nothing else, and the chain terminates. Verified against stock 0.33.0.This is per-call-site, though — one unguarded read inside a
resolveOutputanywhere in the app brings the runaway back.Suggested fix
The #830 goal (row-scope a relation read from inside a hook rather than skipping scoping) seems compatible with terminating, if the recursion is bounded somewhere it can actually see the hook↔read cycle. Options, roughly in order of how surgical they are:
_resolveOutputCounter.depthas a real cap rather than a boolean — refuse to auto-include (or to run nestedresolveOutputhooks) past depth 1.resolveOutputhooks on rows fetched inside aresolveOutputcontext — scope and return them, but don't recurse into their virtuals.filterReadableFields's recursion bounded by a cap tied to the cumulative hook depth, not per-query depth.Happy to test a patch against our suite — it reproduces reliably and fails within seconds.
Versions
@opensaas/stack-core0.33.0 (alsostack-auth/stack-ui/stack-storage0.33.0)next16.2.10,react19.2.4,@prisma/client7.9.1, Node 24