schema-update
| Subpath | @noy-db/hub (root export) |
| Type | Core 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
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
schemaUpdaterequirespersistJsonSchema: 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
SchemaDeltaclassifies top-level object properties and their required-ness; a shape change nested two levels deep is reported asshapeChangedon 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
rejectdecision from a strategy blocks writes. - Order matters. Strategies run in array order and the first non-
allowdecision wins — put the strictest strategy (lockSchema()) before a permissive one (additiveOnly()) if you want the lock to take precedence. - A pending
coordinatedCutoverblocks all writes to that collection, not just ones touching the changed fields, untilvault.runSchemaCutover()completes —put/deletethrowSchemaFenceErrorin the meantime. - A stale client that already opened the vault before a cutover ran gets
MigrationRequiredErroron 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 throwsQuiesceTimeoutError; recover withvault.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