Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/silent-hooks-compute-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@opensaas/stack-core': minor
---

A computed field — any field carrying a `resolveOutput` hook, virtual or not — is now computed if and only if a read is actually going to return it. A fragment `query` that selects three fields no longer runs every `resolveOutput` on the list and discards the rest: an unselected field's field-level read access is never evaluated and its hook never runs. Its declared relations (`needs`, ADR-0025) are fetched under exactly the same condition, folded recursively at every nesting level — a nested fragment selecting a subset computes only that subset, while a nested `include` still computes every computed field at that level, matching bare and `include`-based reads, which are unaffected: they still compute every computed field on the list, exactly as before. See ADR-0027.

**This is a silent break — detect it before you upgrade, the same way ADR-0024's and ADR-0026's were.** Two independent behaviors changed with no thrown error:

1. **A hook's `item` never carries another computed field's resolved output, on any read path.** Previously a virtual field received the already-assembled, already-resolved object, so a virtual field could read an _earlier-declared_ virtual (or any field carrying its own `resolveOutput`, e.g. a `password()`'s wrapper or a formatted display field) and see its resolved value — working only by declaration order, with reordering two fields silently changing the result. Now every computed field's hook sees only the row's stored columns and its own declared dependencies; reaching for a sibling that is itself computed finds nothing there (or its raw stored form, never the wrapped/resolved value), the same as reaching for a field that was never declared. **Grep your config for a `resolveOutput` whose `item` reads a field that is itself computed** — virtual fields reading other virtual fields, or a hook reading a stored field that carries its own `resolveOutput` (a password wrapper, a formatted date) — and recompute from the shared stored columns instead of relying on another field's hook having already run.
2. **A field's hook no longer runs just because it's on the list — only because a read selects it.** If you relied on a `resolveOutput` hook running for a side effect (logging, cache warming) on every read regardless of a fragment's own field selection, that side effect now only fires when the fragment actually names the field. **Grep for a fragment `query` that intentionally omits a field whose hook you were relying on for a side effect**, and select that field explicitly (or move the side effect to a hook that isn't projection-gated, e.g. `afterOperation`).

A hookless virtual field (one with `access.read` but no `resolveOutput`) no longer has its read access evaluated at all on any read — such a field can never produce output, so under this rule it does no work at all.

```typescript
// Before: `displayName` (declared after `fullNameCached`) could read the
// latter's resolved value purely because of declaration order.
User: list({
fields: {
firstName: text(),
lastName: text(),
fullNameCached: virtual({
type: 'string',
hooks: { resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}` },
}),
displayName: virtual({
type: 'string',
// item.fullNameCached is now always undefined here — recompute from
// the shared stored columns instead.
hooks: { resolveOutput: ({ item }) => `${item.fullNameCached} (${item.firstName[0]}.)` },
}),
},
})

// After: compute from the stored columns both fields actually share.
displayName: virtual({
type: 'string',
hooks: {
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName} (${item.firstName[0]}.)`,
},
}),
```
12 changes: 10 additions & 2 deletions packages/core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,14 @@ The read pipeline is **caller-directed**: `buildAccessScopedInclude` walks only

A computed field's declared dependency (`needs`, ADR-0025, below) folds in at **every** relation it's reached through, including one added purely to satisfy another field's own `needs` — the fold recurses through `foldDeclaredDependencies` rather than riding a caller-named relation's auto-expanded subtree, since nothing auto-expands anymore. See `docs/adr/0026-naming-a-relation-fetches-its-columns-not-its-subtree.md`.

### A Computed Field Runs Only When It Is Going To Be Returned (ADR-0027)

A computed field — any field carrying a `resolveOutput` hook, virtual or not — is computed **if and only if the read is actually going to return it**, and its declared relations (`needs`) are fetched under exactly the same condition. A fragment `query` selecting three fields runs only those three fields' hooks (and folds only their `needs`); a field it doesn't select does no work at all — neither its field-level `read` access nor its hook runs. This is **projection-aware, never access-aware**: a fragment's own field selection is the only thing that restricts a level this way. A bare read or an `include`-based read is unaffected — every computed field on the list still computes, exactly as before, since neither ever had a narrower field selection to restrict by. The rule applies at every nesting level: a nested fragment selecting a subset computes only that subset there; a nested `include` still computes every computed field at that level.

**A computed field's hook never sees another computed field's resolved output**, on any read path — only the row's stored columns and its own declared dependencies. A sibling field that was skipped (unselected by a fragment) or denied by field-level access is absent from what the hook sees, never present holding its raw pre-hook value — reaching for it finds nothing there, the same as reaching for a relation never declared via `needs`. Before this, a virtual field received the already-assembled, already-resolved object, so a virtual field could accidentally read an earlier-declared virtual's resolved value purely by declaration order; reordering two such fields silently changed the result. That accidental coupling is gone: recompute from the stored columns both fields share instead.

A hookless virtual field (one with `access.read` but no `resolveOutput`) has its read access evaluated on no read at all — such a field can never produce output, so there's nothing to preserve access side effects for. See `docs/adr/0027-a-computed-field-runs-only-when-it-is-going-to-be-returned.md` and the "Computed field" glossary entry in `CONTEXT.md`.

### Context Type Safety

Context uses generic typing to preserve Prisma types:
Expand Down Expand Up @@ -412,13 +420,13 @@ User: list({

// Usage
const user = await context.db.user.findUnique({ where: { id } })
console.log(user.fullName) // "John Doe" — computed via resolveOutput on every read
console.log(user.fullName) // "John Doe" — computed via resolveOutput whenever the read returns it
```

**Key characteristics:**

- Not stored in database (no Prisma column created)
- Computed via `resolveOutput` on every read (`select` is not honourednarrow with `include`/fragment `query`)
- Computed via `resolveOutput` on every bare/`include`-based read; on a fragment `query` read, only when the fragment selects it (ADR-0027) — `select` is still not honoured, narrow with `include`/fragment `query`
- Must provide `type` (TypeScript type string) and `resolveOutput` hook
- Can optionally provide `resolveInput` for write side effects
- Useful for derived values, computed properties, and external API sync
Expand Down
37 changes: 34 additions & 3 deletions packages/core/src/access/declared-dependencies.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FieldConfig, OpenSaasConfig } from '../config/types.js'
import { getRelatedListConfig } from './engine.js'
import type { FieldSelectionScope } from '../query/index.js'

/**
* Declared Dependencies — folding a computed field's `needs` into a read's
Expand Down Expand Up @@ -102,11 +103,22 @@ function getExplicitInclude(value: unknown): Record<string, unknown> | undefined
* list that have a `resolveOutput` hook. A `needs` entry on a field without
* one is inert — there is no hook to feed it to — so it contributes nothing
* to fetch.
*
* `selectedFields`, when given, restricts the union to fields the read is
* actually going to return (ADR-0027) — a field a fragment did not select is
* never computed, so its declared relation is never fetched for it either.
* `undefined` means unrestricted: every field with a hook contributes,
* matching a bare or `include`-based read, which always returns every
* computed field on the list.
*/
export function getDeclaredRelationNames(fieldConfigs: Record<string, FieldConfig>): string[] {
export function getDeclaredRelationNames(
fieldConfigs: Record<string, FieldConfig>,
selectedFields?: ReadonlySet<string>,
): string[] {
const names = new Set<string>()
for (const fieldConfig of Object.values(fieldConfigs)) {
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
if (!fieldConfig?.hooks?.resolveOutput) continue
if (selectedFields && !selectedFields.has(fieldName)) continue
for (const name of fieldConfig.needs ?? []) {
names.add(name)
}
Expand All @@ -127,15 +139,25 @@ export function getDeclaredRelationNames(fieldConfigs: Record<string, FieldConfi
*
* `listKey` seeds the cycle guard at the root of a read; recursive calls
* extend it with each related list reached along THIS fold's own path.
*
* `selection`, when given, is the fragment scope this level was reached
* under (ADR-0027) — only fields it names contribute their `needs` (see
* `getDeclaredRelationNames`). It is `undefined` for a bare/`include`-based
* read (unrestricted: every field's `needs` folds in, unchanged from before
* ADR-0027) and for any branch reached only to satisfy a declaration — a
* relation added purely by this fold has no fragment scope of its own, so
* its own list folds unrestricted, exactly as it did before selectivity
* existed.
*/
export function foldDeclaredDependencies(
rawInclude: Record<string, unknown> | undefined,
fieldConfigs: Record<string, FieldConfig>,
config: OpenSaasConfig,
listKey: string,
visitedLists: readonly string[] = [listKey],
selection?: FieldSelectionScope,
): { include: Record<string, unknown> | undefined; declaredOnly: DeclaredOnlyTree } {
const declaredNames = getDeclaredRelationNames(fieldConfigs)
const declaredNames = getDeclaredRelationNames(fieldConfigs, selection?.fields)

if (declaredNames.length === 0 && !rawInclude) {
return { include: rawInclude, declaredOnly: emptyDeclaredOnlyTree() }
Expand Down Expand Up @@ -166,13 +188,22 @@ export function foldDeclaredDependencies(
// that merely revisits a list (e.g. `Post → author → posts`).
if (declaredOnly.keys.has(key) && visitedLists.includes(relatedConfig.listName)) continue

// A branch added purely by the fold has no fragment scope of its own —
// it folds unrestricted, as before ADR-0027. A branch the request itself
// named (caller include or fragment) carries that name's own nested
// scope, if the fragment gave it one (a bare `true` selector leaves it
// `undefined` — also unrestricted, since the caller asked for
// "everything" there).
const nestedSelection = declaredOnly.keys.has(key) ? undefined : selection?.nested[key]

const explicitNested = getExplicitInclude(value)
const nested = foldDeclaredDependencies(
explicitNested,
relatedConfig.listConfig.fields,
config,
relatedConfig.listName,
[...visitedLists, relatedConfig.listName],
nestedSelection,
)

if (nested.include) {
Expand Down
Loading
Loading