IMetadataService Contract
Reference for the Metadata Service contract — CRUD operations for object and field definitions, schema registry, overlay management, and import/export
IMetadataService Contract
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[]>;
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>;
// Overlay / customization (optional)
getOverlay?(type: string, name: string, scope?: 'platform' | 'user'): Promise<MetadataOverlay | undefined>;
saveOverlay?(overlay: MetadataOverlay): Promise<void>;
removeOverlay?(type: string, name: string, scope?: 'platform' | 'user'): Promise<void>;
getEffective?(type: string, name: string, context?: { userId?: string; tenantId?: string; positions?: string[]; permissions?: string[] }): Promise<unknown | undefined>;
// 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.
}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
Overlays customize a metadata item without modifying the base (system) definition.
A MetadataOverlay references the target by baseType + baseName, carries a JSON
Merge Patch in patch, and resolves in the order system ← platform ← user.
// Save a platform-scope overlay
await metadataService.saveOverlay({
id: 'overlay-platform-1',
baseType: 'object',
baseName: 'task',
scope: 'platform',
patch: { label: 'Work Item' },
});
// Read the merged (effective) definition with overlays applied
const effective = await metadataService.getEffective('object', 'task', {
userId: 'user-123',
});| Property | Type | Description |
|---|---|---|
baseType | string | Metadata type being customized |
baseName | string | Metadata name being customized |
scope | 'platform' | 'user' | Customization scope (default platform) |
owner | string | Owner user ID, for user-scope overlays |
patch | object | JSON Merge Patch (changed fields only) |
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() |
MetadataOverlay | Runtime customization layer (baseType, baseName, scope, patch) |
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_list');
// List all views for an object
const views = await metadataService.listViews('account');
// Get a dashboard
const dashboard = await metadataService.get('dashboard', 'sales_overview');User-Level Customization
Users can customize views via the overlay system:
// Admin customizes a view for all users
await metadataService.saveOverlay({
id: 'overlay-platform-1',
baseType: 'view',
baseName: 'account_list',
scope: 'platform',
patch: { columns: ['name', 'email', 'status', 'created_at'] },
});
// A specific user saves personal column preferences
await metadataService.saveOverlay({
id: 'overlay-user-123',
baseType: 'view',
baseName: 'account_list',
scope: 'user',
owner: 'user-123',
patch: { columns: ['name', 'status'] }, // user only wants 2 columns
});
// Resolve effective view for a specific user
const effectiveView = await metadataService.getEffective('view', 'account_list', {
userId: 'user-123',
});
// Result: base view ← platform overlay ← user-123 overlayPermission-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 REST layer mounts package routes under /api/v1/packages and per-item metadata
routes 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 a package (body: { manifest, metadata }) |
GET | /api/v1/packages | List all packages (registry + database) |
GET | /api/v1/packages/:id | Get a specific package |
DELETE | /api/v1/packages/:id | Delete a package |
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 |