One CollectionConfig, Three API Surfaces
Every Aeion collection is declared once and exposed three ways — auto-generated REST endpoints, auto-generated MCP tool actions, and optionally GraphQL fields. The schema engine compiles a TypeScript collection definition into Postgres tables, Zod validators, admin UI renderers, and the agent-callable tool surface. The 22 field types below are the primitives the schema engine knows about.
The Shape of a Collection
```typescript import type { CollectionConfig } from "@aeion/types/schema";
export const Products: CollectionConfig = { slug: "products", labels: { singular: "Product", plural: "Products" }, admin: { group: "Commerce", useAsTitle: "name", defaultColumns: ["name", "price", "stockLevel", "status"], }, timestamps: true, // adds createdAt / updatedAt softDelete: true, // adds deletedAt + filters soft-deleted from defaults
fields: [ { name: "name", type: "text", required: true }, { name: "price", type: "number", required: true, min: 0 }, { name: "description", type: "richText" }, { name: "category", type: "relationship", relationTo: "product_categories", }, { name: "tags", type: "tags" }, { name: "metadata", type: "json", storage: "jsonb" }, { name: "status", type: "select", options: ["draft", "active", "archived"], defaultValue: "draft", }, ],
access: { read: ({ user }) => Boolean(user), create: ({ user }) => user?.role === "admin", update: ({ user, doc }) => doc.createdBy === user?.id || user?.role === "admin", delete: ({ user }) => user?.role === "admin", }, }; ```
What that one definition produces:
- A Postgres table named
products(withtenantId,id,createdAt,updatedAt,deletedAt, plus every field above) - 5 REST endpoints —
GET /api/v1/products(list),GET /api/v1/products/:id,POST,PATCH,DELETE - 5 MCP tool actions —
list_products,read_product_by_id,create_product,update_product,delete_product - A typed admin UI surface with the listed columns, edit form, search, and filter UI
- Zod input validators run automatically on create / update
- Tenant scoping on every query (Layer 0 enforces it)
- Optional GraphQL exposure (toggle per collection)
The 22 Field Types
Text (5)
typescript
{ name: "title", type: "text", required: true, maxLength: 200 }
{ name: "bio", type: "textarea", rows: 5 }
{ name: "contactEmail", type: "email" } // validates RFC 5322 + DNS optional
{ name: "homepage", type: "url" } // validates URL parse
{ name: "body", type: "richText" } // ProseMirror JSON, exports to HTML / Markdown
Numbers (3)
typescript
{ name: "price", type: "number", min: 0, max: 1000000 }
{ name: "publishedAt", type: "date" } // ISO 8601 string in transit, Postgres timestamptz at rest
{ name: "stars", type: "rating", max: 5 }
Selection (7)
typescript
{ name: "subscribed", type: "checkbox" } // boolean — single checkbox
{ name: "featured", type: "switch" } // boolean — toggle UI
{ name: "status", type: "select", options: ["draft", "active", "archived"], defaultValue: "draft" }
{ name: "tier", type: "radio", options: ["free", "pro", "enterprise"] }
{ name: "tags", type: "tags" } // freeform multi-string with auto-suggest from prior values
{ name: "interests", type: "checkboxGroup", options: ["news", "products", "events"] }
{ name: "color", type: "color" } // HSL / hex picker UI
Relations (2)
```typescript { name: "category", type: "relationship", relationTo: "product_categories" } { name: "category", type: "relationship", relationTo: "product_categories", hasMany: true } { name: "author", type: "relationship", relationTo: ["users", "external_authors"] } // polymorphic
{ name: "hero", type: "upload", relationTo: "media", mimeTypes: ["image/"] } { name: "gallery", type: "upload", relationTo: "media", hasMany: true, mimeTypes: ["image/", "video/*"] } ```
Structured (4)
```typescript { name: "address", type: "group", fields: [ { name: "street", type: "text" }, { name: "city", type: "text" }, { name: "zip", type: "text" }, ]}
{ name: "phoneNumbers", type: "array", fields: [ { name: "label", type: "select", options: ["home", "work", "mobile"] }, { name: "value", type: "text" }, ]}
{ name: "pageLayout", type: "blocks", blocks: [ { slug: "hero", fields: [...] }, { slug: "callout", fields: [...] }, { slug: "twoColumn", fields: [...] }, ]}
{ name: "metadata", type: "json", storage: "jsonb" } // arbitrary JSON, Postgres jsonb under the hood ```
Advanced (1)
typescript
// Computed — derived field. Not stored; calculated on read.
{
name: "displayName",
type: "computed",
compute: (doc) => ${doc.firstName} ${doc.lastName}.trim(),
}
What Every Field Gets For Free
Validation
A Zod schema is auto-derived from the field definition, with type-specific validators (email format, URL parse, min/max, length, regex pattern) built in. Custom validators drop in via a simple `validate` function when you need business-specific rules.
Postgres mapping
Column type, indexes, default value, nullability, and foreign keys for relationships are all handled for you. Migrations are generated declaratively — no hand-written SQL migration files to maintain.
Admin UI
Every field type ships a typed React renderer — text inputs, rich-text editors, select dropdowns, drag-and-drop file pickers, searchable relationship pickers, JSON tree editors — plus inline-edit support per column, out of the box.
REST input/output
Sparse fieldsets, filter operators, cursor and offset pagination, and depth-controlled relationship population all come free on every collection's REST endpoints.
MCP tool actions
Each collection auto-exposes list/read/create/update/delete actions on its tenant-scoped MCP tool, with Zod-validated input — agents can call them under the exact same RBAC the requesting user has.
GraphQL fields
Optional — toggle GraphQL exposure per collection in the schema config whenever you need graph-shaped queries alongside REST.
Webhooks
Every write auto-emits a created/updated/deleted event tagged with the collection slug, so subscribers can filter by collection without any extra wiring.
Versioning
Optional — when enabled, every write captures a versioned snapshot you can query as of any past timestamp, turning "what did this look like last week" into a single query.
Soft delete
Optional — deleted rows are marked, not destroyed. Default queries exclude them automatically, and a single query flag brings them back into view when you need to recover or audit.
Aegis backup
Universal and automatic. Every collection's table participates in backup snapshots and point-in-time recovery — there's no per-collection opt-in to forget.
Access Control — Four Hooks Per Collection
Every collection declares four optional access hooks. Returning true grants permission; returning a query fragment narrows the query; returning false denies.
```typescript access: { // Pre-query — what rows can this user list / read? read: ({ user, tenant }) => user?.role === "admin" ? true : { authorId: { equals: user?.id } },
// Pre-write — can this user create this kind of doc? create: ({ user, data }) => user?.role === "admin" || (user?.role === "editor" && data.status === "draft"),
// Pre-write — can this user update this specific doc? update: ({ user, doc }) => doc.authorId === user?.id || user?.role === "admin",
// Pre-write — can this user delete this doc? delete: ({ user, doc }) => user?.role === "admin", }, ```
Access hooks run _before_ every query — for list operations, the framework merges the returned filter fragment into the WHERE clause. There's no "load row first then check" race condition; if a row isn't permitted, it never appears in results.
Extension Gating — SaaS Feature Control
Collections can declare which platform extensions a tenant must own to see them. The check happens _before_ access hooks; unauthorized tenants get an unsatisfiable filter ({id: {equals: "__no_access__"}}), so the collection effectively disappears.
typescript
extension: {
moduleId: "aeion-events",
extensionId: ["events.pro", "events.enterprise"], // OR — any of these grants access
},
This is how Aeion's pricing tiers gate features without code-level branching. The same collection definition serves Free / Pro / Enterprise — the platform handles tier enforcement transparently.
Table Prefixing — Module Isolation
| Slug | tablePrefix | Resulting table |
|---|---|---|
| `budgets` | `production` | `production_budgets` |
| `production_assets` | `production` | `production_assets` (no double) |
| `users` | (none) | `users` |
Frequently Asked Questions
Three reasons. (1) **Three surfaces, one definition** — REST + MCP + admin UI all derive from the same source of truth, so they can't drift. (2) **RBAC integration** — access hooks are part of the same definition, enforced uniformly across all surfaces, so an MCP tool can't accidentally expose data the REST API hides. (3) **Tenant scoping** — every query is automatically scoped to the calling tenant at Layer 0; you can't write a query that forgets it.
Yes via the field plugin API. Register a custom type with a Zod validator + admin renderer + Postgres column-type mapping. Custom types appear in the field picker for collection authors and behave like first-class field types.
`array` is a homogeneous list — every entry has the same field shape (e.g., a list of phone numbers, each with a `label` + `value`). `blocks` is heterogeneous — entries can be different "block types" with different field sets (e.g., a page-layout field where each block is a hero / callout / two-column variant). Use `blocks` for content modeling (Notion-style pages, page builders); use `array` for typed lists.
`textarea` is plain string. `richText` is a structured ProseMirror JSON document supporting bold / italic / links / lists / embedded blocks. Stored as JSONB. Auto-rendered to HTML or Markdown for export.
Computed values are derived on read — they don't sit in the table, so you can't filter on them at the SQL layer. For filterable derived fields, use a real column + a hook that recomputes on write (Aeion supports `beforeChange` collection hooks for this exact pattern).
Aeion auto-generates a migration in `db:migrate:generate`. Lossy changes (e.g., `number` → `text` with format conversion needed) require an explicit data migration step the operator approves. The Aegis pre-destructive guard refuses destructive Drizzle diffs against non-empty tables unless you pass `--force-destructive` (which auto-snapshots first).
Tested to ~200M rows per tenant on a single Postgres 17 instance with appropriate indexes. Above that, partition by tenant or by time. The cursor pagination model scales linearly; offset pagination degrades past ~1M rows.
Yes — hooks receive the full `AeionContext` with `ctx.services`, `ctx.db`, `ctx.events`. Common pattern: `read` hook calls a billing service to check active subscription before returning data. Be aware that hooks run on every query, so service calls should be cached or use materialized derivations.
One Schema, Three Surfaces
Declare it once. Get REST, MCP, admin UI, validation, RBAC, tenant scoping, and Aegis backup for free.