experimental

Field metadata — describe() collection.describe() + fieldMeta + metadata ladder

How collection.describe() merges fieldMeta, collection/vault meta, and inferred config into one normalized contract for UI, export, and AI consumers.
Subpath@noy-db/hub (root export) — the seam @noy-db/ui binds to — see API reference
TypeCore 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

CallWhat it returnsWhen 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/constraints via deriveZodFields (lazy import('zod') — never a static dep).
  • Zod-4 .meta() key extraction (label, description, semanticType, …).
  • Dynamic-dict label resolution: { resolveDictLabels: true } calls vault.dictionary(name).list() for each dynamic dictKey field and populates dict.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:

FieldTypePurpose
labelstring (required)Human display name
descriptionstring?Longer tooltip / context
semanticTypeSemanticType?Domain hint: 'date', 'currency', 'email', 'vat', …
unitstring?Display unit: '€', '%', 'kg', …
sensitivity'public' | 'pii' | 'secret'Data-classification for redaction/masking
aggregate'sum' | 'count' | 'distinct' | 'none'Default aggregation
aliasesreadonly string[]?Canonical search synonyms
displayForstring?Entity pairing: 'buyerId' → displayFor: 'buyerName'
widgetstring?Overrides the auto-derived widget hint (see the widget override in "describe() enhancements" below)
groupstring?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
ordernumber?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
  1. channel — values from collection({ fieldMeta: { … } }). Wins over everything. This is where the app author declares intent.
  2. zod-4 .meta() — async path only. Keys recognized: label, description, unit, semanticType, sensitivity, aggregate, aliases, displayFor, group, order. (widget is channel-only — zod .meta() cannot set it.) Unknown .meta() keys are silently ignored.
  3. 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' for refArray)
    • 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 on fieldMeta always winning.
  • Validator-agnostic. Works without zod; non-zod validators get structural type inference from config only (sync path) or an empty zodFields map (async path) — no error.
  • fieldMeta key guard — and since 0.7.0-pre.14 it usually fires from vault.collection(). An unknown fieldMeta key throws FieldMetaUnknownFieldError (noy-db#1253).
    Breaking if you catch around describe(). For a validator whose fields read synchronously — a plain Zod object and most real schemas — the error now arrives from vault.collection(). The consequence is the part that bites: a try wrapping only the describe() call no longer catches it at all, so the error escapes the handler written for it. A test suite that constructs the collection outside its try starts erroring rather than failing with a clear message, which reads as "the release broke our tests" rather than "the guard moved."Catch around the vault.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 published 0.7.0-pre.14: with fieldMeta: { amonut: … } against a schema declaring amount, vault.collection() throws and describe() is never reached; the correct key still carries sensitivity: '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 at describe(). 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 the pii marking, and the real field went out undescribed. Measured on 0.7.0-pre.11: fieldMeta: { amonut: … } against a schema declaring amount throws from describe() — the collection() call itself does not — while the correct key carries sensitivity: '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, since 0.7.0-pre.13 (noy-db#1262 — pre.12 covered 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 published 0.7.0-pre.14 on both majors — zod@3.25.76 and zod@4.4.3, identical results:
    schematypo'd fieldMeta key
    z.object({ … })throws
    .refine(…) / .superRefine(…)throws
    z.preprocess(fn, z.object({ … }))throws
    .transform(…)silent, deliberately

    (Both majors install cleanly from 0.7.0-pre.15; 0.7.0-pre.14 alone 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 and preprocess are one string apart and mean opposite things — preprocess rewrites 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:

ConditionDerived 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/description
  • described?: 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 just name
  • If described is present: one line per field — Label (key: type) [pii] [i18n] [ro] <widget>
  • If described is absent (no live describe): raw key: type from fields
  • Config strip (dimmed): config: idx:2 emb:1536d i18n:3 crdt:lww provenance archive

Markers used:

MarkerMeaning
[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 classified projection contract that surfaces alongside DescribedField
  • computed — computed fields, which describe() marks editable: false
  • crdt — i18nText() fields, which describe() surfaces via the i18n block
  • aggregate — the query-DSL reducers (sum, count, …) that DescribedField.aggregate names 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.