Rollup field — frontend integration
Rollup field — frontend integration
A new field type, rollup, is being added. It is a read-only, computed
aggregate of one field across the many child records reached through a
to-many relation — e.g. a Company's Total Pipeline = SUM of its Deals'
amount, or # of Deals = COUNT.
rollup is the to-many sibling of lookup:
Relation links to a record · Lookup shows one of its fields · Rollup adds them up.
⚠️ Rollout status.
rollupis not gated — the type is offered in the picker and accepted at create in every environment. The engine now computes. Options validation, live recompute when children change, and back-fill on field create have all landed, so totals move on their own.What remains [Coming] is narrower: comparators/sorting, dashboard measures, and
rollup_status. Each section is marked.
1. Field-type picker — GET /object/field/types [Available now]
Gains one entry:
{ "id": "rollup", "name": "Rollup", "icon": "tabler-sum", "category": "relation", "is_available": true }is_availableis alwaystrue.tagis the only type the picker withholds (it is system-provisioned by messaging label-sync).- Sits in the
relationcategory alongsiderelationandlookup.
2. Builder — creating a rollup
Request — POST /object/field/{objectSlug} [Available now]
objectSlug is the parent object (the one that will carry the total, e.g.
Company). Body is the standard CreateFieldDto:
{
"name": "total_pipeline", // required, 1–100, immutable reference key
"label": "Total Pipeline", // required, 1–100, user-facing display text
"type": "rollup", // required
"icon": "tabler-sum", // optional
"is_active": true, // optional, default true
"options": { // required for rollup — the aggregate definition
"relation_field_slug": "deals", // required: a TO-MANY relation on THIS object
"aggregate_fn": "sum", // required: "sum" | "count" | "avg"
"target_field_slug": "amount_ab12", // required for sum/avg; omit/ignored for count
"result_type": "currency", // optional: "number" | "currency"; inferred from target
"decimal_precision": 2, // optional int ≥ 0
"currency_code": "KWD", // optional; only when result_type = "currency"
"null_handling": "null", // optional: "null" (exclude) | "zero"; default "null"
"division_by_zero": "null" // optional (AVG only): "null" | "zero" | "error"; default "null"
}
}Note:
required,default_value, andapi_onlyare ignored for a rollup — the value is engine-owned, so there is no user default and nothing to require.
Field scope note (this first cut): aggregate_fn is limited to
sum · count · avg. min / max / count_distinct / concat and an
optional child filter are rejected with ROLLUP_BAD_FUNCTION — leave them
out of the builder.
Accepted target_field_slug types for sum/avg: number and currency only.
types=number,currency is exactly right — keep sending that. rating and
serial_number are rejected (ROLLUP_TARGET_TYPE_INVALID): a rating is an
ordinal, a serial number is an identity counter, and summing either produces a
number with no meaning. date will be accepted for min/max when those land,
never for sum/avg.
Response — 201 Created
{
"message": "Field created successfully",
"data": {
"name": "total_pipeline",
"slug": "total_pipeline_9f3a", // stable slug — reference the field by THIS, never a numeric id
"label": "Total Pipeline",
"type": "rollup",
"icon": "tabler-sum",
"options": { "relation_field_slug": "deals", "aggregate_fn": "sum", "target_field_slug": "amount_ab12", "result_type": "currency", "decimal_precision": 2, "null_handling": "null", "division_by_zero": "null" },
"is_system": false,
"api_only": false,
"field_group": "custom",
"required": false,
"default_value": null,
"sort_order": 12,
"is_active": true,
"filterOperators": ["is_empty", "is_not_empty"]
}
}The data object is the standard FieldResponseDto — identical shape to every
other field type. Persist the returned slug; it is how you read/patch/delete
the field.
Reading the config back — labels included [Available now]
Every read that returns a rollup descriptor —
GET /object/field/active/{objectSlug}, the field list,
GET /object/field/show/…, and the fields[] embedded in record reads —
stamps the labels of the two slugs onto options:
"options": {
"relation_field_slug": "deals",
"target_field_slug": "amount_ab12",
"relation_field_label": "Deals", // ← new
"target_field_label": "Amount", // ← new
"aggregate_fn": "sum",
"result_type": "currency"
}Render "SUM of Deals · Amount" straight from the descriptor — no fetching the relation list and the child object's field list to translate two opaque slugs.
target_field_labelis absent forcount, which aggregates rows rather than a field. Same rule astarget_field_slug.- Neither label is stored. Both are re-derived per request, so renaming the relation or the aggregated field shows up on the very next read.
- Both are absent when the config no longer resolves (deleted relation,
deleted/deactivated target field). Do not read absence as "count" — check
aggregate_fn. A dedicatedrollup_statusfor that case is still [Coming].
Builder UX
- Aggregate —
Sum/Count/Average. - Through relation — a dropdown of this object's to-many relations only. A to-one relation must be greyed out with "use a Lookup". If the object has no to-many relation, deep-link to the relation builder — a rollup can't exist without one.
- Field to aggregate — a searchable dropdown of the child object's
fields, filtered to types valid for the function (Sum/Average ⇒ numeric or
currency). Hidden when the function is
Count(it counts rows). - Advanced (collapsed): result type, decimal places, currency code (only when currency), null handling, division-by-zero (Average only).
- Defaults so the common case is three picks + Save:
aggregate_fn = "sum",result_typeinferred from the target (currency target ⇒ currency),null_handling = "null",division_by_zero = "null".
Rejections to surface [Available now]
| Code | HTTP | Cause |
|---|---|---|
ROLLUP_REQUIRES_TO_MANY | 422 | The chosen relation is to-one — use a Lookup. |
ROLLUP_UNKNOWN_RELATION | 422 | relation_field_slug is not a relation on this object. |
ROLLUP_UNKNOWN_TARGET_FIELD | 422 | target_field_slug is not a field on the child object. |
ROLLUP_TARGET_TYPE_INVALID | 422 | Sum/Average over a non-numeric target. |
ROLLUP_BAD_FUNCTION | 422 | aggregate_fn is not sum/count/avg. |
ROLLUP_BAD_OPTION | 422 | A presentation option (result_type, null_handling, division_by_zero, decimal_precision) is out of range. |
These codes now ship. Map them to inline field errors through the existing
useFieldFormSubmit→calculationError-style translator. Every one arrives as{ code, message }in the 422 body. Client-side guards are still worth keeping as pre-flight, but they are no longer the only defence — before this landed there was no rollup entry in the options-validator registry at all, so a malformed config was silently stored.
3. Reading the value — the JSONB envelope [Available now]
A rollup stores an envelope, not a bare number, so the average can be maintained without re-scanning children:
// field value on a record
{ "value": 50000, "sum": 50000, "count": 3, "computed_at": "2026-07-28T09:12:44.100Z" }- Render
value— that is the display scalar: forsumit equalssum, forcountit equalscount, foravgit equalssum / count. sum/countare bookkeeping — you normally don't show them, but they're handy for a tooltip ("average of 3 deals").- Format
valuebyresult_type: plain number, or the usual"222 KWD"-style currency (per-currency decimals). - Empty cases:
nullvalue (whole cellnull) means "nothing to show" — an Average over zero children (perdivision_by_zero: "null"), or not-yet computed. Count/Sum of zero children render0. - Error case: with
division_by_zero: "error", an Average over zero children reads{ "error": "DIVISION_BY_ZERO", "value": null, ... }— render a "No records to average" chip, neverNaN. countrollups storesum: null, not0—0would read as "the children summed to zero".- Average divides by the CONTRIBUTING children, not by every linked child.
Three Deals where only two carry an
amountaverage over 2.countin the envelope is that divisor, which is what makes the "average of N" tooltip honest.
Branch statically on field.options.result_type / aggregate_fn; a generic
number renderer that reads the bare column will see the envelope object.
4. Rendering on a record [Available now]
Render as non-editable with a Σ affordance and a tooltip like
"Sum of Deals → amount · 42 records". No input to focus — read-only-ness
should be visible, not merely enforced.
Any submitted value for a rollup slug is silently dropped by the server —
no error, nothing stored. (Like calculation / lookup, it's stripped from the
writable set.)
5. Freshness & eventual consistency [Available now]
Totals update as a side-effect of child records changing (create / edit the target field / delete), not when you edit the parent. Recompute is queued, so a total lags a child change by a moment.
computed_atis the freshness signal you asked for. It is an ISO timestamp inside the envelope, stamped by the recompute that produced it. Compare it against the child record'supdatedAt: a child edited more recently than the parent'scomputed_atmeans a recompute is in flight — that is the "updating…" state (UX-3308). There is no separaterollup_state: 'computing'field; the timestamp carries it, and it survives a page reload where an ephemeral state flag would not.- The parent's create/update response still does not carry a freshly recomputed rollup — the recompute is queued. You no longer need to poll or refetch for it, though: when the recompute settles it is pushed over the socket (§5.1).
- On field create, existing parents back-fill in the background. Until the
back-fill reaches a row its cell is
nullwith nocomputed_at, which is exactly the "computing totals…" state (UX-3306): envelope absent ⇒ computing; envelope present ⇒ settled as ofcomputed_at.
5.1 Realtime — data.rollup_updated [Available now]
Previously nothing told you a total had moved. The recompute runs in a queued
job after the child's write returned, and it deliberately does not touch the
parent's updated_at (a total moving is not an edit of the parent — stamping it
would corrupt the audit trail and re-order every recency-sorted list). So a
Company's Total Pipeline silently disagreed with the screen — including for
the person who had just added the Deal — until something forced a refetch.
Subscribe to the parent object on the existing /data/events namespace, the
same connection data.updated and data.lookup_stale already use:
socket.emit('subscribe:object', { objectSlug: 'company' });
socket.on('data.rollup_updated', (e) => {
for (const record of e.records) {
patchRow(e.objectSlug, record.slug, record.values); // values keyed by field slug
}
});Payload:
{
"eventType": "data.rollup_updated",
"objectSlug": "company",
"records": [
{
"slug": "comp-a3f9", // parent record slug — never a numeric id
"values": { // only the rollups that MOVED
"total_pipeline": { "value": 41000, "sum": 41000, "count": 3, "computed_at": "2026-07-28T12:00:05.001Z" },
"deal_count": { "value": 3, "sum": null, "count": 3, "computed_at": "2026-07-28T12:00:05.001Z" }
}
}
],
"timestamp": "2026-07-28T12:00:05.014Z"
}Notes that matter:
- It carries the value — patch the cell, do not refetch. This is the
deliberate difference from
data.lookup_stale, which names slugs only. A lookup value is resolved through the reader's own rights on the object it mirrors, so it cannot be broadcast; a rollup is aggregated once and stored on the parent's own column, so every reader of that record already gets this exact number from the HTTP read path. valuesholds only the rollups that changed, keyed by field slug. Merge it into the row; do not treat it as the full record.- One event per parent object, carrying every affected record. A single child write can move totals on more than one object (Company and Territory) — those arrive as separate events.
- Back-fill is silent. Creating a rollup field rewrites every parent record,
so it is not relayed; the refetch you already do after creating a field covers
it. Keep the
computed_at-absent ⇒ "computing…" rule for that case. - Best-effort, like every push here. A dropped socket means a stale cell until
the next read — the
computed_atrule in §5 remains the source of truth.
6. Filtering & sorting [Coming]
Still deferred. FIELD_TYPE_OPERATORS["rollup"] remains
["is_empty", "is_not_empty"], so an operator-driven picker only offers
presence checks. Comparators on the numeric total ("pipeline > 1M") and
sorting/group-by need (col->>'value')::numeric in both the filter builder and
the sort clause plus an expression index — don't build those affordances yet.
Dashboard measures: keep rollup in EXCLUDED_MEASURE_TYPES. The
aggregation service aggregates the bare column, which on a JSONB column is
a hard Postgres error, and rollup is not in its numeric-type allow-list either.
Reversing the exclusion is safe only once the backend adds ->>'value'
extraction and registers rollup as numeric.
7. Export / documents / public forms [Coming]
Exports and document/PDF templates will emit the formatted value. Import
ignores any rollup column in the file (the value is recomputed, never
imported). On public reads, a rollup is treated like any computed field —
surfaced only when the parent object is public.