experimental

schema-identifiers

Opaque permanent field ids that survive a rename, plus a generation↔content-hash binding that makes 'which schema is generation N' answerable.
Subpath@noy-db/hub (via describe() / describeAsync() and the persisted-schema surface)
Factorynone — rides persistJsonSchema: true
ClusterC — Schema & Evolution
Since0.6.0

Two identity gaps blocked honest schema-evolution tooling: a field had no identity independent of its name, and "which schema was generation N" was unanswerable from published reads. Both are closed additively on the existing _schemas/<collection> envelope and the existing vault-wide schema fence — no new reserved collection, no new store contract.

Field ids

Every top-level field in a persisted schema gets an opaque, permanent id:

  • Random only — 12 bytes from crypto.getRandomValues, encoded as a 20-character base32url token. No timestamp, no name derivation: a name-derived id would change on rename, defeating the entire point.
  • Minted lazily, once — on the first persist for a collection with persistJsonSchema: true.
  • Stored as a name-keyed map — fieldIds?: Record<string, string> alongside jsonSchema and hash in the same encrypted record. Absent on pre-0.6 envelopes.
  • Preserved by name across re-derivation — re-persisting carries forward the id for every name still present; only a genuinely new name mints a fresh one.
  • Carried across a rename — the delta computation detects an unambiguous 1:1 rename (a removed name and an added name whose canonicalised subschemas match, with no other candidate sharing that shape) and reports it as renamed: { from, to }[]. The id is then looked up under the old name. The id moves with the field; the name is just its current label.

describe() surfaces it — with one asymmetry

DescribedField.id?: string appears in both overloads, but they differ:

  • Sync describe() never touches the store, so every field's id is undefined. This is not a bug — the sync path has no persisted state to read an id from.
  • Async describeAsync() resolves the persisted map and threads it through. It silently degrades to no ids on any failure: no DEK, no persisted envelope yet, or an envelope without the map.

A collection that never opted into persistJsonSchema therefore describes every field with id: undefined on both paths.

Generation ↔ content hash

The vault-wide fence counter is now bound to the schema-set content hash live at that generation:

  • FenceDoc.schemaHash?: string — the envelope hash most recently persisted while the vault sat at currentSchemaVersion.
  • PersistedSchemaEnvelope.generation?: number — mirrors currentSchemaVersion as of that envelope's most recent write.

Together these make "generation N = which content hash" answerable from published reads alone. Both are optional: a fresh vault, or a fence written before 0.6, simply omits them.

The lazy-stamp caveat

These fields do not update the instant a cutover completes. A coordinatedCutover finishes its drain barrier and advances currentSchemaVersion immediately, but the envelope's generation and the fence's schemaHash only catch up on the next ordinary schema write — typically the very next collection registration or open.

So there is a brief window where currentSchemaVersion has advanced but the hash pairing has not been re-stamped. This is pre-existing behaviour of the persist cycle rather than a new race, and it is stated here so nobody is surprised by it. The fence document is re-read immediately before stamping, which narrows — but does not eliminate — the window against a concurrent cutover on another collection.

Renames still trip the guards — deliberately

A rename is classified as kind: 'additive' (a label change is additive-safe), yet both schema-update guards still block it:

  • additiveOnly() rejects whenever renamed is non-empty, via the same non-additive error path as any other blocked change.
  • lockSchema({ fields }) treats both the from and to names as touching those fields — renaming away from a locked name, or onto one, still violates the lock.

The reason matters. Reporting a rename performs no transform of existing record values under the old key. Only coordinatedCutover actually migrates data. If the guards treated a rename as freely admissible, a caller could rename a field with neither guard tripping and no migration running — silently orphaning every existing record's value under the old key.

And coordinatedCutover itself fires its transform on a rename, not only on a non-additive change: its gate checks delta.kind === 'non-additive' || renamed.length > 0. Before field ids existed, a rename was a plain drop-plus-add and was classified non-additive, so the transform already fired. Without the explicit renamed check, reclassifying it as additive would have silently stopped the transform from running — exactly the orphaning the guards exist to prevent.

The story now holds end to end: additiveOnly() blocks a bare rename → its message points at coordinatedCutover() → registering one runs the transform, re-keying each record → and the id carries from the old name to the new. Identity and data survive the rename together.

Why generation stays vault-wide

Per-collection generation was evaluated and rejected. The actual requirement — binding a generation number to a concrete content hash — is fully satisfied at vault granularity. Moving to per-collection counters would mean migrating the fence model itself: a new per-collection counter, per-collection drain-barrier semantics, and a new companion record shape, with nothing in the requirement demanding it. If finer-grained tracking is ever needed, it belongs with the manifest-engine work that is already redesigning how schema state is addressed.

Back-compat

Every new field is optional and additive. A pre-0.6 envelope loads and describes fine with id simply absent; a pre-0.6 fence loads fine with the hash key treated as optional. Ids are minted lazily on the first persist after upgrade — nothing needs an explicit migration step.

See also