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.