ObjectStackObjectStack

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',
});
PropertyTypeDescription
baseTypestringMetadata type being customized
baseNamestringMetadata name being customized
scope'platform' | 'user'Customization scope (default platform)
ownerstringOwner user ID, for user-scope overlays
patchobjectJSON 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);    // failed

Types Reference

TypeDescription
MetadataQuery / MetadataQueryResultQuery parameters and paginated result for query()
MetadataOverlayRuntime customization layer (baseType, baseName, scope, patch)
MetadataExportOptions{ types?, namespaces?, format? } for exportMetadata
MetadataImportOptions{ conflictResolution?, validate?, dryRun? } for importMetadata
MetadataImportResult{ total, imported, skipped, failed, errors? }
MetadataBulkResultResult 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 overlay

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:

  1. Validates all items (optional)
  2. Snapshots each item's definition into publishedDefinition
  3. Increments the package version
  4. Sets all items to active state
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 snapshots

getPublished

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.

MethodPathDescription
POST/api/v1/packagesPublish a package (body: { manifest, metadata })
GET/api/v1/packagesList all packages (registry + database)
GET/api/v1/packages/:idGet a specific package
DELETE/api/v1/packages/:idDelete a package
POST/api/v1/meta/:type/:name/publishPromote a metadata item's pending draft to live
POST/api/v1/meta/:type/:name/rollbackRestore a historical version as the live overlay

On this page