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.
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.