Field metadata — describe() collection.describe() + fieldMeta + metadata ladder
| Subpath | @noy-db/hub (root export) — the seam @noy-db/ui binds to — see API reference |
| Type | Core feature — describe() and toJSONSchema() are methods on the always-loaded Collection class; fieldMeta and meta are plain collection/vault options. |
What it is
Every consumer that renders or exports a collection — a table header, an export pipeline, an API serializer, an AI agent building context — needs to know each field's display label, unit, and sensitivity. Without a shared source, every consumer ends up duplicating that label/unit/sensitivity logic, and the copies drift.
collection.describe() solves this by returning a normalised
CollectionDescription whose fields: DescribedField[] array merges every
per-field config channel into a single consumer-neutral record. Any consumer
reads from this one source instead of maintaining its own.
Descriptive, never prescriptive. DescribedField carries meaning
(label, semanticType, unit, sensitivity, aggregate, aliases,
displayFor) — not layout, not styling, not locale selection. Those stay
app-side.
Sync vs async
| Call | What it returns | When to use |
|---|---|---|
collection.describe() | CollectionDescription (sync, immediate) | Table headers, export spec, any render-time use |
collection.describe(opts) | Promise<CollectionDescription> | Need validator-derived exact types, zod-4 .meta(), or dynamic-dict label resolution |
The sync path is zero-store-I/O: no await, no store reads, no decryption.
It derives field types from the in-memory config (money→'number',
ref→'string'/'array', dictKey→'enum', others→'unknown').
The async path adds:
- Validator-derived
type/optional/constraintsviaderiveZodFields(lazyimport('zod')— never a static dep). - Zod-4
.meta()key extraction (label, description, semanticType, …). - Dynamic-dict label resolution:
{ resolveDictLabels: true }callsvault.dictionary(name).list()for each dynamic dictKey field and populatesdict.values[].label.
The fieldMeta channel
fieldMeta is the primary, validator-agnostic authoring surface. It is
declared as a collection option:
import { createNoydb, money, dictKey, ref } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({ store: toMemory(), user: 'alice', secret: 'a-strong-secret' })
const vault = await db.openVault('ledger')
const saleStatus = dictKey('saleStatus', ['draft', 'to_verify', 'paid'] as const)
type Sale = { id: string; saleDate: string; total: number; status: string; buyerId: string; notes?: string }
const sales = vault.collection<Sale>('sales', {
moneyFields: { total: money({ currency: 'EUR' }) },
dictKeyFields: { status: saleStatus },
refs: { buyerId: ref('buyers') },
fieldMeta: {
saleDate: { label: 'Date' },
total: { label: 'Total (€)', unit: '€' },
buyerId: { label: 'Buyer', sensitivity: 'pii', displayFor: 'buyerName' },
notes: { label: 'Internal notes', sensitivity: 'secret' },
},
})
FieldMeta members:
| Field | Type | Purpose |
|---|---|---|
label | string (required) | Human display name |
description | string? | Longer tooltip / context |
semanticType | SemanticType? | Domain hint: 'date', 'currency', 'email', 'vat', … |
unit | string? | Display unit: '€', '%', 'kg', … |
sensitivity | 'public' | 'pii' | 'secret' | Data-classification for redaction/masking |
aggregate | 'sum' | 'count' | 'distinct' | 'none' | Default aggregation |
aliases | readonly string[]? | Canonical search synonyms |
displayFor | string? | Entity pairing: 'buyerId' → displayFor: 'buyerName' |
widget | string? | Overrides the auto-derived widget hint (see the widget override in "describe() enhancements" below) |
group | string? | Card/section grouping hint for detail & form layouts (e.g. 'Identity', 'Amounts'). describe() still emits fields alphabetically — grouping is a hint for the consumer to apply |
order | number? | Relative ordering hint within (and across) groups; lower renders first |
Merge precedence
For each field, three tiers of metadata are merged in priority order:
channel (fieldMeta) > zod-4 .meta() (async path) > inferred-from-config
- channel — values from
collection({ fieldMeta: { … } }). Wins over everything. This is where the app author declares intent. - zod-4
.meta()— async path only. Keys recognized:label,description,unit,semanticType,sensitivity,aggregate,aliases,displayFor,group,order. (widgetis channel-only — zod.meta()cannot set it.) Unknown.meta()keys are silently ignored. - inferred-from-config — automatically derived from the collection's other
config:
moneyFields→semanticType:'currency',aggregate:'sum',type:'number'refs→semanticType:'entity',type:'string'(or'array'forrefArray)dictKeyFields→type:'enum',dict: { name, static, values[] }
Structural extras (money, dict, ref blocks) always come from config, not
from the channel.
Two-consumer pattern
Call describe() once; hand the result to every consumer:
const d = sales.describe()
d (CollectionDescription, using the sales collection declared above):
{
"collection": "sales",
"fields": [
{ "key": "buyerId", "type": "string", "optional": false, "label": "Buyer",
"semanticType": "entity", "sensitivity": "pii", "displayFor": "buyerName",
"ref": { "target": "buyers", "mode": "strict" }, "widget": "ref-select", "editable": true },
{ "key": "notes", "type": "unknown", "optional": false, "label": "Internal notes",
"sensitivity": "secret", "widget": "text", "editable": true },
{ "key": "saleDate", "type": "unknown", "optional": false, "label": "Date",
"widget": "text", "editable": true },
{ "key": "status", "type": "enum", "optional": false, "label": "Status",
"dict": { "name": "saleStatus", "static": false,
"values": [{ "value": "draft" }, { "value": "to_verify" }, { "value": "paid" }] },
"widget": "select", "editable": true },
{ "key": "total", "type": "number", "optional": false, "label": "Total (€)", "unit": "€",
"semanticType": "currency", "aggregate": "sum",
"money": { "mode": "fixed", "currency": "EUR", "scale": 2 }, "widget": "money", "editable": true }
],
"meta": { "label": "Sales" }
}
// Consumer A — table header row
const headers = d.fields.map((f) => f.label)
// → ['Buyer', 'Internal notes', 'Date', 'Status', 'Total (€)']
// Consumer B — export column spec (flag PII/secret for redaction)
const exportSpec = d.fields.map((f) => ({
key: f.key,
label: f.label,
redacted: f.sensitivity !== undefined && f.sensitivity !== 'public',
}))
// → [{ key:'buyerId', label:'Buyer', redacted:true },
// { key:'notes', label:'Internal notes', redacted:true },
// { key:'saleDate', label:'Date', redacted:false },
// { key:'status', label:'Status', redacted:false },
// { key:'total', label:'Total (€)', redacted:false }]
// Consumer C — AI agent context (structured field list, no raw records)
const agentContext = d.fields.map((f) => ({
key: f.key,
type: f.type,
...(f.sensitivity === 'secret' ? { omit: true } : {}),
}))
// → [{ key:'buyerId', type:'string' }, { key:'notes', type:'unknown', omit:true },
// { key:'saleDate', type:'unknown' }, { key:'status', type:'enum' }, { key:'total', type:'number' }]
CollectionDescription is a frozen plain object — pass it freely.
Scope boundary
describe() is descriptive, not prescriptive:
- Layout order → app-side (describe() returns alphabetical by key)
- Locale selection → app-side (staticDict can surface one locale via
displayLocale; the choice belongs to the caller) - Rendering / formatting → app-side
- Validation / write enforcement → schema / guards, not describe()
Key invariants
- Sync = zero store I/O. The sync overload reads only in-memory config assembled at collection registration time. It never calls any store method.
- Descriptive not prescriptive. No layout, styling, or active-locale
selection in
DescribedField. - Merge precedence is fixed: channel > zod-4
.meta()> inferred. Application code can rely onfieldMetaalways winning. - Validator-agnostic. Works without zod; non-zod validators get structural
type inference from config only (sync path) or an empty
zodFieldsmap (async path) — no error. - fieldMeta key guard — and since
0.7.0-pre.14it usually fires fromvault.collection(). An unknownfieldMetakey throwsFieldMetaUnknownFieldError(noy-db#1253).Breaking if you catch arounddescribe(). For a validator whose fields read synchronously — a plain Zod object and most real schemas — the error now arrives fromvault.collection(). The consequence is the part that bites: atrywrapping only thedescribe()call no longer catches it at all, so the error escapes the handler written for it. A test suite that constructs the collection outside itstrystarts erroring rather than failing with a clear message, which reads as "the release broke our tests" rather than "the guard moved."Catch around thevault.collection()call — or around both, and report which surface rejected, which turns the relocation into a changed label rather than a break.Measured on the published0.7.0-pre.14: withfieldMeta: { amonut: … }against a schema declaringamount,vault.collection()throws anddescribe()is never reached; the correct key still carriessensitivity: 'pii'.
The describe-time check is kept, not moved, because knowability has three tiers rather than two: a name declared in the config itself needs no schema at all; a validator whose fields read synchronously is checkable at registration; fields that exist only after async derivation are checkable only atdescribe(). Hoisting the check outright would have moved it earlier for some collections and removed it for others. The sync path used to skip the check, which was silent in both directions on a PDPA surface: the typo'd key produced a phantom field carrying thepiimarking, and the real field went out undescribed. Measured on0.7.0-pre.11:fieldMeta: { amonut: … }against a schema declaringamountthrows fromdescribe()— thecollection()call itself does not — while the correct key carriessensitivity: 'pii'onto the described field.
The check still stays silent when no validator is attached, and that is deliberate: a collection typed only by its TypeScript generic has real fields the hub cannot enumerate, so refusing there would reject correct code.
It also sees through schema wrappers, on both Zod majors, since0.7.0-pre.13(noy-db#1262 —pre.12covered refinements only). The rule is follow the output side: these keys describe the parsed record, so a wrapper matters only if it changes the parsed shape. Measured against the published0.7.0-pre.14on both majors —zod@3.25.76andzod@4.4.3, identical results:schema typo'd fieldMetakeyz.object({ … })throws .refine(…)/.superRefine(…)throws z.preprocess(fn, z.object({ … }))throws .transform(…)silent, deliberately
(Both majors install cleanly from0.7.0-pre.15;0.7.0-pre.14alone refuses Zod 3 at install — see persisted schemas → limits.).transform()replaces the output, so the inner keys would describe a record that is never stored. It andpreprocessare one string apart and mean opposite things —preprocessrewrites the input and then parses with the inner schema, so the parsed keys are the inner schema's.
Public API surface (@noy-db/hub)
import { createNoydb, money, staticDict, ref } from '@noy-db/hub'
import type { FieldMeta, SemanticType, CollectionDescription, DescribedField, DescribeOptions } from '@noy-db/hub'
// collection option
const coll = vault.collection('name', {
fieldMeta: Record<string, FieldMeta>
})
// sync
const d: CollectionDescription = coll.describe()
// async (validator types + dynamic dict labels)
const d2: CollectionDescription = await coll.describe({ resolveDictLabels: true })
A consumer that only needs the output types imports them from @noy-db/hub — they are
type-only, so they cost nothing at runtime:
import type {
CollectionDescription,
DescribedField,
DescribeOptions,
CollectionMeta,
FieldMeta,
SemanticType,
} from '@noy-db/hub'
This is the same surface @noy-db/ui and @noy-db/ui-nuxt bind to. They once came from a
narrow @noy-db/hub/ui seam, but that subpath had no importers and was pruned in
noy-db#866 — see Ports.
describe() enhancements — i18n, widget, editable
The metadata ladder build added three fields to DescribedField:
i18n?: { locales?: readonly string[]; densify?: boolean }
Present on fields declared with i18nText(). Exposes the configured locale set
and the densifyOnWrite flag so consumers know the field is multi-locale.
widget?: string
Derived from semanticType + type, overridable via fieldMeta.widget:
| Condition | Derived widget |
|---|---|
semanticType: 'date' or 'datetime' | 'date' |
semanticType: 'currency' (money) | 'money' |
semanticType: 'entity' (ref) | 'ref-select' |
dict block present | 'select' |
type: 'boolean' | 'checkbox' |
type: 'number' (no matching semanticType) | 'number' |
semanticType: 'percent' | 'number' |
semanticType: 'url' | 'url' |
semanticType: 'email' | 'email' |
| else | 'text' |
Priority: fieldMeta.widget override > semanticType > dict presence >
type. A plain z.number() field with no semanticType, no .meta(), and
no money/dict config resolves to type: 'number' and therefore widget
'number', not 'text'.
Override: fieldMeta: { amount: { label: 'Amount', widget: 'currency-input' } }.
editable: boolean
false for computed fields, the id field, and provenance-stamped fields;
true otherwise. Data editors use this to render read-only cells without
knowing the collection's internals.
dumpSchema() collection-level config block
CollectionDescriptor.config surfaces the live collection's configuration
options for structural-audit tooling. It is populated by dumpSchema() when the
collection is live-declared; omitted for bundle-reconstructed collections where
options aren't available.
config?: {
textIndexes?: readonly string[]
textIndexPersist?: boolean
embeddings?: { source: string | readonly string[]; dim: number; model?: string }
i18nFields?: readonly string[]
crdt?: string
provenance?: boolean
archive?: boolean // presence flag (the predicate is code)
tiers?: readonly number[]
tierMode?: string
perRecordKeys?: boolean
history?: boolean // presence flag
schemaUpdate?: readonly string[] // strategy names
}
Function-valued options surface as booleans (presence). conflictPolicy is
consumed at construction and has no retained state to surface.
Devtools schema view
in-devtools snapshot()
InspectorCollection carries all three rung outputs:
meta?: CollectionMeta— collection-level label/descriptiondescribed?: readonly DescribedField[]— rich field list (label/type/widget/sensitivity/i18n/editable)config?: CollectionConfig— structural config strip
InspectorSnapshot gains meta?: VaultMeta for the vault-level label.
Terminal UI (@noy-db/in-devtools-tui)
The structure view renders the metadata ladder compactly:
VaultList — shows meta.label (vaultId) for the active vault when a label
is declared; falls back to the vault id.
CollectionList — shows meta.label (name) for each collection when a label
is declared; falls back to the collection name.
DetailPane (drilled schema tab):
- Heading:
meta.label (name)or justname - If
describedis present: one line per field —Label (key: type) [pii] [i18n] [ro] <widget> - If
describedis absent (no live describe): rawkey: typefromfields - Config strip (dimmed):
config: idx:2 emb:1536d i18n:3 crdt:lww provenance archive
Markers used:
| Marker | Meaning |
|---|---|
[pii] | sensitivity: 'pii' |
[secret] | sensitivity: 'secret' |
[i18n] | i18n block present |
[ro] | editable: false |
<widget> | widget hint, e.g. <money> or <ref-select> |
See also
- classified — the serialized
classifiedprojection contract that surfaces alongsideDescribedField - computed — computed fields, which
describe()markseditable: false - crdt —
i18nText()fields, whichdescribe()surfaces via thei18nblock - aggregate — the query-DSL reducers (
sum,count, …) thatDescribedField.aggregatenames as a display hint - Metadata ladder — collection + vault-level meta rungs above field meta
- Metadata riders — toJSONSchema(), dictKey inline labels, devtools PII masking
- Core 01 — Vault and collections — what a collection is and how it's opened
- Proven by showcase
126-describe-field-metadata.