Metadata Lifecycle & HMR
How metadata flows through Repository → Change Log → Cache → Registry — the canonical event stream that powers Studio HMR, REST writes, and future cloud editing.
Metadata Lifecycle & HMR
This page documents the metadata data path introduced by ADR-0008 and refined by ADR-0005. It is the canonical event stream that powers Studio Hot Module Replacement (HMR), REST writes, and future cloud editing.
TL;DR — Every write goes through a single MetadataRepository.put() call. The repository appends to a change log, emits a watch event with a monotonic seq, and broadcasts over SSE to the console UI. In dev, the console's MetadataHmrReloader subscribes to that stream and triggers a debounced full page reload.
Architecture (one diagram)
┌─────────────────────────────────────────────┐
│ Console UI (incl. Studio) │
│ (MetadataHmrReloader) │
└────────────────────┬────────────────────────┘
│ SSE /api/v1/dev/metadata-events
▼
┌─────────────────────────────────────────────┐
│ MetadataManager (bridge) │
│ forwards Repository events → SSE channel │
└────────────────────┬────────────────────────┘
│ ChangeEvent{kind, metadataType, name, seq?}
▼
┌─────────────────────────────────────────────┐
│ Overlay precedence (protocol.ts) │
│ ┌─────────────────────────────────────┐ │
│ │ Top: SysMetadataRepository (org) │ │ ← writable overlay
│ │ Bottom: FS / InMemory (artifact) │ │ ← read-only baseline
│ └─────────────────────────────────────┘ │
└────────────────────┬────────────────────────┘
│ append(change log) + emit watch event
▼
sys_metadata table
(or filesystem artifact)Reads walk top-to-bottom: the first non-null layer wins. Writes always route to the topmost writable layer (the overlay). Deletes from the layered API only remove the overlay row — the artifact baseline survives.
Today this precedence is implemented inside the protocol layer, not by
LayeredRepository. ObjectStackProtocolImplementation lazily builds one
SysMetadataRepository per org (getOverlayRepo()) and merges it against the
in-memory code registry — getMetaItemLayered() returns code / overlay /
effective separately. LayeredRepository ships in @objectstack/metadata-core
and implements exactly the semantics above, but it is not yet composed into
any production path: every new LayeredRepository(...) call site is a test
(metadata-core's own suite plus one objectql integration test).
The four primitives
| Primitive | Package | Purpose |
|---|---|---|
| Repository | @objectstack/metadata-core | CRUD + watch interface over a single metadata source. InMemoryRepository and LayeredRepository ship from @objectstack/metadata-core; FileSystemRepository from @objectstack/metadata-fs; SysMetadataRepository from @objectstack/metadata-protocol (relocated out of @objectstack/objectql by ADR-0076 — import it from @objectstack/metadata-protocol; @objectstack/objectql no longer re-exports it). |
| Change Log | @objectstack/metadata-core | Append-only log of every mutation, tagged with a monotonic seq. Watchers can replay from any since. |
| Cache | @objectstack/metadata-core | MetadataCache — a bounded LRU in front of a repository, keyed by refKey(ref) and capped by maxEntries / maxBytes. Lazily filled on read miss (no bulk preload), invalidated by repo.watch() events, and negative-caches misses. |
| Registry | (per consumer) | Not a single package — each consumer owns a typed projection over the cache (e.g. SchemaRegistry in @objectstack/objectql), per ADR-0008 §2.9. |
MetaRef = (type, name, org). As of ADR-0008 §0 amendment (2026-04-13), project and branch are removed from the runtime tuple. Project survives only as an artifact-packaging concept on the objectstack.json envelope; branching is left to Git.
Write path: Studio → SSE → repository
When a user edits a view in Studio:
- Studio calls
PUT /api/v1/meta/view/case_grid(REST). protocol.ts:saveMetaItem()runs the two-tier gate (see overlay whitelist): an item that already exists as a packaged artifact requiresMetadataTypeRegistryEntry.allowOrgOverride; a brand-new item requiresallowRuntimeCreateorallowOrgOverride.- If allowed, the call lands on
MetadataRepository.put(ref, body, { parentVersion, actor }). - The repository:
- Verifies
parentVersionmatches the current head (ConflictErroron mismatch). - Writes the new row + appends a change-log entry with the next
seq. - Emits a
MetadataEvent { op, ref, hash, parentHash, actor, seq, ts, source }, whereopis one ofcreate | update | delete | rename | publish | revert.
- Verifies
- The
MetadataManagerbridge forwards the event over the SSE channel/api/v1/dev/metadata-events. The wire payload is not the internalMetadataEvent— it is aChangeEvent { kind: 'metadata-change', type, metadataType, name, path?, timestamp, seq? }(emitted asevent: metadata-change).seqis the canonical repository sequence and is absent for FS-watcher (chokidar) dev events. - In dev, the console's
MetadataHmrReloaderreceives themetadata-changeevent and schedules a debouncedlocation.reload()(defaultdebounceMs: 400, plus a 150 ms toast delay).
The seq is the single source of truth for ordering on the server. Note that the shipped console client does not read seq — it reloads the whole page rather than invalidating per-item caches. There is no granular-refetch hook or seq status badge in the current UI.
Package-first authoring (ADR-0070)
Every runtime-authored item lives inside a writable package — there are no
orphans. Code-defined and installed packages are read-only at runtime, so the
first step of any Studio/API authoring action is to target a writable package (a
"base"): a create/update aimed at a read-only package is rejected, and the author
is asked to pick or create a writable base first. New objects, fields, views, and
flows are namespaced into that package — which is exactly what os package publish
later ships. See ADR-0070.
Note: "package" here is the runtime authoring base (a
package_id-bearing container of metadata), distinct from the npm@objectstack/*packages listed in the repo README.
Overlay whitelist (shared-DB tenancy invariant)
In shared-database multi-tenancy, most metadata types must not be per-org customizable — overriding them would break the physical schema. The whitelist lives in one place: MetadataTypeRegistryEntry.allowOrgOverride in packages/spec/src/kernel/metadata-plugin.zod.ts.
| Type | allowOrgOverride | Rationale |
|---|---|---|
view, dashboard, report, email_template, translation | ✅ | Pure rendering / render-time content. Per-org customization is safe. |
flow | ❌ | Flows carry execution side-effects (events, jobs, audit), and the flow entry declares supportsOverlay: false — the loader cannot merge a per-org flow overlay, so the write permission granted a write nothing could read back: an org-scoped flow overlay wrote successfully and lost its binding on the next cold start. Rolled back (#6283) so the silent phantom is a loud 403 not_overridable at the moment of the write. allowRuntimeCreate stays true — a tenant may still author a brand-new flow through the runtime API (ADR-0070 package-first authoring); what is closed is overlaying a packaged flow per org. |
agent | ❌ | Agents are platform-owned and closed to third parties (ADR-0063 §2) — no per-org agent fork. |
permission, position | ❌ | Authorization correctness — a per-org overlay of a packaged permission set is silent privilege drift (ADR-0005's security row). Rolled back from an unratified true by the 2026-08-08 maintainer ruling (#6483); position takes the amendment's false default for new types. allowRuntimeCreate stays true: runtime-created sets (including package-bound rows materialized through the metadata door) keep working, while an admin-door edit of a code-declared set refuses with 403 not_overridable — edit the package and re-publish (ADR-0086 two-doors). |
object | ❌ | Defines the table schema. Overriding a packaged object would break existing data — but allowRuntimeCreate: true, so tenants can author brand-new objects, and this is also how a new field is added (write the object with the field in fields). |
field | ❌ | Also allowRuntimeCreate: false since protocol 17 (#7893). A field is not a standalone artifact — fields are authored inside their object (ObjectSchema.fields), so a field write minted a separate sys_metadata row keyed ('field','<object>.<name>') that nothing ever composed into the parent: PUT /meta/field/showcase_task.zz_probe answered 200 state=active, the row read back _diagnostics.valid: true, and GET /meta/object/showcase_task never listed the field. The door is closed rather than bridged: add a field by writing its object (PUT /meta/object/:name, or **/*.object.ts and redeploy), which both persists and composes. Existing rows are untouched — they were inert before the change too — and remain deletable. |
datasource | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. (allowRuntimeCreate: true — the datasource wizard persists origin: 'runtime' rows.) |
job | ❌ | Also allowRuntimeCreate: false since protocol 17 (#4509). JobSchema.handler names a function in the compiled bundle's function table, which a runtime writer has no way to reach — so a job created in Studio or through PUT /meta parsed, saved, reported success and was never scheduled. The door is closed rather than bridged: job stays first-class through *.job.ts / defineStack({ jobs, functions }), where every schedule shape, retryPolicy and timeout does reach the scheduler. Existing rows are untouched — they were never scheduled — and migrateStoredMetadata reports them skipped. |
Those five are the complete allowOrgOverride: true set: of the 27 types in DEFAULT_METADATA_TYPE_REGISTRY, every other one is false. The ❌ rows above are the false types whose second tier (allowRuntimeCreate) is worth calling out; any type not listed is allowOrgOverride: false.
There is no workflow metadata type (per ADR-0020, record state machines are a state_machine validation). Nor is there a standalone validation type any more — it was retired in protocol 17 under ADR-0088 because ValidationRuleSchema carries no object-binding key, so a rule authored through that door could never say what it protected; author rules in the object's own validations[] instead. The runtime gate is implemented in OVERLAY_ALLOWED_TYPES (derived from the registry) and enforced by SysMetadataRepository.put().
The gate is two-tier — allowOrgOverride: false is not the same as "no runtime writes":
- Overwriting an item that ships from a code package requires
allowOrgOverride. Denied →403 not_overridable. - Creating a brand-new item that has no artifact backing requires
allowRuntimeCreate(orallowOrgOverride). Denied →403 not_creatable.
See ADR-0005 for the full design and amendments.
Conflict handling
Every put() requires a parentVersion (or null for fresh creates). If the head has moved since the caller read it, the repository throws ConflictError(ref, expectedParent, actualHead) with code METADATA_CONFLICT. protocol.ts catches it at every write entry point (save, publish, rollback, overlay-delete) and re-throws an API error with code metadata_conflict, HTTP 409 Conflict, and the expectedParent / actualHead pair attached. Clients are expected to re-read the item and retry; the console does not yet surface a dedicated conflict-resolution UI.
The hash is sha256: + 64-hex of a canonical (sorted-keys, no-undefined) JSON serialization of the body. Identical bodies produce identical hashes — put() short-circuits on no-op writes.
HMR end-to-end (latency & ordering)
- Latency. The server half (REST
PUT→ DB write → SSE flush) is fast on localhost, but the client half dominates:MetadataHmrReloaderdebounces for 400 ms, waits another 150 ms so its toast can paint, and then does a full page reload. Budget roughly half a second plus a cold React mount, not a sub-100 ms in-place re-render. - Ordering. The
seqis monotonic per repository. The repository-levelwatch()API supports replay from asincecursor, but the dev HMR SSE endpoint does not replay on reconnect — it registers a fresh listener, emits areadyevent, and then streams only live events. A tab that disconnects and reconnects will miss any events that occurred while it was offline; it should refetch the affected metadata on reconnect. (The stream also emits: pingheartbeat comments every 15 s.) - Multi-tab. Every open tab registers its own listener on the same in-process broadcast hub, so all tabs receive the same events in the same order. No tab tracks
seqclient-side — the shipped reloader ignores it entirely.
Cross-references
- ADR-0005: Metadata customization overlay — overlay semantics, whitelist, conflict rules.
- ADR-0008 §0 amendment (2026-04): project/branch removal — why
MetaRef.projectandMetaRef.branchare dead. - ADR-0008: Metadata Repository & Change Log — the four-primitive architecture.
- IMetadataService Contract — the runtime API plugins call.
- North Star — the product shape these primitives serve.
Status (as of 2026-04)
| Component | State |
|---|---|
InMemoryRepository, LayeredRepository | ✅ Shipped (@objectstack/metadata-core) |
FileSystemRepository | ✅ Shipped (@objectstack/metadata-fs) |
Change log + seq (per-org, monotonic) | ✅ Shipped |
SSE bridge (/api/v1/dev/metadata-events, event: metadata-change) | ✅ Shipped |
| Granular client-side HMR hook + seq status badge | ❌ Not implemented — no such hook or badge exists in the console today |
Console dev-mode HMR reloader (MetadataHmrReloader) | ✅ Shipped (console app, mounted app-wide, dev-only) |
SysMetadataRepository (overlay over sys_metadata) | ✅ Shipped (@objectstack/metadata-protocol) |
LayeredRepository(SysMeta + artifact) composition | ⏳ Not wired — the class ships, but nothing composes it outside tests |
protocol.ts:saveMetaItem routed through SysMetadataRepository.put | ✅ Shipped (via the per-org getOverlayRepo() cache) |
sys_metadata_history table (durable, org-keyed change log) | ✅ Shipped (object definition in @objectstack/metadata-core; written by SysMetadataRepository) |
Cache + Registry refactor against MetadataRepository | ⏳ Post-M0 |
Cross-replica sync happens above the repository, not inside it.
SysMetadataRepository.watch()is deliberately scoped to the local instance — it has noLISTEN/NOTIFYor pub/sub of its own, and never will. Cross-node propagation is instead a cluster concern:@objectstack/runtimeregistersClusterServicePluginandMetadataClusterBridgePluginby default (opt out withcluster: false), and atkernel:readythe bridge callsMetadataManager.attachClusterPubSub(), fanning every local watch event out over themetadata.changedchannel (loopback-suppressed byoriginNode) so peer nodes invalidate their caches. Note this replays the invalidation event, not the overlay row — peers re-read from the shared database. The default cluster driver ismemory, which is in-process, so real cross-replica fan-out still requires configuring a distributed driver; and if nocluster(ormetadata) service is registered the bridge logs and skips, leaving each replica seeing only its own writes.
FAQ — When does code-defined metadata enter the database?
Short answer: never. This is a deliberate design choice; the table below is the authoritative reference and supersedes any other description you may find.
| Where the metadata came from | Lands in sys_metadata? | Lands in history? |
|---|---|---|
defineView(...) / defineFlow(...) / any source file → compiled into dist/objectstack.json | ❌ Never. Loaded into the in-memory registry on boot; refreshed via HMR in dev. | ❌ The artifact's own version history is Git. The metadata layer does not duplicate it. |
Editing a .json under <root>/<type>/<name>.json (FS overlay, e.g. <root>/view/case_grid.json) | ❌ FS layer is independent of DB. | ✅ Appended to the change log at <root>/.objectstack/.log/main.jsonl by FileSystemRepository. |
Studio inline edit, or PUT /api/v1/meta/... (REST) on an allowOrgOverride: true type | ✅ Written by SysMetadataRepository.put() as an overlay row scoped to organization_id. | ✅ Appended to sys_metadata_history (per-org event_seq) in the same transaction as the sys_metadata write. No-op puts (identical hash) skip the history row entirely. |
Deploying a new build (new dist/objectstack.json) | ❌ The artifact is loaded into memory, not synced into sys_metadata. | ❌ Use Git tags / your deployment platform's release log; that's where artifact "version history" lives. |
Why artifact never enters the database
- Single source of truth. Code → artifact → memory. If artifact also wrote to
sys_metadata, every deploy would need a reconciliation step ("the row says v3.0.0 but the code says v3.0.1") that is impossible to get right under concurrent edits. - Overlay stays small and auditable. Per-org overlay = "what this organization deliberately changed at runtime". A clean table that's safe to dump, diff, or reset to factory.
- Immutable infrastructure. Rolling back a deploy = switching image tags. No DB migration, no reverse
put, no history replay required. - Per-org multi-tenant economics. If artifacts were materialised into
sys_metadata, every org would carry tens of thousands of baseline rows. Overlay-only keeps each org's row count proportional to its actual customisation.
What if I want auditable history of deployed artifact changes?
That's a deployment log, not a metadata change log. Track the envelope hash of each deployed objectstack.json against the org/environment in a deployment-history table. The internal items inside the artifact need no per-item history because their canonical history is the Git repo that produced them.