stable

schema-update

Detect a collection's schema changing between vault opens — allow, reject, lock, or run a coordinated cutover that migrates records before unblocking writes.
Subpath@noy-db/hub (root export)
TypeCore feature — activated by the schemaUpdate collection option (needs persistJsonSchema: true as the baseline).

What it does

Your application's schema evolves — a field gets added, renamed, or its type changes — but a vault's existing records were encrypted and stored under the old shape. schema-update is how the hub reacts when it detects that drift: on every vault open it compares the freshly-derived schema against the one it persisted last time (see persisted-schemas, which this feature builds on and requires), classifies the difference, and runs it past an ordered list of strategies you register per collection. Each strategy either lets the change through, rejects it (blocking writes until fixed), or — for coordinatedCutover — schedules a one-time bulk migration of the collection's existing records.

This is always on within its layer — there's no with*() factory to add. The opt-in is two collection options together: persistJsonSchema: true (the baseline to detect against) and schemaUpdate: [...] (the strategies to run when a change is detected).

Setup

import { z } from 'zod'
import { createNoydb, additiveOnly } 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 invoices = vault.collection('invoices', {
  schema: z.object({ id: z.string(), amount: z.number() }),
  persistJsonSchema: true,
  schemaUpdate: [additiveOnly()],
})

schemaUpdate is ignored unless persistJsonSchema: true is also set — detection needs the persisted baseline to compare against. With no schemaUpdate strategies at all, schema changes are accepted silently (the pre-schema-update behavior): only registering strategies turns on enforcement.

Usage

About these snippets. Each one reopens the same vault twice in a row — gen 0 then gen 1 — to simulate two separate application deployments seeing two versions of the schema, without needing two real processes. persistJsonSchema: true writes the schema baseline in the background, so the snippets call vault._drainPendingSchemaWrites() — a test-only synchronization helper — to wait for that write before moving on to gen 1. A real deployment reopening a vault in a later process never needs to call it: by the time that process starts, the previous write has long since finished.

An additive change passes

Adding a new optional field is the safe case — every registered strategy allows it by default.

// gen 0: register with the original shape
let vault = await db.openVault('acme')
vault.collection('invoices', {
  schema: z.object({ id: z.string() }),
  persistJsonSchema: true,
  schemaUpdate: [additiveOnly()],
})
await vault._drainPendingSchemaWrites() // test-only: see note above

// gen 1 (a later process, or a reopened vault): a new optional field
vault = await db.openVault('acme')
const invoices = vault.collection('invoices', {
  schema: z.object({ id: z.string(), amount: z.number().optional() }), // additive
  persistJsonSchema: true,
  schemaUpdate: [additiveOnly()],
})
await vault._drainPendingSchemaWrites() // test-only: see note above

await invoices.put('i1', { id: 'i1', amount: 10 }) // → resolves

A non-additive change is rejected

Removing a field, changing a field's shape, or flipping required-ness is non-additive. additiveOnly() is the safety backstop — it rejects the change and every write throws until you fix the schema or add a migration strategy:

import { additiveOnly } from '@noy-db/hub'

// gen 1 removes 'amount' from the schema above — non-additive
vault = await db.openVault('acme')
const invoices = vault.collection('invoices', {
  schema: z.object({ id: z.string() }),
  persistJsonSchema: true,
  schemaUpdate: [additiveOnly()],
})
await vault._drainPendingSchemaWrites()

await invoices.put('i1', { id: 'i1' })
// → throws NonAdditiveSchemaChangeError:
// 'Non-additive schema change to "invoices" (added: [], removed: [amount], changed: []).
//  Register a coordinatedCutover() strategy to migrate, or revert the change.'

The rejection happens at write time (put/delete), not at vault.collection() — registering a collection with a rejected schema change never throws; only the next write does.

Locking a collection outright

lockSchema() refuses any schema change (or, with fields, only changes that touch specific fields) regardless of whether the change would otherwise be additive. Strategies run in order and the first non-allow decision wins, so putting lockSchema() before additiveOnly() makes the lock take precedence:

import { lockSchema, additiveOnly } from '@noy-db/hub'

vault = await db.openVault('acme')
const invoices = vault.collection('invoices', {
  schema: z.object({ id: z.string(), note: z.string().optional() }), // additive, but locked
  persistJsonSchema: true,
  schemaUpdate: [lockSchema(), additiveOnly()],
})
await vault._drainPendingSchemaWrites()

await invoices.put('i1', { id: 'i1' })
// → throws SchemaLockedError:
// 'Schema for "invoices" is locked; the change was refused.'

A coordinated cutover migrates existing records

Some non-additive changes are legitimate — a field is being reshaped, not dropped. coordinatedCutover() accepts the change but defers it: it registers a pending migration (a transform function run over every existing record) and fences the collection's writes until you explicitly run the cutover.

import { coordinatedCutover, additiveOnly } from '@noy-db/hub'

interface InvOld extends Record<string, unknown> { id: string; total: number }
interface InvNew extends Record<string, unknown> { id: string; amount: { gross: number } }

// gen 0: old shape, one record
let vault = await db.openVault('acme')
const oldInvoices = vault.collection<InvOld>('invoices', {
  schema: z.object({ id: z.string(), total: z.number() }),
  persistJsonSchema: true,
})
await vault._drainPendingSchemaWrites()
await oldInvoices.put('i1', { id: 'i1', total: 100 })

// gen 1: reshape `total` into `amount.gross` — non-additive
vault = await db.openVault('acme')
const invoices = vault.collection<InvNew>('invoices', {
  schema: z.object({ id: z.string(), amount: z.object({ gross: z.number() }) }),
  persistJsonSchema: true,
  schemaUpdate: [
    coordinatedCutover({ transform: (d) => ({ id: d['id'], amount: { gross: d['total'] } }) }),
    additiveOnly(),
  ],
})
await vault._drainPendingSchemaWrites()

await invoices.put('i2', { id: 'i2', amount: { gross: 5 } })
// → throws SchemaFenceError: writes are blocked while the cutover is pending

const result = await vault.runSchemaCutover()
result.migrated // → 1  (i1 rewritten in place through the transform)

const i1 = await invoices.get('i1')
i1?.amount.gross // → 100

await invoices.put('i2', { id: 'i2', amount: { gross: 5 } }) // → resolves — the fence is clear

runSchemaCutover() waits for every other active client on the vault to acknowledge the pending schema generation (a drain barrier) before it rewrites records and clears the fence — see Limits & gotchas for the timeout and stale-client behavior.

API reference

All exports below come from the @noy-db/hub root.

blindUpdate()

function blindUpdate(): SchemaUpdateStrategy

Allows every schema change unconditionally. Explicit opt-in to the pre-schema-update, back-compat behavior — useful as a documented no-op strategy rather than omitting schemaUpdate entirely.

additiveOnly()

function additiveOnly(): SchemaUpdateStrategy

Allows additive changes (new optional fields only); rejects anything else with NonAdditiveSchemaChangeError. The recommended default safety backstop.

lockSchema(opts?)

function lockSchema(opts?: { readonly fields?: readonly string[] }): SchemaUpdateStrategy

Rejects any schema change with SchemaLockedError. With opts.fields, only rejects when one of the listed fields was added, removed, or changed — changes to other fields are allowed through to the next strategy.

coordinatedCutover(opts)

function coordinatedCutover(opts: { readonly transform: TransformFn }): SchemaUpdateStrategy

opts.transform has the shape (doc: Record<string, unknown>) => Record<string, unknown> (its TransformFn type alias is internal, not exported from @noy-db/hub). On a non-additive delta, coordinatedCutover() returns a cutover decision carrying that transform instead of rejecting. The transform runs once per existing record when vault.runSchemaCutover() executes; additive and unchanged deltas are allowed through untouched.

SchemaUpdateStrategy

interface SchemaUpdateStrategy {
  readonly name: string
  onSchemaDelta(delta: SchemaDelta, ctx: UpdateContext): UpdateDecision | Promise<UpdateDecision>
}

The shape every strategy — built-in or your own — implements. evaluateStrategies (internal) runs a collection's strategy list in order against the detected SchemaDelta and returns the first non-allow UpdateDecision.

SchemaDelta / FieldChange (types)

interface SchemaDelta {
  readonly collection: string
  readonly kind: 'none' | 'additive' | 'non-additive'
  readonly added: readonly string[]     // new top-level properties
  readonly removed: readonly string[]   // removed top-level properties
  readonly changed: readonly FieldChange[]
}

interface FieldChange {
  readonly field: string
  readonly requiredChanged: boolean  // required-ness flipped
  readonly shapeChanged: boolean     // subschema shape changed
}

The classified difference between the previously-persisted JSON Schema and the freshly-derived one, computed internally on every vault open. kind is 'additive' only when every change is a new optional top-level property — any removal, shape change, required-ness flip, or new required property makes it 'non-additive'. Nested-object and union-level changes are not yet classified individually; see Limits & gotchas.

UpdateContext / UpdateDecision (types)

interface UpdateContext {
  readonly collection: string
}

type UpdateDecision =
  | { readonly action: 'allow' }
  | { readonly action: 'reject'; readonly error: Error }
  | { readonly action: 'cutover'; readonly transform: TransformFn }

UpdateContext is what a strategy's onSchemaDelta receives alongside the delta. UpdateDecision is its verdict: allow falls through to the next strategy, reject/cutover are terminal.

vault.runSchemaCutover(opts?)

async runSchemaCutover(opts?: { onPoll?: () => Promise<void> }): Promise<{ migrated: number }>

Runs every pending coordinatedCutover for this vault: waits for the active client set to quiesce (see Limits & gotchas), applies each collection's transform to every existing record, bumps the vault's schema generation, and clears the fence. Returns how many collections were migrated. opts.onPoll is a test hook for advancing other simulated clients between barrier checks.

vault.abortSchemaCutover()

async abortSchemaCutover(): Promise<void>

Resets a stuck fence back to 'normal' without migrating or bumping the generation — a manual recovery escape hatch (for example, after a QuiesceTimeoutError).

vault.schemaFenceState()

async schemaFenceState(): Promise<FenceDoc>

type FenceState = 'normal' | 'draining' | 'migrating' | 'complete'

interface FenceDoc {
  readonly currentSchemaVersion: number
  readonly fenceState: FenceState
}

A thin, uncached read of the vault's current cutover state — useful for a status UI or a health check without holding a reference to the client that started the cutover.

Limits & gotchas

  • schemaUpdate requires persistJsonSchema: true. Without a persisted baseline there's nothing to diff against, so strategies never run and changes pass through silently.
  • Detection is top-level only. v0's SchemaDelta classifies top-level object properties and their required-ness; a shape change nested two levels deep is reported as shapeChanged on the outer field, not decomposed further.
  • A detection failure never blocks writes. If computing the delta itself throws (a storage error, a corrupt persisted-schema record), that failure is swallowed — schema-update is a safety net on top of normal writes, not a correctness gate that can wedge the collection. Only an explicit reject decision from a strategy blocks writes.
  • Order matters. Strategies run in array order and the first non-allow decision wins — put the strictest strategy (lockSchema()) before a permissive one (additiveOnly()) if you want the lock to take precedence.
  • A pending coordinatedCutover blocks all writes to that collection, not just ones touching the changed fields, until vault.runSchemaCutover() completes — put/delete throw SchemaFenceError in the meantime.
  • A stale client that already opened the vault before a cutover ran gets MigrationRequiredError on its next write once the generation counter has bumped — it must reopen the vault to pick up the new schema.
  • runSchemaCutover() can time out. If another connected client never quiesces, it throws QuiesceTimeoutError; recover with vault.abortSchemaCutover() and retry once the holdout client is closed or upgraded.

See also

  • persisted-schemas — the persisted JSON Schema baseline schema-update detects drift against
  • introspection — vault.introspect() reports each collection's registered strategy names
  • migrations — planned broader migration surface (collection renames, cross-version moves); coordinatedCutover() here already covers the field-level backfill case
  • Enclave — the DEK and envelope format the persisted schema (and vault metadata) reuse
  • Services overview · Orientation map