persisted-schemas
| Subpath | @noy-db/hub (root export) |
| Type | Core feature — activated per collection by the persistJsonSchema: true option. |
What it does
Your collection's schema (a Zod object, say) lives in your application code — it's not visible to anything that only has the vault's data, like a bundle file handed to a teammate or the noydb describe CLI. Persisted JSON Schema closes that gap: opt a collection in and, on every vault open, the hub converts your validator to JSON Schema and writes it into the vault as an encrypted record, next to the collection's own data.
The record lives at _schemas/<collection> and is encrypted with the same per-collection key (DEK) that encrypts the collection's records — so anyone who can unlock the collection's data can also read its schema, and no one else. Writing is skip-aware: the hub hashes the freshly-derived JSON Schema and compares it to what's already stored, so an unchanged schema costs one read and no write.
This is always on within its layer — there's no with*() factory to add. The opt-in is the persistJsonSchema: true collection option.
Setup
import { z } from 'zod'
import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
const store = toMemory()
const db = await createNoydb({
store,
user: 'alice',
secret: 'a-strong-secret',
})
const vault = await db.openVault('acme')
const InvoiceSchema = z.object({
id: z.string(),
amount: z.number(),
})
const invoices = vault.collection('invoices', {
schema: InvoiceSchema,
persistJsonSchema: true,
})
Two things are required: a schema (v0 derives real JSON Schema only from Zod — see Limits & gotchas) and persistJsonSchema: true. Default is false; omitting the option writes nothing.
The write is asynchronous and does not block vault.collection() — it's queued in the background against the DEK derivation and the store write, so normal application code never waits on it. Tests (or anything that must observe the write immediately, as in the examples below) can drain the queue explicitly:
await vault._drainPendingSchemaWrites()
Usage
First open — the envelope is written
await vault._drainPendingSchemaWrites()
const stored = await store.get('acme', '_schemas', 'invoices')
{
"_noydb": 1,
"_v": 1,
"_ts": "2026-07-08T10:00:00.000Z",
"_iv": "…",
"_data": "…"
}
The record on the wire is a standard encrypted envelope — _iv/_data hide the payload from anything that doesn't hold the collection's DEK. Decrypted, _data holds:
{
"_noydb_schema": 1,
"kind": "Zod",
"jsonSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"amount": { "type": "number" }
},
"required": ["id", "amount"]
},
"hash": "3f2e...c9",
"derivedAt": "2026-07-08T10:00:00.000Z"
}
Reopening with an unchanged schema — no write
vault.collection() caches by name on that Vault instance — calling it again for 'invoices' on the same vault object is a no-op, it doesn't re-derive anything. To observe the hash-skip you need a genuinely fresh session: a new Noydb (via createNoydb) opening the same vault name against the same store, exactly as a second process or a later run of your app would:
// a fresh session against the same store — same user/secret, new Noydb instance:
const db2 = await createNoydb({ store, user: 'alice', secret: 'a-strong-secret' })
const vault2 = await db2.openVault('acme')
vault2.collection('invoices', { schema: InvoiceSchema, persistJsonSchema: true })
await vault2._drainPendingSchemaWrites()
const after = await store.get('acme', '_schemas', 'invoices')
after._v // → 1 (unchanged — the derived hash matched, so the write was skipped)
The hub re-derives the JSON Schema, hashes it, and compares the hash to the one already stored. Equal hash means equal shape, so it skips the write entirely — reopening a vault with hundreds of persisted-schema collections doesn't churn every one of them on every startup.
Changing the schema — a new version is written
Same pattern — a fresh Noydb/vault against the same store — but this time the collection's schema has a new field:
const db3 = await createNoydb({ store, user: 'alice', secret: 'a-strong-secret' })
const vault3 = await db3.openVault('acme')
vault3.collection('invoices', {
schema: z.object({ id: z.string(), amount: z.number(), note: z.string() }), // shape changed
persistJsonSchema: true,
})
await vault3._drainPendingSchemaWrites()
const bumped = await store.get('acme', '_schemas', 'invoices')
bumped._v // → 2
A non-Zod validator — a stub envelope, not a JSON Schema
If the collection's schema isn't Zod (Valibot, ArkType, Effect Schema — any other Standard Schema validator), the hub still writes a record, but without a derived body:
import { derivePersistedSchema } from '@noy-db/hub'
// a Standard Schema v1 validator that isn't Zod (shape a real Valibot/ArkType/
// Effect Schema value carries — the `~standard.vendor` tag is what's detected)
const nonZodValidator = { '~standard': { version: 1, vendor: 'valibot', validate: () => ({}) } }
const stub = await derivePersistedSchema(nonZodValidator)
{
"_noydb_schema": 1,
"kind": "Unknown",
"jsonSchema": null,
"hash": null,
"reason": "derivation not yet supported for kind=Unknown (v0 supports Zod only)",
"derivedAt": "2026-07-08T10:00:00.000Z"
}
kind reports 'Unknown' for anything the hub can't fingerprint as Zod today (see Limits & gotchas for what kind can be). Because there's no hash to compare, a later reopen skips the write only when kind hasn't changed — a kind change is the only signal available without a body.
API reference
All exports below come from the @noy-db/hub root.
persistJsonSchema
vault.collection<T>(name, {
schema,
persistJsonSchema?: boolean, // default: false
})
When true and schema is set, the hub derives, hashes, and (if changed) writes the _schemas/<name> envelope on every vault open.
derivePersistedSchema(validator)
function derivePersistedSchema(validator: unknown): Promise<PersistedSchemaEnvelope>
Converts a validator to a PersistedSchemaEnvelope without touching storage — the pure derive step persistJsonSchema runs internally. Useful for previewing what would be persisted, or for computing a JSON Schema outside a vault entirely.
import { derivePersistedSchema } from '@noy-db/hub'
const env = await derivePersistedSchema(z.object({ id: z.string() }))
env.hash // → 64-char hex SHA-256 of the canonicalized JSON Schema
isZodSchema(value)
function isZodSchema(value: unknown): boolean
true when value is a Zod schema (Zod v3 or v4). Used internally to pick the derivation path; exported because it's a generally useful duck-type check.
PersistedSchemaEnvelope / PersistedSchemaKind (types)
type PersistedSchemaKind = 'Zod' | 'Valibot' | 'ArkType' | 'Effect' | 'Unknown'
interface PersistedSchemaEnvelope {
readonly _noydb_schema: 1
readonly kind: PersistedSchemaKind
readonly jsonSchema: object | null // null when kind isn't Zod
readonly hash: string | null // null when jsonSchema is null
readonly reason?: string // populated when jsonSchema is null
readonly derivedAt: string // ISO-8601
readonly classified?: ClassifiedMarker // config-drift marker, see classified.md
readonly satellite?: PairingMarker // satellite pairing marker, see satellites docs
}
The decrypted shape of the _schemas/<collection> record's payload. kind is a detected label — v0 only ever derives a real jsonSchema for 'Zod'; every other value in the union writes a stub with jsonSchema: null and a reason. classified and satellite are independent of persistJsonSchema — they're written into this same shared record by classified collections and satellite declarations respectively, and a schema-persisting write preserves whichever of them is already stored rather than dropping it.
SCHEMAS_COLLECTION
const SCHEMAS_COLLECTION = '_schemas'
The reserved collection name the envelopes are stored under, exported so callers don't have to hardcode the string.
loadPersistedSchema(store, vault, collection, dek) / savePersistedSchema(store, vault, collection, dek, payload)
function loadPersistedSchema(
store: NoydbStore, vault: string, collection: string, dek: EnclaveKey,
): Promise<PersistedSchemaEnvelope | undefined>
function savePersistedSchema(
store: NoydbStore, vault: string, collection: string, dek: EnclaveKey,
payload: PersistedSchemaEnvelope, expectedVersion?: number,
): Promise<void>
Low-level read/write against the raw storage adapter and a resolved DEK, rather than a Vault/Collection handle. dek is an opaque EnclaveKey token — the hub's internal per-collection key type, not part of the public surface exported from @noy-db/hub — obtainable only from inside the hub itself. These two functions are documented here for completeness (they're the layer collection.describe() and the noydb describe bundle-inspection command use internally to read a schema from a store or bundle that has no live validator in memory), not as plumbing you wire up yourself; persistJsonSchema: true is the opt-in that matters for application code. loadPersistedSchema returns undefined when nothing has been written yet, or when decryption fails (wrong DEK, corrupt record).
persistSchemaIfNeeded(opts) / PersistSchemaResult
function persistSchemaIfNeeded(opts: {
store: NoydbStore
vault: string
collectionName: string
validator: unknown
dek: EnclaveKey
strategies?: readonly SchemaUpdateStrategy[]
}): Promise<PersistSchemaResult>
interface PersistSchemaResult {
readonly written: boolean // true when a fresh envelope was written
readonly skipped: boolean // true when the stored envelope already matched
readonly envelope: PersistedSchemaEnvelope
readonly decision?: UpdateDecision // present when strategies ran, see schema-update.md
}
The derive → hash-compare → skip-or-write orchestration that vault.collection({ persistJsonSchema: true }) calls internally. Exported for advanced callers that manage their own store/DEK plumbing outside a Vault. strategies is the opt-in schema-update plugs into: when the derived schema changed and a stored baseline exists, each strategy gets a chance to allow, reject, or run a coordinated cutover before the write happens — decision reports which. Omit strategies (or pass none) and every change is allowed, matching the plain skip-or-write behavior described above.
Limits & gotchas
- v0 detects Zod vs. everything else — not each validator family individually. Zod 3 (via the optional peer-dep
zod-to-json-schema) and Zod 4 (via Zod's owntoJSONSchema()) are both supported and auto-detected, and derive a realjsonSchemabody.PersistedSchemaKindalso has'Valibot'/'ArkType'/'Effect'members for future per-library detection, but today any non-Zod Standard Schema validator is reported askind: 'Unknown'and always gets a stub envelope (jsonSchema: null), never a derived JSON Schema body. - The write is asynchronous and not awaited by
vault.collection(). It's queued against a background drain; callawait vault._drainPendingSchemaWrites()when a test or script needs to observe the write before reading_schemas/<collection>back. - Hash-skip only inspects the derived JSON Schema, not your Zod source. Renaming a field or changing a constraint changes the derived shape (and the hash) and triggers a write; comments or code reordering in your schema definition do not.
- Encrypted with the collection's own key. There is no separate "schema visibility" permission — whoever can decrypt the collection's records can decrypt its schema, and no one else can.
- Requires Zod's optional peer-dep for Zod 3 schemas. Calling
persistJsonSchema: trueon a Zod 3 validator withoutzod-to-json-schemainstalled throws with an install hint; Zod 4 schemas use Zod's built-in converter and need no extra package. zoditself is an optional peer of@noy-db/hubfrom0.7.0-pre.14(noy-db#1227). Hub previously bundledzod@4.4.3intodist/at a build-frozen version, declared in no manifest field — sonpm ls zodshowed nothing while that copy was reachable, SBOM tooling missed it, and a consumer could not remediate a zod advisory by bumping their own. Declaring it is also the correctness fix:toJSONSchemareads a schema's internals, and the schema is built by your zod, so there is now one zod rather than two with undetected skew.The range is^3.0.0 || ^4.0.0— both majors, and the peer is optional, so a project with no zod at all installs fine and never reaches the converter.⛔0.7.0-pre.14is the one release to skip if you are on Zod 3. It declared the peer as^4.0.0alone, and a published manifest cannot be amended, so that version refuses the pair under npm permanently.0.7.0-pre.15corrects the declaration. Measured:Zod 3 was never unsupported; onlyresult npm i @noy-db/hub@0.7.0-pre.15 zod@3fine npm i @noy-db/hub@0.7.0-pre.15 zod@4fine npm i @noy-db/hub@0.7.0-pre.14 zod@3ERESOLVE— that release onlypre.14's declaration said otherwise, and the runtime behaviour was unaffected throughout. If you meet that error, the fix is to move offpre.14— not to downgrade a plan onto zod 4, and not--legacy-peer-deps, which would have suppressed a true statement about a real manifest rather than working around a false one.
See also
- Enclave — the DEK and envelope format persisted-schema envelopes reuse
- introspection —
collection.describe()reads the persisted envelope as a fallback when no live validator is in memory - schema-update — strategies that react to a changed persisted schema (additive-only, locked, coordinated cutover)
- validation — planned richer runtime validators; today the JSON Schema derived and persisted here is the closest thing to a schema-level validation surface
- classified — a config-drift marker that shares the same
_schemas/<collection>record - satellites — the satellite pairing marker that also shares the
_schemas/<collection>record - Services overview · Orientation map