introspection
| Subpath | @noy-db/hub (root export) |
| Type | Core feature — dumpSchema() and introspect() are methods on the always-loaded Vault class. |
What it is
A vault is a self-contained, encrypted namespace of collections — but once an app has more than a couple of collections, "what does this vault actually contain?" stops being something you can answer by reading source. vault.dumpSchema() answers it programmatically: it returns a structured snapshot — every collection's fields (with type info and where that type info came from), foreign-key references, materialized/overlay views, derivations, and which optional services are switched on — as one plain JavaScript object.
Two related surfaces live here:
vault.dumpSchema()— the full snapshot described above. With{ withStats: true }it additionally walks every collection's stored records to compute per-collection record counts, byte totals, and oldest/newest timestamps.vault.introspect()— a much cheaper read: collection names + doc counts, configured guards, materialized views, schema-update strategies, and the currently unlocked user's own grants. Onelist()call per collection, no decryption, no field data.
The noy-db describe command (formerly noydb describe) builds its human-readable audit output entirely from a dumpSchema() snapshot; application code can call the same method directly for admin dashboards, migration tooling, or CI schema-drift checks.
Both methods are always available on an opened vault — there is no with*() strategy to opt into, and the walker behind dumpSchema() is loaded lazily on first call, so it costs nothing in your bundle until you actually call it.
Setup
Nothing to wire. dumpSchema() and introspect() are plain methods on every Vault you get back from openVault():
import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'orange river candle mountain silver fox',
})
const vault = await db.openVault('acme')
Usage
Full snapshot
import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
import { z } from 'zod'
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'orange river candle mountain silver fox',
})
const vault = await db.openVault('acme')
vault.collection('clients', {
schema: z.object({ id: z.string(), name: z.string() }),
})
vault.collection('invoices', {
schema: z.object({ id: z.string(), amount: z.number(), status: z.enum(['draft', 'paid']) }),
})
await vault.collection('clients').put('c1', { id: 'c1', name: 'Acme Co' })
await vault.collection('invoices').put('i1', { id: 'i1', amount: 100, status: 'draft' })
const snap = await vault.dumpSchema()
console.log(snap)
{
"_noydb_snapshot": 1,
"vault": "acme",
"emittedAt": "2026-07-08T12:00:00.000Z",
"subsystems": { "guards": false, "derivations": false, "materializedViews": false, "overlayViews": false },
"collections": {
"clients": {
"fields": {
"id": { "type": "string", "source": "live-validator" },
"name": { "type": "string", "source": "live-validator" }
},
"indexes": [],
"refs": {},
"validator": { "kind": "Zod", "source": "live-validator" }
},
"invoices": {
"fields": {
"id": { "type": "string", "source": "live-validator" },
"amount": { "type": "number", "source": "live-validator" },
"status": { "type": "enum", "source": "live-validator", "constraints": { "values": ["draft", "paid"] } }
},
"indexes": [],
"refs": {},
"validator": { "kind": "Zod", "source": "live-validator" }
}
},
"materializedViews": {},
"overlayViews": {},
"derivations": {}
}
clients and invoices were declared with a live Zod schema but no persisted schema, so every field reports source: "live-validator" — derived in-process from the attached validator. Fields validated by a persisted _schemas/<collection> envelope instead report source: "persisted".
With record/byte stats
const withStats = await vault.dumpSchema({ withStats: true })
console.log(withStats.collections.invoices?.stats)
{
"records": 1,
"bytes": 87,
"bytesAvg": 87,
"bytesMin": 87,
"bytesMax": 87,
"oldest": "2026-07-08T12:00:00.000Z",
"newest": "2026-07-08T12:00:00.000Z"
}
Byte counts are illustrative — they measure the encrypted envelope, so exact numbers depend on your data. oldest/newest come from each envelope's _ts; both are the empty string for an empty collection.
Lightweight read
For a cheap "what's in this vault, and what can I see" check, skip dumpSchema() and call introspect() — no decryption, no field data:
const overview = await vault.introspect()
console.log(overview)
{
"collections": [
{ "name": "clients", "docCount": 1 },
{ "name": "invoices", "docCount": 1 }
],
"guards": [],
"materializedViews": [],
"schemaUpdate": [],
"grants": [
{ "collection": "clients", "permission": "rw" },
{ "collection": "invoices", "permission": "rw" }
]
}
grants reflects what the unlocked user can actually access — the collections they hold a decryption key for, at the permission level from their keyring (owners/admins default to 'rw').
API reference
vault.dumpSchema(opts?)
dumpSchema(opts?: DumpSchemaOptions): Promise<VaultSchemaSnapshot> — walks the vault's collections (and, with withStats, its stored records) and returns a VaultSchemaSnapshot.
DumpSchemaOptions:
| Option | Type | Default | Purpose |
|---|---|---|---|
withStats | boolean | false | Also compute per-collection stats (record count, byte totals, oldest/newest) and an internal block for non-empty internal collections. Reads every record — see Limits. |
sampleSize | number | 50 | Reserved for a future records-sampling fallback for collections with no schema. Currently has no effect — see Limits. |
VaultSchemaSnapshot:
| Field | Type | Notes |
|---|---|---|
_noydb_snapshot | 1 | Snapshot format version. |
vault | string | The vault's name. |
emittedAt | string | ISO-8601 timestamp of the call. |
subsystems | Record<string, boolean> | Opt-in matrix: guards, derivations, materializedViews, overlayViews. |
aclRoles? | string[] | Reserved for a future ACL-role listing; not populated by the current walker. |
meta? | VaultMeta | Present only when the vault was opened with a meta option — see field-metadata. |
collections | Record<string, CollectionDescriptor> | Every non-internal collection, alphabetical by name. |
materializedViews | Record<string, MaterializedViewDescriptor> | |
overlayViews | Record<string, OverlayViewDescriptor> | |
derivations | Record<string, DerivationDescriptor> | |
internal? | Record<string, InternalCollectionStats> | Only with withStats: true, and only for internal collections (_keyring, _ledger, _meta, _schemas, _deltas) that have at least one record. |
CollectionDescriptor:
| Field | Type | Notes |
|---|---|---|
fields | Record<string, FieldDescriptor> | Empty object when no schema info could be found. |
indexes | Array<{ fields: string[]; unique?: boolean }> | Reserved — currently always [], see Limits. |
refs | Record<string, { target: string; mode: 'strict' | 'warn' | 'cascade' }> | Foreign-key declarations from the collection's refs option. |
validator? | { kind: 'Zod' | 'Valibot' | 'ArkType' | 'Effect' | 'Unknown'; source: 'persisted' | 'live-validator' } | Present only when a schema was found. |
stats? | CollectionStats | Only with withStats: true. |
meta? | CollectionMeta | Only when the collection was declared with a meta option. |
config? | CollectionConfig | Only for live-declared collections; see the full field list in field-metadata → dumpSchema() collection-level config block. |
FieldDescriptor:
| Field | Type | Notes |
|---|---|---|
type | string | One of 'string' | 'number' | 'boolean' | 'enum' | 'object' | 'array' | 'null' | 'opaque'. |
source | FieldSource | 'persisted' | 'live-validator' | 'sampled' | 'unknown' — see Limits for why 'sampled' never actually appears today. |
constraints? | Record<string, unknown> | minLength, maxLength, pattern, format, minimum, maximum, gt, lt, or values (enum/literal members). |
optional? | boolean | true when the field is not required. |
references? | string | "<collection>.id" when the field is declared as a ref. |
CollectionStats: { records, bytes, bytesAvg, bytesMin, bytesMax, oldest, newest } — all number except oldest/newest (string, ISO-8601, empty when the collection has no records).
MaterializedViewDescriptor: { sources: string[], groupBy?: string[], aggregate?: Record<string, string>, refresh: string, stats?: CollectionStats } — aggregate values are summarized as "op(field)" (e.g. "sum(amount)").
OverlayViewDescriptor: { base: string, overlay: string }.
DerivationDescriptor: { source: string, outputs: string[] }.
InternalCollectionStats: { records: number, bytes: number }.
vault.introspect()
introspect(): Promise<SchemaIntrospection> — no options.
| Field | Type | Notes |
|---|---|---|
collections | Array<{ name: string; docCount: number }> | Union of collections registered this session and collections with persisted records, sorted by name. |
guards | Array<{ collection: string; count: number }> | From the guard registry, when guards is opted in. |
materializedViews | Array<{ name: string; sourceCollections: string[] }> | From the materialized-view registry, when opted in. |
schemaUpdate | Array<{ collection: string; strategies: string[] }> | Registered schema-update strategy names per collection. |
grants | Array<{ collection: string; permission: Permission }> | Collections the unlocked user holds a key for, with their permission level. |
Limits & gotchas
withStats: truereads every record. It callsadapter.get()once per record in every collection to compute byte sizes and timestamps — cost scales with total record count. Reserve it for periodic audits or CLI use, not hot request paths.- Sampling is declared but not implemented.
sampleSizeis accepted and defaults to50, but the sampling fallback for collections with no persisted or live schema is not built yet: such collections simply reportfields: {}and novalidator, regardless ofsampleSize.'sampled'exists as aFieldSourcevalue butdumpSchema()never emits it today. indexesis always[]. The shape is reserved onCollectionDescriptorbut the current walker does not populate it.aclRolesis never set. The field is reserved onVaultSchemaSnapshotfor a future ACL-role listing, but nothing in the current walker assigns it.MaterializedViewDescriptor.statsis alwaysundefined. The field exists on the type for a future record-count walk over materialized views; today's walker never sets it.- Views and derivations reflect the current process.
materializedViews,overlayViews, andderivationsare read from the vault's live in-memory registries, not from persisted storage — a fresh process must re-register them (e.g. via the samewith*()opt-ins) before they show up in a snapshot. - A missing key silently degrades, it doesn't throw. If the unlocked keyring has no decryption key for a collection,
dumpSchema()falls back from the persisted schema to the live validator (or to empty fields) instead of throwing — it never surfaces a permission error. configneeds a live collection. It's populated from the in-processCollectionobject, so a collection that was never touched viavault.collection()this session (e.g. reconstructed purely from a.noydbbundle) gets noconfigblock.
See also
- field-metadata —
collection.describe(),CollectionMeta/VaultMeta, and the fulldumpSchema()configblock field list. - persisted-schemas — the encrypted
_schemas/<collection>envelope that producessource: 'persisted'field data. - schema-update — the strategies whose names appear in
introspect().schemaUpdateandconfig.schemaUpdate. - guards · materialized-views — the subsystems summarized in
subsystemsandintrospect().guards/.materializedViews. - overlay-views · derivations — the
OverlayViewDescriptor/DerivationDescriptorshapes returned bydumpSchema(). - Services overview · Orientation map