schema-identifiers
| Subpath | @noy-db/hub (via describe() / describeAsync() and the persisted-schema surface) |
| Factory | none — rides persistJsonSchema: true |
| Cluster | C — Schema & Evolution |
| Since | 0.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>alongsidejsonSchemaandhashin 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'sidisundefined. 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 atcurrentSchemaVersion.PersistedSchemaEnvelope.generation?: number— mirrorscurrentSchemaVersionas 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 wheneverrenamedis non-empty, via the same non-additive error path as any other blocked change.lockSchema({ fields })treats both thefromandtonames 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
- persisted-schemas — the envelope these fields live on
- schema-update — the guards and cutover strategy
- introspection —
describe()anddescribeAsync() - manifest — the manifest engine that consumes these fields