stable

persisted-schemas

Persisted JSON Schema: write an encrypted snapshot of your validator into the vault so tools without your live validator can still see the shape.
Subpath@noy-db/hub (root export)
TypeCore 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 own toJSONSchema()) are both supported and auto-detected, and derive a real jsonSchema body. PersistedSchemaKind also has 'Valibot' / 'ArkType' / 'Effect' members for future per-library detection, but today any non-Zod Standard Schema validator is reported as kind: '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; call await 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: true on a Zod 3 validator without zod-to-json-schema installed throws with an install hint; Zod 4 schemas use Zod's built-in converter and need no extra package.
  • zod itself is an optional peer of @noy-db/hub from 0.7.0-pre.14 (noy-db#1227). Hub previously bundled zod@4.4.3 into dist/ at a build-frozen version, declared in no manifest field — so npm ls zod showed 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: toJSONSchema reads 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.14 is the one release to skip if you are on Zod 3. It declared the peer as ^4.0.0 alone, and a published manifest cannot be amended, so that version refuses the pair under npm permanently. 0.7.0-pre.15 corrects the declaration. Measured:
    result
    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 only
    Zod 3 was never unsupported; only pre.14's declaration said otherwise, and the runtime behaviour was unaffected throughout. If you meet that error, the fix is to move off pre.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