Dynamic CRUD
The kernel turns manifest.tables[] 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:
- The runtime reads metadata at startup (or after each install).
- It mounts a generic store + handler that knows how to serve any table the metadata describes.
- The frontend reads the same metadata and renders forms / tables that match.
- 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
For each addon, the kernel mounts a fixed set of routes:
GET /api/addons/:id/_meta/columns
GET /api/addons/:id/_meta/columns/:table
GET /api/addons/:id/:table?page=&size=&sort=&filter=
GET /api/addons/:id/:table/:rowId
POST /api/addons/:id/:table
PATCH /api/addons/:id/:table/:rowId
DELETE /api/addons/:id/:table/:rowId
POST /api/addons/:id/_actions/:actionIdThere is one set of handler code in the kernel, regardless of how many addons or tables exist.
What a list call does
A GET /api/addons/tickets/tickets?page=2&size=20&sort=created_at:desc&filter=status:open:
- Auth. The host's middleware has set
kernel.Identityon the context. - Resolution. The router parses
addon=tickets,table=tickets. - Capability check. Does the
ticketsaddon havedb:readontickets? If not, 403. - Permission check. Does the user have
tickets.view? If not, 403. - Tenancy scope. The store filters by
org_id = ctx.OrgIDautomatically (multi-tenant by default). - Query build. Filter and sort are validated against the manifest's column schema; unknown columns are rejected.
- Execute. A single SQL statement against the addon's database.
- Serialize. Rows + pagination cursor + total count, JSON.
- 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/addons/tickets/tickets with {"title":"...", "status":"open"}:
- Auth + resolution as above.
- Capability check.
db:writeontickets? - Permission check.
tickets.create? - Validation. Body is checked against the manifest column schema: types, required, max length, enum values, regex.
- Custom validators. Any Go-side validators registered on the addon run.
- Insert. Within a transaction; tenancy fields are auto-populated.
- Event emit. The runtime publishes
tickets.changedon the WebSocket hub. - 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. Each table is namespaced by addon ID at schema or table-name level (configurable). Migrations are run inside a transaction; failed installs are rolled back atomically.
The frontend half
<DynamicTable> from manifest
<DynamicTable addon="tickets" table="tickets" />What the component does:
- Calls
GET /api/addons/tickets/_meta/columns/ticketsonce, caches it. - Builds a column config from the metadata: header label, cell type, sortability, filterability.
- Calls
GET /api/addons/tickets/ticketswith the current page/sort/filter state. - Renders the table.
- Subscribes to
tickets.changedon the WebSocket hub; invalidates the query on each event.
You get pagination, sorting, multi-column filters, row-click-to-edit, create button, delete confirmation, and real-time updates. None of it is per-table code.
Customizing without escaping
<DynamicTable> accepts overrides:
<DynamicTable
addon="tickets"
table="tickets"
columns={{
status: { renderCell: (v) => <Badge tone={v}>{v}</Badge> },
title: { width: '40%' },
}}
onRowClick={(row) => navigate(`/tickets/${row.id}`)}
toolbar={<MyExtraButtons />}
/>When overrides aren't enough, drop to the hooks layer:
import { useDynamicQuery, useDynamicMutation } from '@asteby/metacore-runtime-react'
const { rows, isLoading, page, setPage } = useDynamicQuery({
addon: 'tickets',
table: 'tickets',
filter: { status: 'open' },
sort: [{ col: 'priority', dir: 'desc' }],
})
const update = useDynamicMutation({ addon: 'tickets', table: 'tickets', op: 'update' })The hooks handle pagination, real-time invalidation, optimistic updates and permission gating; you do the rendering.
Forms
<DynamicForm> is the create/edit counterpart:
<DynamicForm addon="tickets" table="tickets" rowId={id} />It reads the same metadata, builds a form from columns[], runs the same validators client-side, and submits to the kernel's CRUD endpoint. Custom inputs are pluggable per type (e.g. a rich-text editor for text columns).
Where dynamic CRUD ends
A few cases are deliberately out of scope:
- Cross-addon joins. The dynamic store reads one table at a time. Joins are either denormalized into the manifest or implemented as a custom action.
- Aggregations. Reports and dashboards are addon-defined, not auto-generated.
- Bespoke layouts. When the metadata-driven UI fights the design, mount a custom slot instead.
These are the right boundaries — dynamic CRUD is an 80% solution; the remaining 20% is where addons earn their keep.
Related
- Manifest — the schema dynamic CRUD reads.
- Permissions — the gates on every CRUD call.
- SDK docs / DynamicTable ↗ — every prop, every hook.
- Kernel docs / dynamic store ↗ — the backend internals.