Computed Fields
@computed (with per-request computedParams and the typed client surfaces)
is available since v0.8.11; RPC get selection parity and the
<Model>ComputedParams builders since v0.8.12. It replaces the earlier
@custom attribute, which generated a resolver trait nothing ever called; a
schema still using @custom fails to parse with a pointer to @computed.@computed declares such a field in the schema. The framework generates a
resolver hook, calls it while composing the response, and puts the result on the
wire — but never stores the field, never accepts it as input, and never lets you
filter or sort by it.
Image.proxyUrl is a real field to every client that decodes the response, and
does not exist as a column in Postgres.
The generated resolver
Each@computed field adds one method to a per-schema ComputedFieldResolver
trait. The method name is resolve_<owner>_<field>, both parts snake-cased:
db— theCratestackhandle, so a resolver may query. It runs after the originating operation, so the row it is enriching has already passed the model’s read policy.source— the server-side struct. Computed fields are absent from it (they are not stored), sosourceis exactly the persisted row.ctx— theCratestackContextfor the request, including auth. Use it to make a resolver caller-dependent.params— only for parameterized fields; see below.
Clone + Send + Sync + 'static, like the procedure registry.
Returning Err(...) maps to an error response through the normal
CratestackError status mapping.
Schemas with no computed fields
The macro generatesimpl ComputedFieldResolver for () whenever a schema
declares no @computed fields, so those schemas pass () and never write a
resolver:
resolvers argument sits between the procedure registry and the codec on
router, rpc_router, model_router, and procedure_router.
Where resolvers run
Computed fields are resolved everywhere a client decodes the owner:
Resolution happens after the database work, never before. On create, update, and
delete this means the write has already committed when the resolver runs — a
resolver that fails on a create returns an error response for a row that exists.
Keep resolvers side-effect free and treat their failure as a rendering failure,
not a transaction failure.
Exception: the output of an
@isolation procedure (since 0.14.1). For a procedure
that declares @isolation, the output’s
computed fields are resolved inside the procedure’s transaction, after its body and before
COMMIT. The resolver’s db is the same type as before but bound to that attempt, so its model
reads see the body’s writes; a resolver error rolls the whole attempt back; and on a serialization
failure or deadlock the attempt, resolvers included, runs again. Such resolvers must be safe to run
more than once. Their db.pool(), views(), queries() and events() still run on the pool,
outside the transaction.
@server_only fields stay out of procedure outputs (since 0.13.0). A procedure output is
composed field by field rather than through serde, and from v0.8.11 through v0.12.0 that
composition sent the @server_only fields of any model with a computed field
(GHSA-ch54-jqw2-vpp5,
see Field attributes).
Field selection skips resolvers
?fields= is honoured before the resolver is called, not after:
proxyUrl is excluded, so resolve_image_proxy_url is never invoked. This is
the mechanism to avoid paying for an expensive resolver on a listing that does
not need it. Computed field names are valid in ?fields= and in
includeFields[<relation>]; they are never valid in filters or in sort,
because there is no column to filter or sort on.
Parameterized resolvers
A resolver can take per-request arguments. Declare a params type and reference it from the attribute:type, must not itself contain computed
fields, and the trailing ? is required — params are always optional in this
version, because a required parameter would make an ordinary CRUD read
unsatisfiable. The resolver gains one argument:
On the wire
Read requests carry them in one query parameter,computedParams, whose value is
a URL-encoded JSON object keyed by computed field name:
{"proxyUrl": {"width": 800}}.
Rejected with a validation error: a value that is not a JSON object, a key that
names no computed field on this model, a key for a computed field that declares
no params type, a key for a field excluded by ?fields=, and a params object
that does not deserialize into the declared type. The first four are checked
before any database query runs; the deserialize check happens while composing the
response.
RPC transport
Ontransport rpc schemas the same params ride inside the frame, as a field
holding the JSON-object text:
get frames also carry the full REST selection surface — fields, include,
and include_fields (snake_case on the wire, matching the list frame) — so a
projected, relation-including, parameterized read is one frame:
model.<X>.list frames carry the same fields, and each frame in a
POST /rpc/batch envelope carries its own — selection and params are applied
per-frame, order preserved. Old frames without these keys keep working
unchanged, and validation is byte-for-byte the same code path the REST query
parameters go through: an unknown field name, an orphan include_fields
relation, or computedParams naming a field excluded by fields are all
rejected identically on both transports. Because the frame bytes are the
signed canonical body, everything in-frame is covered by request signing
automatically.
Where computedParams does not reach, the resolver receives None:
relation-included records, every non-read path (create/update/delete), and
procedure outputs. It applies to the request’s root model only.
Generated clients
Computed fields appear in generated response types for Rust, Dart, and TypeScript, and are excluded from create/update inputs,Where builders, and
sort enums in all three.
Every client accepts computedParams on get/list as a typed, generated
per-model class — one optional property per parameterized computed field, typed
as the declared params type. The parameter only exists on models that declare
at least one @computed(params: …) field; passing params to any other model is
a compile error in all three languages, not a runtime 422.
ImageComputedParamsBuilder is expanded by
build_runner from a @CratestackBuilder(...) annotation rather than emitted
inline — see Dart client generation: the --run-build-runner
flag for what that
means for the default preset specifically.
The Dart surface covers both presets and both transports — the plain APIs, the
riverpod @riverpod convenience providers (the params class has value equality,
so provider caching keys correctly), and the RPC client mode.
CratestackFetchQuery<TComputedParams = never>), so computedParams is
unassignable on models without parameterized computed fields. swr cache keys
incorporate the params, so differently-parameterized reads never collide.
<Model>ComputedParams carries the same generated typestate builder every other
generated object has; a plain struct literal with ..Default::default() works
too. The Rust client (both include_client_schema! and the server’s embedded
self-client) exposes the struct on REST and RPC calls.
Projected reads
Selection rides a separate client surface from the full-recordget, on both
transports: the Rust client’s get_view<P: ProjectionDecoder>(id, projection)
(now with an RPC twin) decodes a projected payload, and TypeScript’s RPC get
takes fields/include/includeFields on its per-model options bag — the same
shape TS REST has always had. get_view carries no computedParams (matching
REST’s get_view), so through the generated Rust surfaces you choose per call:
projection (get_view) or params (get, full record). The wire composes
both — TypeScript’s options bag can send fields and computedParams
together, and a hand-built RpcGetInput can too.
Rules enforced at parse time
cratestack check rejects all of these, with a span pointing at the offending
declaration:
@computedon anything other than atypeormodelfield — not on mixins, views, or theauthblock, all of which lack a response-composition step.@computedcombined with any other field attribute. A computed field is never stored or accepted as input, so@id,@default,@unique,@readonly,@relation, and validators would all be dead text on it.- A computed field typed as a
model, or as atypethat itself contains computed fields — a resolver’s return value is serialized as-is, so nested computed fields inside it would never be resolved. - A computed-bearing
typeormodelused as a procedure argument, directly or through a nested field. The client-side shape includes computed fields and the server-side shape does not, so such an input would silently lose data. - A computed field named in
@@id,@@unique, or@@index. Computed fields are never persisted, so a constraint over one could not be enforced by the database. @streamprocedures whose item type is computed-bearing — per-item resolution inside the incremental encoder is not implemented.- Two computed fields whose resolver method names would collide after
snake-casing (
Image.setUrlandImageSet.urlboth yieldresolve_image_set_url). - An attribute argument list separated from its attribute by whitespace —
@computed (params: ProxyParams?)is an error naming the attached spelling, not a silently bare@computed. (This applies to every field attribute, e.g.@default (5)too.)
include_embedded_schema! rejects any schema containing a computed field at
macro-expansion time. The embedded backend is synchronous and has no response
boundary at which a resolver could run; use include_server_schema! or
include_client_schema! for such a schema.
Limitations
- Event and change-stream payloads never carry computed fields.
@streamprocedures cannot return computed-bearing items.computedParamsapplies to the request’s root model only; relation-included records, write-path responses, and procedure outputs resolve withNone.- The Rust client’s RPC
get_viewcarries nocomputedParams, matching REST’sget_view; the Dart RPC client has no projection surface yet (forlisteither); swr’s RPCgetcache key does not incorporatefields. - Computed fields cannot be redacted through
@pii/@sensitive, since@computedcannot be combined with another attribute. A resolver must not return data that requires audit-log redaction.