experimentalsince 0.3.0-pre.9

via Field features — one declaration surface, one pipeline, one dependency graph

The via() field-feature composer: declare what a field can do — money, i18n, lookup, computed, classified, blob — as one stacked declaration per field.
Subpath@noy-db/hub (root export — via, the feature factories, and the posture errors)
Wiringno with*() strategy — declare viaFields: { field: via(...) } on vault.collection()
Kindcore 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

FeatureFactoriesDeclared viaPage
Moneymoney()via(...) or moneyFieldsmoney
I18n text + dict codesi18nText(), dictKey(), staticDict()via(...) or i18nFields / dictKeyFieldsi18n
Lookup referenceslookup(), enumOf() / enum, dict()via(...) or lookupFieldslookup
Computed valuescomputed(fn, { deps, mode })via(...) or the computed mapcomputed
Classified (sealed) fieldsthe classified.* presetsclassifiedFields (sugar only — see below)classified
Blob attachmentsslot policiesblobFields (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 with FieldNotQueryableError (e.g. a blobFields slot, or a virtual computed field); a classified field keeps its deterministic exact-match behavior (det-exact) unchanged.
  • Export — vault.exportStream() / exportJSON() redact an exportable: false field to the literal string '[sealed]' on the record itself, before any exporter sees it.
  • Forget — vault.forget() consults forgettable and folds each sealed-posture binding's erasure hook into its report (the sealedFieldsShredded / sealedResidue accounting).

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-frozen event, 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 viaFields throws ValidationError.
  • via(classified()) / via(blob()) don't exist — classified fields and blobs are declared through classifiedFields / blobFields only; 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, since sealRecordToHost() and .blob(id) work on collections that never declared a classified or blob field. Passing withForget({ subjects, scopedPurge: true }) instead gates both purges on whether the collection declared classifiedFields / blobFields, and reports every skipped collection through ForgetResult.scopedPurgeResidue rather 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-xml now emits the plain string '[sealed]' (a bare [sealed] CSV cell; a text SQL column) where it previously fell through to JSON-stringified output of a live sealed handle ("""[sealed]""" in a CSV cell; a jsonb column) — the new output is the intended one, and closes an accidental path where a revealable handle reached an export stream.

See also