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 (with tenantId, 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

SlugtablePrefixResulting 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.

Composes with the rest of the OS