via Field features — one declaration surface, one pipeline, one dependency graph
| Subpath | @noy-db/hub (root export — via, the feature factories, and the posture errors) |
| Wiring | no with*() strategy — declare viaFields: { field: via(...) } on vault.collection() |
| Kind | core feature — the kernel's unified field-feature declaration surface |
What it does
Every capability a field can have in noy-db — exact-decimal money, translated text, a reference to a lookup dimension, a computed value, a sealed classified value, an externalized blob — is a via-feature: a declaration on the field that plugs behavior into one unified, kernel-run pipeline. You start with plain JSON and adopt one feature at a time; each feature is a separately tree-shaken chunk, so anything you don't declare doesn't exist in your bundle.
The name is grammar: family packages read as two-letter prepositions (data goes to a store,
unlock on a method), vault services read as with (withSync() — the vault comes with
sync), and field features read as via — the value passes via the formula, via the seal,
via the dictionary. Same product, three grains: package, vault, field.
Underneath, every feature's promises are enforced rather than documented: each binding declares a security posture, the kernel refuses queries and redacts exports that would violate it, and a per-vault dependency graph makes a derived field inherit the strictest posture of its sources — so a stale rollup, a dangling reference, or a leaked derivative of a sealed value is a kernel-refused state, not a bug found in production.
Declaring features — via() and viaFields
viaFields is a sibling collection option next to schema — never a key inside it — holding
one via(...) per declared field:
import { createNoydb, via, money, i18nText } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
import { z } from 'zod'
const db = await createNoydb({ store: toMemory(), user: 'alice', secret: 'a-strong-secret' })
const vault = await db.openVault('books')
const invoices = vault.collection('invoices', {
schema: z.object({
id: z.string(),
total: z.union([z.number(), z.string()]),
note: z.record(z.string(), z.string()),
}),
viaFields: {
total: via(money({ currency: 'EUR', scale: 2 })),
note: via(i18nText({ languages: ['en', 'th'], required: 'all' })),
},
})
A field can stack more than one feature in a single via(...) call — for example a computed
money total:
// A fragment: `viaFields` is shown out of its `collection(...)` context, so it
// is deliberately not standalone TypeScript. Marked no-check so it does not
// suppress semantic analysis for every other snippet — see registry/validate-snippets.mjs.
import { computed } from '@noy-db/hub'
viaFields: {
total: via(
computed((r) => (r.subtotal as number) * 1.07, { deps: ['subtotal'] }),
money({ currency: 'EUR', scale: 2 }),
),
}
The stack order features run in is deterministic and kernel-pinned (money → i18n → classified →
blob → computed — computed always compiles last, so its formula can read other fields'
already-decoded values); see computed for what that ordering means when
computed stacks with money on the same field.
Sugar equivalence — the existing spellings are the same thing
The feature-specific sugar keys — moneyFields, i18nFields, dictKeyFields, lookupFields,
computed — compile to the same internal bindings as viaFields, with byte-identical stored
envelopes and identical describe() output. Existing code keeps working unchanged; via() is
the canonical composable spelling, not a replacement:
// Sugar spelling:
vault.collection('invoices', { schema, moneyFields: { total: money({ currency: 'EUR', scale: 2 }) } })
// via() spelling — identical internals, envelopes, and introspection:
vault.collection('invoices', { schema, viaFields: { total: via(money({ currency: 'EUR', scale: 2 })) } })
Declaring the same field under both a sugar key and viaFields throws ValidationError
naming the field — one declaration site per field.
The feature catalog
| Feature | Factories | Declared via | Page |
|---|---|---|---|
| Money | money() | via(...) or moneyFields | money |
| I18n text + dict codes | i18nText(), dictKey(), staticDict() | via(...) or i18nFields / dictKeyFields | i18n |
| Lookup references | lookup(), enumOf() / enum, dict() | via(...) or lookupFields | lookup |
| Computed values | computed(fn, { deps, mode }) | via(...) or the computed map | computed |
| Classified (sealed) fields | the classified.* presets | classifiedFields (sugar only — see below) | classified |
| Blob attachments | slot policies | blobFields (sugar only — see below) | blobs |
Classified fields and blobs are full via-features internally — they carry a binding, a posture,
and an erasure hook like every other feature — but they are declared through their own sugar keys
(classifiedFields / blobFields), not through the via() composer: via(classified()) /
via(blob()) spellings do not exist yet.
The phased pipeline
All field features run in a kernel-orchestrated phased pipeline, so cross-feature ordering is pinned in one place rather than hand-wired per feature:
WRITE: derive (computed) → normalize (money/i18n/lookup) → validate (kernel) → encode (classified) → store
READ: load → decode (classified) → present (money/i18n/lookup/computed-virtual)
Fields with no via declaration skip the pipeline entirely — an identity fast path, so plain documents pay nothing.
Posture — a feature's security contract, enforced
Every binding declares a ViaPosture across four axes: encryptedAtRest
('envelope' — the normal record encryption — or 'sealed' — its own slot under per-record key
material), queryable ('none' | 'det-exact' | 'ordered' | 'full'), exportable (boolean), and
forgettable (boolean). The kernel consults posture generically — no per-feature special cases:
- Query — the query DSL refuses a
queryable: 'none'field withFieldNotQueryableError(e.g. ablobFieldsslot, or a virtual computed field); a classified field keeps its deterministic exact-match behavior (det-exact) unchanged. - Export —
vault.exportStream()/exportJSON()redact anexportable: falsefield to the literal string'[sealed]'on the record itself, before any exporter sees it. - Forget —
vault.forget()consultsforgettableand folds each sealed-posture binding's erasure hook into its report (thesealedFieldsShredded/sealedResidueaccounting).
The dependency graph — derived fields inherit the strictest source posture
A per-vault dependency graph connects every derivation, rollup,
materialized view, and computed field to the
sources it reads. Its taint algebra folds two postures to the strictest per-axis result —
encryptedAtRest: sealed wins; queryable: the least-capable rung of
none < det-exact < ordered < full; exportable: logical AND; forgettable: logical OR:
foldPosture(CLASSIFIED, MONEY)
// → { encryptedAtRest: 'sealed', queryable: 'det-exact', exportable: false, forgettable: true }
The fold is load-bearing: a computed field whose deps include a classified source is actually
sealed at rest (materialized mode) or redacted on every read (virtual mode) — it can no longer
silently copy sealed plaintext into an ordinary field. The same fold reaches whole-record formula
outputs: a derivation, rollup, or
materialized view that reads a classified-bearing source now seals
its output field(s) at rest, refuses them to queries, and redacts them from exports by default — a
compute function can no longer land a copied classified plaintext in the open. The graph also
powers three cross-cutting behaviors:
- Sync dispatch — writes applied by
pull()/push(), schema cutover, or restore dispatch derivations in a batched, per-target-deduped wave (N synced children of one rollup parent recompute the parent once, not N times); a collection with no dependents is skipped at zero decrypt cost. - Frozen-output rule — a derivation output landing in a period closed by
periods is skipped (the historical output stands) and audited via the
derivation:skipped-frozenevent, instead of failing the legal source write that triggered it. - Forget fan-out —
vault.forget()erases record-grain derived copies and recomputes aggregate-grain rollups without the forgotten contribution.
See computed for the worked taint examples, the declare-time dependency guard and its limits, and the virtual/materialized mode semantics.
Limits & gotchas
- One declaration site per field — the same field under both a sugar key and
viaFieldsthrowsValidationError. via(classified())/via(blob())don't exist — classified fields and blobs are declared throughclassifiedFields/blobFieldsonly; the composer covers money, i18n (i18nText/dictKey/staticDict), lookup, and computed.- Posture inheritance is a behavior change for pre-existing configurations that computed a value from — or ran a derivation, rollup, or materialized view over — a classified field: what used to read back (and export, and query) as plaintext is now sealed or redacted. This is a deliberate security fix, shipped pre-1.0.
- Scoped forget purge is opt-in. By default,
vault.forget()'s vault-level sealed-key and blob purges run unconditionally across every collection — a field declaration is a necessary-but-not-sufficient signal, sincesealRecordToHost()and.blob(id)work on collections that never declared a classified or blob field. PassingwithForget({ subjects, scopedPurge: true })instead gates both purges on whether the collection declaredclassifiedFields/blobFields, and reports every skipped collection throughForgetResult.scopedPurgeResiduerather than narrowing the erasure promise silently. See forget, classified, and blobs for the full semantics. - Export bytes changed for redacted fields: the default (non-
redact-option) export of a classified field through@noy-db/as-csv/@noy-db/as-sql/@noy-db/as-xmlnow emits the plain string'[sealed]'(a bare[sealed]CSV cell; atextSQL column) where it previously fell through to JSON-stringified output of a live sealed handle ("""[sealed]"""in a CSV cell; ajsonbcolumn) — the new output is the intended one, and closes an accidental path where a revealable handle reached an export stream.
See also
- money · i18n · lookup · computed · classified · blobs — the individual features
- derivations · materialized-views — the cross-record derived data the dependency graph also covers
- forget — the erasure report the posture and graph fan-outs feed
- Services overview · Orientation map