Skip to content

Dynamic CRUD ​

The kernel turns manifest.models[] into a working REST + UI layer without any per-table code. This page explains how, from both sides — what the runtime does on the backend, and what the SDK does on the frontend.

Why it's worth a concept page ​

CRUD is a solved problem at the request-handler level — but solving it once per table gets old fast. Dynamic CRUD means:

  1. The runtime reads metadata at startup (or after each install).
  2. It mounts a generic store + handler that knows how to serve any table the metadata describes.
  3. The frontend reads the same metadata and renders forms / tables that match.
  4. Adding a column requires zero code on either side.

This is the core leverage Metacore provides. Every other piece — actions, slots, lifecycle — extends this loop.

The backend half ​

What gets mounted ​

The kernel mounts a fixed, model-centric set of routes (the host wires them under a prefix — typically /api):

GET    /api/metadata/table/:model
GET    /api/metadata/modal/:model
GET    /api/metadata/all

GET    /api/dynamic/:model?page=&size=&sort=&filter=
GET    /api/dynamic/:model/:id
POST   /api/dynamic/:model
PUT    /api/dynamic/:model/:id
DELETE /api/dynamic/:model/:id

POST   /api/dynamic/:model/:id/actions/:key
GET    /api/options/:model        # select-option lookups (host opts in via MountOptions)
GET    /api/search/:model         # typeahead search

There is one set of handler code in the kernel, regardless of how many addons or models exist.

What a list call does ​

A GET /api/dynamic/tickets?page=2&size=20&sort=created_at:desc&filter=status:open:

  1. Auth. The host's middleware has set the request identity on the context.
  2. Resolution. The router parses model=tickets.
  3. Capability check. Does the tickets addon have db:read on tickets? If not, 403.
  4. Permission check. Does the user have tickets.view? If not, 403.
  5. Tenancy scope. The store filters by org_id = ctx.OrgID automatically (multi-tenant by default).
  6. Query build. Filter and sort are validated against the manifest's column schema; unknown columns are rejected.
  7. Execute. A single SQL statement against the addon's database.
  8. Serialize. Every kernel response uses the envelope { "success": true, "data": ..., "meta": ... } — rows in data, pagination + total count in meta. Errors are { "success": false, "message": "<reason>" }. TS consumers read .data for the payload.
  9. Audit (optional). The call is logged via the kernel's audit hook.

Steps 3 and 4 are the gates that make Metacore safe by default. Steps 5 and 6 are why you don't write WHERE org_id = ? AND ... in your handlers.

What a write does ​

POST /api/dynamic/tickets with {"title":"...", "status":"open"}:

  1. Auth + resolution as above.
  2. Capability check. db:write on tickets?
  3. Permission check. tickets.create?
  4. Validation. Body is checked against the manifest column schema: types, required, max length, enum values, regex.
  5. Custom validators. Any Go-side validators registered on the addon run.
  6. Insert. Within a transaction; tenancy fields are auto-populated.
  7. Event emit. The runtime publishes tickets.changed on the WebSocket hub.
  8. Response. The created row, including server-generated fields.

Updates and deletes follow the same shape with the relevant verb-specific checks.

Where the data lives ​

The addon's tables are physical tables in the host's database, namespaced in the addon's schema (addon_<key>). Migrations are run inside a transaction; failed installs are rolled back atomically.

The frontend half ​

<DynamicTable> from manifest ​

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

<DynamicTable model="tickets" />

What the component does:

  1. Calls GET /api/metadata/table/tickets once, caches it (useMetadataCache).
  2. Builds a column config from the metadata: header label, cell style, sortability, filterability. The default getDynamicColumns handles every cell style the kernel emits — badge, avatar, phone, date, boolean, relation badges, media gallery, image, plus a text fallback.
  3. Calls GET /api/dynamic/tickets with the current page/sort/filter state.
  4. Renders the table — pagination, sorting, multi-column filters, row actions, create/edit/delete dialogs.
  5. Subscribes to model-change events on the WebSocket hub; invalidates the query on each event.

None of it is per-model code.

Customizing without escaping ​

<DynamicTable> accepts a getDynamicColumns prop — a pure function that turns metadata into TanStack ColumnDef[] — so you can extend or override the default cell rendering:

tsx
import { DynamicTable, makeDefaultGetDynamicColumns } from '@asteby/metacore-runtime-react'

<DynamicTable
  model="tickets"
  getDynamicColumns={makeDefaultGetDynamicColumns({
    overrides: { status: (col) => ({ ...col, cell: StatusBadge }) },
  })}
/>

When you need raw access, the runtime exposes useApi (the typed client over the kernel envelope), useOptions / useOptionsResolver (select lookups), and useMetadataCache; you drive your own TanStack Query calls and do the rendering.

Forms ​

<DynamicForm> is the create/edit counterpart:

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

<DynamicForm model="tickets" rowId={id} />

It reads the same metadata, builds a form from the model's columns, runs the same validators client-side, and submits to the kernel's CRUD endpoint. Custom inputs are pluggable per type, and you can register extra validators via registerValidator.

Where dynamic CRUD ends ​

The query layer now does more than flat list/get. It supports relation filters, group_by, aggregations and relation preloads — enough to back dashboards and grouped views without bespoke handlers. A few cases stay deliberately out of scope:

  • Arbitrary cross-addon joins. The dynamic store reads a model and its declared relations; an ad-hoc join across unrelated addons is a custom action.
  • Fully bespoke reports. Once a report needs domain math beyond what the aggregation API expresses, write it as an action or a host endpoint.
  • Bespoke layouts. When the metadata-driven UI fights the design, contribute a custom slot instead.

These are the right boundaries — dynamic CRUD is an 80% solution; the remaining 20% is where addons earn their keep.

Metacore is open-source. Apache-2.0.