Queries & projections
Stack has no GraphQL layer. A read is composed on context.db.<List> — an immutable query value — and narrowed with .select(), which the engine honours exactly. There is no fragment to declare, no codegen step, and no result type to infer by hand: the generated types give each list's surface its own shape.
const summaries = await context.db.Post.where({ published: { equals: true } })
.orderBy({ createdAt: 'desc' })
.select('title', 'excerpt')
.all()
Everything below runs through the same secured terminals — .all(), .first(), .aggregate(), .nearest() — so your access control rules are always enforced. The Context API reference describes each terminal and what it returns; this page is about what a projection means.
Migrating from Keystone? See the Migrating from KeystoneJS guide for a side-by-side translation table.
.select()
.select() names the fields of this list you want back. It replaces any previous call rather than accumulating, and the result matches it exactly.
// Exactly these keys, plus the list's system fields
const rows = await context.db.Post.select('title', 'excerpt').all()
A computed field is selectable like any other. Selecting one returns it whether or not you named the columns it is computed from. Below, wordCount declares needs: ['body']: the engine reads body, computes the field, and leaves body out of the result, because the call site never asked for it.
const rows = await context.db.Post.select('wordCount').all()
That is the whole rule: the engine widens the query for what it needs — the declared dependency sets of the computed fields it will return, and any field read rule that has to see a row to answer — and then strips everything it added back out, at every nesting level.
Relations are reached with .include(), not .select()
.select() narrows this list's own columns; a relation arrives because a read named it. The two compose, and naming a relation in .select() is refused:
const rows = await context.db.Post.select('title')
.include('author', (author) => author.select('name'))
.all()
// → [{ id, createdAt, updatedAt, title, author: { id, createdAt, updatedAt, name } }]
A refinement takes its own .select(), so a projection is exact at every level.
The result type narrows with the projection
The generated types read the projection off the call site, so an unselected column is a compile error rather than an absent value — and so is a key the list does not have.
const [row] = await context.db.Post.select('title').all()
row.title // string
row.body // Property 'body' does not exist
.limit()
.limit(count) bounds .all(). It replaces any previous call; .first() is bounded by its own terminal, and .nearest() takes its bound from options.limit.
const recent = await context.db.Post.orderBy({ createdAt: 'desc' }).limit(20).all()
What a resolveOutput hook sees
A computed field's hook is handed exactly its own declared dependencies plus the list's system fields — never what the caller happened to select. So the item below is { id, createdAt, updatedAt, body } on every read of the list, no matter what the call site selected.
Post: list({
fields: {
body: text(),
wordCount: virtual({
type: 'number',
needs: ['body'],
hooks: {
resolveOutput: ({ item }) => item.body.split(/\s+/).length,
},
}),
},
})
This is what keeps a field's value the same from every call site. A hook that reads something it did not declare finds nothing there — declaring it is what earns the data. See needs and ADR-0051.
The cost: a declaration outranks a caller's read denial
A declaration outranks a caller-facing read denial on the same column or relation: the value reaches the hook, and is still stripped before the caller sees it. That is what buys the property above — adding a read rule elsewhere cannot silently change a computed field's value.
The price is the other direction of the same rule. needs: ['passwordHash'] is a deliberate, greppable way to surface a denied column's derived value: the column's own read rule does not stop the hook from seeing it, and whatever the hook returns is a field of its own, subject only to that field's rules. The stack does not try to guess which derivations are safe, so this is a cost you own — grep your needs declarations when you audit a read denial. See ADR-0051.
A read with no projection
A read that names no .select() returns the row's own columns plus its computed fields — never its relations, matching the ORM's own semantics for the same call. Naming a relation fetches that relation's own columns and stops; reaching further means naming further. See ADR-0024.
Denial is silent
A denied read returns the empty value of its type — [] from .all(), null from .first() — rather than throwing, whether the rows do not exist or the session may not see them. A projection changes nothing about that: the denial is resolved before the query is built.