IMetadataService Contract
Reference for the Metadata Service contract — CRUD operations for object and field definitions, schema registry, and import/export
The Metadata Service manages all object and field definitions at runtime. It serves as the schema registry — plugins, the Kernel, and the API layer all query this service to discover what objects exist and what fields they contain.
Source: packages/spec/src/contracts/metadata-service.ts
Service name: metadata (registered via ctx.registerService('metadata', ...))
For the data path behind this contract — Repository → Change Log → Cache → Registry, plus HMR semantics — see Metadata Lifecycle & HMR.
Interface Definition
IMetadataService is a generic, type-keyed registry. Every item is addressed by
(type, name) — where type is a metadata type identifier ('object', 'view',
'flow', …) and name is the item's snake_case machine name. There are no
per-shape methods like createObject / getField; you register and read the whole
definition for a type.
export interface IMetadataService {
// Core CRUD (by type + name)
register(type: string, name: string, data: unknown): Promise<void>;
registerInMemory?(type: string, name: string, data: unknown): void;
get(type: string, name: string): Promise<unknown | undefined>;
list(type: string): Promise<unknown[]>;
listDiagnosed?(type: string): Promise<{ items: unknown[]; degraded: boolean; errors: string[] }>;
unregister(type: string, name: string): Promise<void>;
exists(type: string, name: string): Promise<boolean>;
listNames(type: string): Promise<string[]>;
// Convenience accessors for objects
getObject(name: string): Promise<unknown | undefined>;
listObjects(): Promise<unknown[]>;
// Loader reads (optional)
load?<T>(type: string, name: string, options?): Promise<T | null>;
loadDiagnosed?<T>(type: string, name: string, options?):
Promise<{ data: T | null; degraded: boolean; errors: string[] }>;
// Convenience accessors for UI metadata (optional)
getView?(name: string): Promise<unknown | undefined>;
listViews?(object?: string): Promise<unknown[]>;
getDashboard?(name: string): Promise<unknown | undefined>;
listDashboards?(): Promise<unknown[]>;
// Package management (optional)
unregisterPackage?(packageName: string): Promise<void>;
publishPackage?(packageId: string, options?: { changeNote?: string; publishedBy?: string; validate?: boolean }): Promise<PackagePublishResult>;
revertPackage?(packageId: string): Promise<void>;
getPublished?(type: string, name: string): Promise<unknown | undefined>;
// Query / bulk (optional)
query?(query: MetadataQuery): Promise<MetadataQueryResult>;
bulkRegister?(items: Array<{ type: string; name: string; data: unknown }>, options?: { continueOnError?: boolean; validate?: boolean }): Promise<MetadataBulkResult>;
bulkUnregister?(items: Array<{ type: string; name: string }>): Promise<MetadataBulkResult>;
// Watch / subscribe (optional)
watch?(type: string, callback: MetadataWatchCallback): MetadataWatchHandle;
// Import / export (optional)
exportMetadata?(options?: MetadataExportOptions): Promise<unknown>;
importMetadata?(data: unknown, options?: MetadataImportOptions): Promise<MetadataImportResult>;
// Validation, type registry, dependencies, history (optional)
validate?(type: string, data: unknown): Promise<MetadataValidationResult>;
getRegisteredTypes?(): Promise<string[]>;
getTypeInfo?(type: string): Promise<MetadataTypeInfo | undefined>;
getDependencies?(type: string, name: string): Promise<MetadataDependency[]>;
getDependents?(type: string, name: string): Promise<MetadataDependency[]>;
getHistory?(type: string, name: string, options?: MetadataHistoryQueryOptions): Promise<MetadataHistoryQueryResult>;
rollback?(type: string, name: string, version: number, options?: { changeNote?: string; recordedBy?: string }): Promise<unknown>;
diff?(type: string, name: string, version1: number, version2: number): Promise<MetadataDiffResult>;
}Core CRUD
All items are addressed by (type, name). The same six methods cover every metadata
type — objects, views, flows, and any plugin-registered type.
get / list
// Read one object definition (convenience accessor for get('object', name))
const task = await metadataService.getObject('task');
// Equivalent generic form:
const taskAlt = await metadataService.get('object', 'task');
// List all object definitions
const allObjects = await metadataService.listObjects();
const allObjectsAlt = await metadataService.list('object');
// List just the names of a type
const names = await metadataService.listNames('object');
// Existence check
const has = await metadataService.exists('object', 'task');get / getObject resolve to undefined (not null) when an item does not exist.
load / loadDiagnosed
Both read one item through the registered loaders. The difference is what a missing answer means.
load returns null for both "no loader has this item" and "every loader
failed" — a loader that throws is warn-logged and skipped, so the two collapse
into the same value. loadDiagnosed returns the same data plus degraded and
errors, which is what tells them apart (ADR-0110 D3).
Reach for loadDiagnosed whenever the difference could change a decision.
Treating a degraded read as an absence is how a store being down turns into an
authorization answer.
const { data, degraded, errors } = await metadataService.loadDiagnosed?.('action', name) ?? {};
if (degraded) {
// The store is unreachable — do NOT conclude the action does not exist.
}list / listNames
The plural reads' failure posture. Both read a set through the same
registered loaders, and — unlike the singular reads above, which collapse every
fault into one null — they answer two different kinds of fault differently.
| condition | outcome |
|---|---|
A loader cannot be read — a storage outage, an unreachable sys_metadata, any other throw | Degrade — that loader is reported once and skipped; the read resolves with what the reachable loaders hold |
One metadata name is derived from more than one file — twin.json beside twin.yaml in one type directory | Refuse — AmbiguousMetadataStemError propagates out of both reads |
Degrade is the older of the two postures and the one nothing announces to
the caller: list and listNames still resolve, the caller still gets an
array, nothing 500s, and the set is quietly short. listDiagnosed is what
tells a short set apart from a complete one — it returns the same items plus
degraded and errors, and degraded is true when at least one loader could
not be read while the set was assembled. It says the set is known-partial,
never that it is empty and never that it is wrong: a reason to withhold a claim
of completeness, never a reason to withhold the items.
listNames has no diagnosed counterpart. A short name set is not
distinguishable by its caller at all — the lost loader is reported at error
in the server log and nowhere else.
Refuse is an authoring error rather than an outage, so it is deliberately
not absorbed by the degrade seam above. The filesystem loader derives a
metadata name by stripping the extension from a flat file's basename, so two
files under one type directory sharing a stem produce one name that is listed
twice while only one of them is reachable under that name. Instead of picking a
winner by extension precedence, the loader throws, and both plural reads
re-raise it. The error carries the ADR-0112 envelope — code
AMBIGUOUS_METADATA_STEM, status 500 (the request is well formed and no
caller can fix it by sending something else; only deleting or renaming a file
does), plus the metadata type, the stem, and every colliding path,
sorted — never just the precedence winner. Catch it with
isAmbiguousMetadataStemError from @objectstack/metadata wherever you need
to tell it apart from an outage.
Only stems the loader would actually resolve collide: the comparison is
case-sensitive, it covers just the extensions whose serializers are registered
(.js is not in the default set), and a nested file sharing a flat file's
basename is not a collision.
register / unregister
register saves (creates or replaces) the full definition for a (type, name).
There are no separate create/update calls and no per-field mutators — register the
complete object definition, including its fields array.
// Create or replace an object definition
await metadataService.register('object', 'project', {
name: 'project',
label: 'Project',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'status', label: 'Status', type: 'select', options: [
{ label: 'Active', value: 'active' },
{ label: 'Archived', value: 'archived' },
]},
],
});
// Remove a definition
await metadataService.unregister('object', 'project');To inspect what would break before removing an item, call getDependents('object', 'project') — it returns the items that reference this one.
registerInMemory
Optional. Seeds an item into the in-memory registry only — never persisted
to the DB store, never announced to watch subscribers. This is the boot-time
path for source-control-owned artefacts that must be listable without
creating DB drift — e.g. code-defined datasources (origin: 'code'), which is
how the default datasource appears in Setup → Datasources:
metadataService.registerInMemory?.('datasource', 'default', {
name: 'default',
label: 'Default',
driver: 'sqlite',
origin: 'code',
});Callers that mutate metadata mid-run want register, which persists and
announces.
Bulk Operations
bulkRegister registers many items in one batch; bulkUnregister removes many.
const result = await metadataService.bulkRegister([
{ type: 'object', name: 'task', data: { name: 'task', label: 'Task', fields: [/* ... */] } },
{ type: 'object', name: 'project', data: { name: 'project', label: 'Project', fields: [/* ... */] } },
], { continueOnError: true, validate: true });To validate a single item without persisting it, use validate:
const validation = await metadataService.validate('object', definition);Overlay Management — removed
The optional getOverlay / saveOverlay / removeOverlay / getEffective
members and their MetadataOverlay record were removed in #13135 (ADR-0049
enforce-or-remove): they belonged to a paper customization protocol no route
ever served, and ADR-0126 supersedes it on the record. Org-scoped
customization is ADR-0005's metadata overlay
— opt-in per type via allowOrgOverride, written through the REST meta write
doors, and read back through the layered read (code / overlay /
effective).
Watch / Subscribe
Subscribe to metadata changes for live-reload and cache invalidation. watch takes a
metadata type (or '*' for all types) and returns a handle with unsubscribe().
const handle = metadataService.watch('object', (event) => {
// event: { type: 'registered' | 'updated' | 'unregistered', metadataType, name, data? }
console.log(`${event.type}: ${event.metadataType}/${event.name}`);
});
// Clean up
handle.unsubscribe();Import / Export
exportMetadata
Exports metadata as a portable bundle, optionally filtered by types, namespaces,
or format.
const bundle = await metadataService.exportMetadata({
types: ['object', 'view'],
format: 'json',
});importMetadata
Imports a metadata bundle with conflict-resolution options.
const result = await metadataService.importMetadata(bundle, {
conflictResolution: 'merge', // 'skip' | 'overwrite' | 'merge'
validate: true,
dryRun: false,
});
console.log(result.total); // items processed
console.log(result.imported); // successfully imported
console.log(result.skipped); // skipped via conflict resolution
console.log(result.failed); // failedTypes Reference
| Type | Description |
|---|---|
MetadataQuery / MetadataQueryResult | Query parameters and paginated result for query() |
MetadataExportOptions | { types?, namespaces?, format? } for exportMetadata |
MetadataImportOptions | { conflictResolution?, validate?, dryRun? } for importMetadata |
MetadataImportResult | { total, imported, skipped, failed, errors? } |
MetadataBulkResult | Result of bulkRegister / bulkUnregister |
MetadataWatchCallback | (event: { type, metadataType, name, data? }) => void |
MetadataWatchHandle | { unsubscribe(): void } |
PackagePublishResult | { success, packageId, version, publishedAt, itemsPublished, validationErrors? } |
UI Metadata (Views & Dashboards)
UI metadata types (view, dashboard, page, app, theme) are first-class citizens in the Metadata Service. The previously separate IUIService was removed in v11 — use the Metadata Service for views/dashboards.
Reading UI Metadata
// Get a view definition
const view = await metadataService.get('view', 'account.default');
// List all views for an object
const views = await metadataService.listViews('account');
// Get a dashboard
const dashboard = await metadataService.get('dashboard', 'sales_overview');Org-Level Customization
Per-org view customization rides ADR-0005's metadata overlay (opt-in per type
via allowOrgOverride, view among the overlay types): an org-scoped write
through the REST meta doors stores a sys_metadata row, and the layered read
returns code / overlay / effective for it. The per-user, per-field patch
overlay a previous revision of this page taught here was removed in #13135 —
it was never served by any route.
Permission-Based UI Filtering
Permission filtering is handled at the API layer, not in the metadata service itself:
// In your API handler / middleware:
const views = await metadataService.list('view');
const filteredViews = views.filter(view => {
const v = view as any;
// Check if user has permission to see this view
return !v.requiredPermission || userPermissions.includes(v.requiredPermission);
});Package Publishing
ObjectStack uses package-level publishing to ensure metadata consistency. All metadata items within a package are published atomically — either everything goes live, or nothing does.
publishPackage
Publishes all metadata items in a package:
- Validates all items (optional)
- Snapshots each item's definition into
publishedDefinition - Increments the package version
- Sets all items to
activestate
const result = await metadataService.publishPackage('com.acme.crm', {
publishedBy: 'admin-user',
validate: true, // default: true
changeNote: 'Added opportunity fields',
});
console.log(result.success); // true
console.log(result.version); // 2
console.log(result.itemsPublished); // 5
console.log(result.publishedAt); // "2025-06-01T12:00:00Z"revertPackage
Reverts all metadata items in a package to their last published state. Discards any unpublished changes.
await metadataService.revertPackage('com.acme.crm');
// All items restored to their publishedDefinition snapshotsgetPublished
Returns the published version of a metadata item (for runtime/end-user serving). Falls back to the current definition if the item has never been published.
// End user sees the published version
const published = await metadataService.getPublished('object', 'opportunity');
// Designer sees the draft version (via regular get)
const draft = await metadataService.get('object', 'opportunity');REST Endpoints
The package family under /api/v1/packages is served by the runtime dispatcher's
/packages domain — one implementation for the reads and the uninstall — with the
REST layer contributing only the marketplace publish route beside it; per-item
metadata routes live under /api/v1/meta. Publishing a single metadata item's
pending draft is done via the /meta/:type/:name/publish route.
| Method | Path | Description |
|---|---|---|
POST | /api/v1/packages/publish | Publish a package to the marketplace registry (body: { manifest, metadata }) — the REST registrar's one route |
GET | /api/v1/packages | List the installed packages (the in-memory registry; published-but-not-installed artifacts are not listed) |
GET | /api/v1/packages/:id | Get an installed package — the bare row under data; a missing id answers 404 RESOURCE_NOT_FOUND, message Package 'ID' not found |
DELETE | /api/v1/packages/:id | Uninstall a package for the caller's organization (?keepData=true keeps the object tables) |
POST | /api/v1/meta/:type/:name/publish | Promote a metadata item's pending draft to live |
POST | /api/v1/meta/:type/:name/rollback | Restore a historical version as the live overlay |