Skip to content

Metacore

Addon Cookbook

Short recipes for the patterns that come up while building addons. Each entry is a question, the smallest working snippet, and a one-line note about why. For deep context on any feature, follow the links to manifest-spec.md and dynamic-ui.md.

Table of contents ​

How do I add a foreign-key relation? ​

Declare ref on the column. The host generates the FOREIGN KEY constraint and exposes a /options/<model> endpoint that the modal's relation picker uses.

json
{
  "table_name": "ticket_comments",
  "columns": [
    { "name": "ticket_id", "type": "uuid", "required": true,
      "ref": "addon_tickets.tickets" }
  ]
}

The edit dialog will render a searchable combobox for ticket_id. Targets can be cross-schema (addon_<other>.<table>) or core (users, organizations).

How do I make a column searchable? ​

Two layers — column-level filterability (driven by metadata, surfaced as a per-column filter chip in the toolbar) and global search (driven by the toolbar's free-text input).

json
{
  "name": "title",
  "type": "string",
  "size": 255,
  "searchable": true,
  "filterable": true
}

searchable: true includes the column in the global ILIKE-style search the toolbar emits as ?search=. filterable: true lets the kernel produce a FilterDefinition for it so users see a per-column filter button.

How do I add a custom validation? ​

For action fields, add validation (regex applied after normalize):

json
"input_schema": [
  { "name": "rfc", "type": "string", "required": true,
    "normalize": "uppercase",
    "validation": "^[A-ZÑ&]{3,4}\\d{6}[A-Z0-9]{3}$" }
]

For column constraints beyond what the manifest expresses (NOT NULL, UNIQUE, length), validate in your action handler / WASM export. Keep schema-level constraints declarative; keep business rules in code.

How do I create a custom action with a modal? ​

Declare the action under the model with fields[]:

json
"actions": {
  "tickets": [
    {
      "key": "reassign",
      "label": "Reassign",
      "icon": "UserPlus",
      "fields": [
        { "name": "assignee_id", "label": "New assignee", "type": "user", "required": true },
        { "name": "note", "label": "Note", "type": "text" }
      ]
    }
  ]
}

<DynamicTable> adds "Reassign" to the row dropdown. Clicking it fires <ActionModalDispatcher>, which renders a modal with the declared inputs and POSTs to /data/tickets/<id>/action/reassign. Wire the server side via hooks (webhook), a WASM export, or a compiled ActionInterceptor — see manifest-spec.md.

For full custom UI register a component:

tsx
import { actionRegistry } from '@asteby/metacore-sdk'
actionRegistry.register('tickets', 'reassign', ReassignDialog)

The dispatcher routes to your component when (model, action.key) matches.

How do I require a permission for a button? ​

Wrap the affordance in <CapabilityGate>:

tsx
import { CapabilityGate } from '@asteby/metacore-runtime-react'

<CapabilityGate require="db:write addon_tickets.tickets">
  <Button onClick={createTicket}>New ticket</Button>
</CapabilityGate>

The kernel still enforces the same capability server-side — gating UI is purely a UX courtesy. See dynamic-ui.md for all / any / invert modes.

How do I emit an event when a record changes? ​

Declare the capability and the event topic:

json
"capabilities": [
  { "kind": "event:emit", "target": "ticket.*", "reason": "Notify state changes" }
],
"events": ["ticket.created", "ticket.resolved"]

In a webhook / WASM export, call the host's event API with { topic: 'ticket.resolved', payload: {…} }. The kernel checks the capability, persists the event, and fans out to subscribers.

For automatic events on every CRUD operation, use lifecycle_hooks:

json
"lifecycle_hooks": {
  "tickets": [
    { "event": "after_create",
      "target": { "type": "webhook", "url": "/webhooks/ticket_created" },
      "async": true }
  ]
}

How do I subscribe to events from another addon? ​

Declare the capability and pull events from the bus in your handler:

json
"capabilities": [
  { "kind": "event:subscribe", "target": "invoice.stamped" }
]

The publishing addon must declare events: ["invoice.stamped"] so the host knows the schema. Subscribe via the kernel's bus API in your WASM module or via a webhook the kernel calls when the event fires.

How do I show a different UI for create vs edit? ​

<DynamicRecordDialog> already swaps title and submit label per mode. If you need different fields, branch at the call site and render two distinct components (or two manifest models — one for the create funnel, one for editing the persisted record).

tsx
{mode === 'create'
  ? <FullCreationWizard onDone={refetch} />
  : <DynamicRecordDialog open mode="edit" model="tickets" recordId={id} />}

How do I add a soft-delete column? ​

json
{
  "table_name": "tickets",
  "soft_delete": true,
  "columns": [ /* … */ ]
}

The host adds a deleted_at timestamptz column, filters it out from default queries, and routes DELETE /data/tickets/<id> to a UPDATE … SET deleted_at = now(). No app-level wiring required.

How do I scope records per organization? ​

json
{
  "table_name": "tickets",
  "org_scoped": true,
  "columns": [ /* … */ ]
}

The host adds an organization_id column, sets a NOT NULL + index, applies a Postgres RLS policy, and stamps the column on insert. Cross-tenant reads are denied by the kernel even if a capability would otherwise allow them.

For regulated data prefer tenant_isolation: "schema-per-tenant" instead — see manifest-spec.md.

How do I bundle a frontend extension with my addon? ​

Declare a federation entry in the manifest:

json
"frontend": {
  "entry": "/api/metacore/addons/tickets/frontend/remoteEntry.js",
  "format": "federation",
  "expose": "./plugin",
  "container": "metacore_tickets"
}

Build the frontend with @originjs/vite-plugin-federation and a name matching container. The exposed module must export register(api: AddonAPI), which receives the host SDK and registers slot contributions, action handlers, navigation items, etc.

tsx
// frontend/src/plugin.tsx
import type { AddonAPI } from '@asteby/metacore-sdk'

export function register(api: AddonAPI) {
  api.slot.register('dashboard.widgets', RevenueWidget, { priority: 10 })
  api.action.register('tickets', 'reassign', ReassignDialog)
  api.nav.add({ key: 'tickets', label: 'Tickets', to: '/m/tickets' })
}

The host loads it via <AddonLoader> from @asteby/metacore-runtime-react.

How do I test my addon locally? ​

bash
metacore validate         # static checks: regex, semver, capabilities, defaults
metacore build --strict   # produces my-addon-0.1.0.tar.gz
metacore inspect *.tar.gz # prints manifest + migrations + bundle sizes

Run a host with a file: reference to your addon directory and reload — the kernel re-runs AutoMigrate on every restart in dev. Webhooks pointed at http://localhost:7101/webhooks/... work straight through; for WASM, use metacore compile-wasm to produce a fresh backend/backend.wasm before reloading.

How do I prefetch metadata at app boot? ​

tsx
import { useMetadataCache } from '@asteby/metacore-runtime-react'

function PrefetchMetadata() {
  const { prefetchAll } = useMetadataCache()
  const api = useApi()
  useEffect(() => { prefetchAll(api) }, [api])
  return null
}

prefetchAll issues a single GET /metadata/all and seeds both the table and modal caches. Subsequent <DynamicTable> mounts render with no network round-trip. The cache is namespaced by metadataVersion — when the kernel bumps it, the cache invalidates automatically.

Declare an action with type: "link" and a linkUrl template:

json
{
  "key": "open_invoice",
  "label": "Open invoice",
  "icon": "ExternalLink",
  "type": "link",
  "linkUrl": "/invoices/{invoice_id}"
}

Tokens like {invoice_id} are replaced with the row's value before navigation. <DynamicTable> recognises type: "link" and uses the host's TanStack Router navigate() instead of opening a modal.

How do I gate an action on the row's current state? ​

Use condition to hide the action from the dropdown when the row doesn't match, and requiresState to assert the same on the server:

json
{
  "key": "resolve",
  "label": "Resolve",
  "icon": "CheckCircle2",
  "confirm": true,
  "condition": { "field": "status", "operator": "in", "value": ["open", "in_progress"] },
  "requiresState": ["open", "in_progress"]
}

condition filters the dropdown client-side (eq, neq, in, not_in); requiresState makes the kernel reject stale executions where the row state changed between fetch and click.


Have a recipe to add? Send a PR — recipes live in this file as a flat list, no nesting.

Metacore is open-source. Apache-2.0.