Skip to content

Data model

Every table carries a site_id. That column is the tenant boundary: queries are always scoped by it, and the API refuses cross-site reads even when an ID is guessed correctly.

IDs are UUIDs generated on insert. Content additionally has a numeric content_id for humans — the #111 you see in the list table.

One row per record of any type.

Column Type Notes
id text (UUID) Primary key
content_id integer Human-facing number, auto-increment
site_id text Tenant
type text Collection name — posts, pages, insights…
slug text URL segment, unique within a type
title text
body text HTML
status text publish · draft · trash
meta JSON Terms, excerpt, featured image, custom field values
created_at / updated_at timestamp

Metadata for files stored in R2.

Column Type Notes
id text (UUID)
site_id text
key text Object key in R2, e.g. demo/2316-hero.webp
url text Public URL
filename text Original name
mime_type text
alt / caption text
width / height integer For images
size_bytes integer
source_url text Where it was imported from, when applicable
Column Type Notes
email text Identity used to sign in
site_id text
name text Display name
role text Matches a row in roles.name
active boolean
created_at timestamp
Column Type Notes
id text (UUID)
site_id text
name text Machine name, e.g. staff
label text Shown in the UI
capabilities JSON array e.g. ["content.read","content.write"]
Column Type Notes
id text (UUID)
site_id text
name text Machine name, e.g. categories
label text
description text
hierarchical boolean Category-style parent/child
content_types JSON array Which types it applies to
Column Type Notes
id text (UUID)
site_id text
taxonomy text FK-ish to taxonomies.name
term_key text Original external id, when imported
name / slug text
description text
Column Type Notes
id text (UUID)
site_id text
name text Becomes content.type
label text
fields JSON array { name, label, type, options }
taxonomies (1) ──< terms
│ │
└── content_types └── referenced from content.meta.terms[] { taxonomy, id, name, slug }
collections (1) ──< content via content.type = collections.name
roles (1) ──< users via users.role = roles.name
media (1) ──< content via content.meta.featured_image

Term attachments live inside content.meta.terms as an array of small objects. That is why renaming a term has to propagate: the name is copied into every record that references it.