computed
| Subpath | @noy-db/hub (root export) |
| Type | Core feature — activated per collection by declaring a computed map. |
What it does
A computed field is a value your data model owns instead of your callers: you declare a pure function on the collection (netAmount = unitPrice × qty), and every put() evaluates it and stores the result on the record. Callers never supply the field — and if they try, the computed value wins. Because the result is materialized (physically stored, not recalculated on read), a computed field behaves like any other field afterwards: it is encrypted with the record, queryable, indexable, and aggregatable.
Functions run in declaration order, and each one sees the record with all earlier computed fields already filled in — so total can read netAmount. Evaluation happens before schema validation, which means the schema validates the computed result, not the raw input.
There is no with*() factory to wire: declaring a computed map on a collection is the opt-in. The evaluation engine loads lazily on the first write to a computed-declared collection, so collections without one pay nothing in bundle size.
Since 0.3.0-pre.9, computed is a full field feature (see
via): each entry can carry declared dependencies and a mode —
{ fn, deps, mode } — and the same declaration composes through the via() spelling,
via(computed(fn, { deps, mode })). deps names the field(s) fn reads, feeding the
dependency graph that folds each computed field's effective security posture from its sources;
mode picks 'materialized' (the default — byte-for-byte the write-time compute described
above) or 'virtual' — computed fresh on every read and never stored.
Setup
Declare a computed map — field name to pure, synchronous function — in the collection config:
import { z } from 'zod'
import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'a-strong-secret',
})
type Line = {
id: string
unitPrice: number
qty: number
netAmount?: number
taxAmount?: number
total?: number
}
const vault = await db.openVault('acme')
const lines = vault.collection<Line>('lines', {
schema: z.object({
id: z.string(),
unitPrice: z.number(),
qty: z.number(),
// computed targets are optional: callers never supply them
netAmount: z.number().optional(),
taxAmount: z.number().optional(),
total: z.number().optional(),
}),
computed: {
netAmount: (r) => (r.unitPrice as number) * (r.qty as number),
taxAmount: (r) => (r.netAmount as number) * 0.2, // reads the field above
total: (r) => (r.netAmount as number) + (r.taxAmount as number),
},
})
Mark computed targets .optional() in the schema — the user never supplies them — but constrain them as strictly as you like (.nonnegative(), ranges, …): the schema runs after computation and validates the final values.
Usage
Write a record — computed fields materialize
await lines.put('a', { id: 'a', unitPrice: 10, qty: 2 })
const row = await lines.get('a')
{
"id": "a",
"unitPrice": 10,
"qty": 2,
"netAmount": 20,
"taxAmount": 4,
"total": 24
}
The three functions ran in declaration order — netAmount first, then taxAmount reading it, then total reading both — and the results were stored with the record. Reads are plain reads; nothing is recalculated.
Schema-owned: a user-supplied value is overwritten
A computed field belongs to the schema, not the caller. If a write supplies a value for a computed field, the computed result silently replaces it:
await lines.put('b', { id: 'b', unitPrice: 5, qty: 3, total: 9999 })
;(await lines.get('b'))?.total // → 18 (never 9999)
The schema validates the computed result
Because computation runs first, schema constraints apply to what the formula produced. A formula that yields an invalid value makes the whole put() reject — nothing is stored:
const audited = vault.collection('audited', {
schema: z.object({
id: z.string(),
amount: z.number(),
fee: z.number().nonnegative(),
}),
computed: { fee: (r) => (r.amount as number) * -0.01 },
})
await audited.put('x', { id: 'x', amount: 100 })
// → rejects with a SchemaValidationError: fee = -1 violates nonnegative()
And a formula that throws rejects the write with a ComputedFieldError naming the field and the record:
computed: { total: () => { throw new Error('boom') } }
await lines.put('c', { id: 'c', unitPrice: 1, qty: 1 })
// → rejects with ComputedFieldError: computed field "total" threw for record "c": boom
Virtual mode — computed on read, never stored
mode: 'virtual' flips the evaluation to read time: the function runs fresh on every
get()/list(), and the field is never stored — it is absent from the encrypted record
body entirely:
import { via, computed } from '@noy-db/hub'
const items = vault.collection<{ id: string; amount: number; doubled?: number }>('items', {
viaFields: {
doubled: via(computed((r) => (r.amount as number) * 2, { deps: ['amount'], mode: 'virtual' })),
},
})
await items.put('r1', { id: 'r1', amount: 21 })
;(await items.get('r1'))?.doubled // → 42 — computed on this read, not persisted
A virtual field is unconditionally non-queryable — query().where('doubled', …) throws
FieldNotQueryableError — because there is no stored value to match against. The same
{ fn, deps, mode } shape works in the plain computed: sugar map (a bare function still means
materialized mode with no declared deps). Virtual mode is construction-only: declaring it on a
later reconcile call for an already-constructed collection throws ValidationError.
Composing with money — both modes format the derived value
field "…" is declared via both a sugar key and \viaFields`?** The field is listed in **both** moneyFieldsandviaFields. A money-typed computed field is **one** viaFieldsentry composing both descriptors — do not also name it undermoneyFields`:// ✗ throws: via(): field "receiptAmount" is declared via both a sugar key
// and `viaFields` — declare it in one place only.
moneyFields: { receiptAmount: { currency: 'THB' } },
viaFields: { receiptAmount: via(computed(/* … */)) },
// ✓ one entry, both descriptors
viaFields: {
receiptAmount: via(
money({ currency: 'THB' }),
computed((r) => (r.kind === 'IV' ? r.paidTotal : r.amount), {
mode: 'virtual',
deps: ['kind', 'amount', 'paidTotal'],
}),
),
}
// receiptAmount '42.50' · receiptAmountFormatted 'THB 42.50' · receiptAmountNumber 42.5
via() groups by descriptor brand, not position.Stacking money(...) onto a computed field money-formats its result in both modes. In
'materialized' mode (default) the computed value merges into the record before money's
encode/decode/present hooks run, so money covers it exactly like a plain money field. In
'virtual' mode the value is never stored for money to decode, so it is money-formatted at read
time instead: the fresh function output is quantized to the field's scale and presented as a
decimal string, with a <field>Formatted companion alongside it.
const priced = vault.collection<{ id: string; base: number; doubledPrice?: number }>('priced', {
viaFields: {
doubledPrice: via(
computed((r) => (r.base as number) * 2, { deps: ['base'], mode: 'virtual' }),
money({ currency: 'EUR', scale: 2 }),
),
},
})
await priced.put('a', { id: 'a', base: 10.5 })
;(await priced.get('a'))?.doubledPrice // → '21.00' — quantized decimal string
;(await priced.get('a'))?.doubledPriceFormatted // → defined — locale-dressed companion
<field>Formatted in a test?Intl.NumberFormat separates the currency code from
the number with a non-breaking space (U+00A0), not a regular space. Compare against a literal
you typed and you get the maddening expected 'THB 42.50' to be 'THB 42.50' — two strings that
look identical and are not. Normalise first:const spaces = (s: unknown) => String(s).replace(/\s/g, ' ')
expect(spaces(rec.receiptAmountFormatted)).toBe('THB 42.50')
Two constraints on the virtual case: it is fixed-currency only (a virtual output has no
{ amount, currency } shape for money's multi-currency mode to parse), and it only quantizes a
value that parses cleanly at the declared scale. A function output with excess precision and no
rounding declared is left raw and untouched — 21.005 stays 21.005, no error — while a
declared rounding (e.g. 'half-up') rounds it into range ('21.01').
Taint — a computed field inherits its sources' security posture
A computed field's effective security posture is folded from its deps' postures by the
via dependency graph. A materialized field computed from a
classified source is sealed at rest, refused in the query DSL, and
redacted on export — it reads back as a sealed handle, not plaintext:
const people = vault.collection('people', {
classifiedFields: { ssn: classified.email() /* any preset */ },
computed: { ssnCopy: { fn: (r) => r.ssn, deps: ['ssn'] } }, // materialized (default)
})
await people.put('r1', { id: 'r1', ssn: '123-45-6789' })
const rec = await people.get('r1')
// rec.ssnCopy is a SealedHandle — JSON.stringify(rec.ssnCopy) → '"[sealed]"', never the value
A virtual field from a classified source has nothing to seal (it is never stored), so it is
redacted to the literal '[sealed]' string on every read instead. This posture inheritance
is a deliberate behavior change shipped in 0.3.0-pre.9: a computed-from-classified
configuration that previously leaked plaintext into an ordinary field now seals or redacts it.
When a redacted virtual field is also money-formatted, its <field>Formatted/<field>Number
companions are stripped along with the base field — the redaction never leaves a pre-redaction
formatted value behind.
Two guard rails at declare time, on collections that also declare classified fields: a computed
entry with no deps, or with a deps entry naming an unknown field, throws
ValidationError at construction (an opaque function could otherwise copy sealed plaintext with
no way for the graph to know). Known limit, deliberately documented: the guard checks that
deps names some known field, not that it names the field the function actually reads —
deps: ['amount'] on a function that reads ssn still passes and still leaks, because the
graph folds from amount's posture. Declare deps honestly.
Computed money fields aggregate exactly
Declare the computed target in the collection's moneyFields map and the numeric result is quantized to an exact decimal on store — so sums over it never accumulate float error (see money and aggregate):
import { withReduce, sum } from '@noy-db/hub/reduce'
import { money } from '@noy-db/hub'
// createNoydb({ ..., reduceStrategy: withReduce() })
const sales = vault.collection('sales', {
schema: z.object({
id: z.string(), unitPrice: z.number(), qty: z.number(),
total: z.union([z.number(), z.string()]).optional(),
}),
computed: { total: (r) => (r.unitPrice as number) * (r.qty as number) },
moneyFields: { total: money({ currency: 'EUR', scale: 2 }) },
})
await sales.put('a', { id: 'a', unitPrice: 0.10, qty: 1 })
await sales.put('b', { id: 'b', unitPrice: 0.20, qty: 1 })
const agg = await sales.query().aggregate({ total: sum('total') }).run()
// → { total: "0.30" } — exact decimal string, not 0.30000000000000004
API
All exports below come from the @noy-db/hub root.
computed
collection<T>(name, {
schema,
computed?: ComputedFields<T>, // Record<string, fn | { fn, deps?, mode? }>
})
| Aspect | Behavior |
|---|---|
| Key | Top-level field name the result is stored under |
| Value | Pure, synchronous function (record) => unknown — no async, no I/O — or, since 0.3.0-pre.9, an object { fn, deps?: string[], mode?: 'materialized' | 'virtual' } (a bare function means mode: 'materialized', no declared deps) |
| Order | Declaration order of the map's keys; each function sees the record with all earlier computed results injected |
| Ownership | Schema-owned: the computed result overwrites any user-supplied value of the same name |
| When | 'materialized': on every put(), after write gates, before schema validation. 'virtual': on every read, never stored |
| Cost | The evaluation engine lazy-loads on the first write to a computed-declared collection; zero bundle cost otherwise |
computed(fn, opts?) — the via-composable descriptor
import { via, computed } from '@noy-db/hub'
function computed(fn: (record) => unknown, opts?: { deps?: readonly string[]; mode?: 'materialized' | 'virtual' }): ComputedDescriptor
The descriptor form for the via() spelling — viaFields: { total: via(computed(fn, { deps: ['subtotal'] })) } — identical semantics to the sugar map's object entries (materialized mode is
byte-for-byte identical to the plain sugar, pinned by test). isComputedDescriptor(x) is the
runtime predicate; the ComputedDescriptor type is exported alongside.
ComputedFn<T> / ComputedFields<T> (types)
type ComputedFn<T = Record<string, unknown>> = (record: T) => unknown
type ComputedFields<T = Record<string, unknown>> = Record<string, ComputedFn<T>>
The shape of one computed function and of the whole map.
evalComputedFields(record, computed, id)
function evalComputedFields<T extends Record<string, unknown>>(
record: T,
computed: ComputedFields,
id: string,
): T
The pure evaluator the write pipeline uses — exported for testing formulas outside a vault. Returns a shallow clone with every computed field injected in declaration order; the input record is not mutated. Throws ComputedFieldError if any function throws.
import { evalComputedFields } from '@noy-db/hub'
evalComputedFields(
{ unitPrice: 10, qty: 2 },
{ netAmount: (r) => (r.unitPrice as number) * (r.qty as number) },
'a',
)
// → { unitPrice: 10, qty: 2, netAmount: 20 }
ComputedFieldError
class ComputedFieldError extends NoydbError {
readonly field: string // the computed field whose function threw
readonly id: string // the record being written
readonly cause: unknown // the original thrown value
}
Error code COMPUTED_FIELD. Raised when a computed function throws during a write; the put() rejects and nothing is stored.
Limits & gotchas
- Synchronous, single-record only. A computed function gets exactly one record and must return immediately — no
await, no reading other records or collections. Cross-record and reactive derivation belong to derivations and materialized views. - Declare
depsfor what the function actually reads. The taint guard validates that eachdepsentry names a known field, but cannot verify the function body — a wrong-but-known dep still passes construction and folds the wrong posture. On classified-declaring collections, depsless or unknown-dep entries are refused outright. - Deps validate against the same
vault.collection()call. DeclaringclassifiedFields: { ssn }in one call and a computed field withdeps: ['ssn']in a later, separate call over-refuses withValidationError(fail-safe) — declare the classified field and its dependent computed field together in one call. - Virtual mode is construction-only.
mode: 'virtual'on a reconcile call for an existing collection throwsValidationError; there is no late-attach door for a never-stored field. - Top-level field names, not nested paths. The map key becomes a top-level property on the record; a key like
'a.b'creates a literal property nameda.b, not a nested one. - Materialized values are not retroactive. Values are computed when a record is written. Changing a formula does not rewrite existing records; they keep the values computed at their last write until they are written again (
mode: 'virtual'sidesteps this — it always evaluates the current formula on read). - Callers cannot override. A user-supplied value for a computed field is silently replaced, never merged or rejected. If a caller must be able to set the field sometimes, it should not be computed.
- Declaration order is load-bearing. A function reading a computed field declared after it sees the raw (possibly
undefined) input value. Order dependent fields accordingly. - A virtual function can't read another field's presentation. A
mode: 'virtual'function runs before label/format dressing from features like i18n or lookup is applied, so it can read sibling fields' stored values but not their derived<field>Label/<field>Formattedpresentation. A same-field money amount is the exception — money quantizes ahead of computed, so it is visible. - Not user-editable in introspection.
collection.describe()reports computed fields withcomputed: trueandeditable: false, so schema-driven UIs render them read-only. - classified rider companions share the map. Field classifications that derive companion fields merge into the same computed map and run before your functions (your formulas may read them); declaring a
computedkey that collides with a rider companion is a configuration error at collection setup.
See also
- via — the field-feature surface, the dependency graph, and posture enforcement
- Schema — the collection schema the
computedmap sits beside - derivations — cross-record, reactive derivation (rollups, downstream collections)
- materialized-views — derived collections rather than derived fields
- money — declare a computed target as a money field for exact decimal storage
- aggregate —
sum()/groupBy()over computed fields - guards — write gates run before computed evaluation and treat computed fields as schema-owned
- introspection — how computed fields appear in
collection.describe()