ObjectStackObjectStack

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.

conditionoutcome
A loader cannot be read — a storage outage, an unreachable sys_metadata, any other throwDegrade — 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 directoryRefuseAmbiguousMetadataStemError 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);    // failed

Types Reference

TypeDescription
MetadataQuery / MetadataQueryResultQuery parameters and paginated result for query()
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.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:

  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 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.

MethodPathDescription
POST/api/v1/packages/publishPublish a package to the marketplace registry (body: { manifest, metadata }) — the REST registrar's one route
GET/api/v1/packagesList the installed packages (the in-memory registry; published-but-not-installed artifacts are not listed)
GET/api/v1/packages/:idGet 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/:idUninstall a package for the caller's organization (?keepData=true keeps the object tables)
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