stable

computed

Computed scalar fields: derived values declared on the schema, evaluated on every write and stored — or virtual, computed fresh on every read.
Subpath@noy-db/hub (root export)
TypeCore 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

**Landed here from 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
Argument order doesn't matter — 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
Asserting on <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? }>
})
AspectBehavior
KeyTop-level field name the result is stored under
ValuePure, 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)
OrderDeclaration order of the map's keys; each function sees the record with all earlier computed results injected
OwnershipSchema-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
CostThe 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 deps for what the function actually reads. The taint guard validates that each deps entry 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. Declaring classifiedFields: { ssn } in one call and a computed field with deps: ['ssn'] in a later, separate call over-refuses with ValidationError (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 throws ValidationError; 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 named a.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>Formatted presentation. 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 with computed: true and editable: 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 computed key 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 computed map 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()