Skip to content

Data Model

Everything you store in LyEve is an entry of a content type. The content type is the shape: a name, a list of fields and a few switches. An entry is one item written in that shape, such as one blog post. This page builds a small blog, a post that belongs to an author, and uses it to explain each piece.

TermWhat it isIn the blog
Content typeThe shape of one kind of contentauthor and post
FieldOne value in that shape, with a typetitle, body, published_on
RelationA field that links to an entry of another content typea post's author
EntryOne item written against a content typethe post "Hello, world"
StatusWhether an entry is a draft, published or archiveddraft
RevisionA kept earlier version of an entrythe post before its last edit

Build a content type in the admin console's Schema builder, or send its definition to POST /api/admin/schemas. The blog needs two. The author comes first, because the post points at it:

{
"name": "author",
"display_name": "Author",
"fields": [
{ "name": "name", "field_type": "text", "required": true },
{ "name": "email", "field_type": "email", "unique": true }
]
}
{
"name": "post",
"display_name": "Post",
"with_draft_publish": true,
"with_soft_delete": true,
"fields": [
{ "name": "title", "field_type": "text", "required": true, "indexed": true },
{ "name": "body", "field_type": "rich_text" },
{ "name": "published_on", "field_type": "date" },
{ "name": "author", "field_type": "relation", "relation_to": "author", "relation_type": "belongs_to" }
]
}
KeyMeaning
nameLetters, digits and _, starting with a letter or _, at most 63 characters. Any other name answers 422 and changes nothing.
display_nameThe label the admin console shows.
fieldsThe fields, described in the next section.
with_draft_publishEach entry carries a status. See Statuses.
with_soft_deleteA delete hides the entry instead of removing it. See Soft delete.
with_created_at, with_updated_atLists that timestamp among the fields. The admin console shows an entry's Updated time only when with_updated_at is set.

Every entry gets an id, a created_at and an updated_at whether or not you list them. Leave out with_localization: a content type that sets it reads back with a deprecations list, and Localization translates entries field by field instead.

Content types belong to a tenant. Two tenants can each define a post with different fields, and neither sees the other's. Reading content types takes any signed-in role. Creating, changing and deleting them takes admin or super_admin.

KeyMeaning
nameThe field's name, with the same rules as the content type's name.
field_typeThe kind of value, from the table below. Required: a field without one answers 422.
requiredA write without a value answers 422.
uniqueA value another entry of the same content type already holds in your tenant answers 409.
indexedFiltering and sorting on the field is fast. It changes nothing about what you write.
defaultFilled in when a write leaves the field out.
validationRules checked on every write, described below.
field_typeWhat it holdsWhat you send
textPlain textA string
rich_textFormatted textA string of HTML. The admin console removes unsafe markup when it saves. The Content API stores the string as sent, so sanitize it where you render it.
emailAn email addressA string. One that is not an address answers 422.
urlA linkA string. Add the url rule below to refuse one that is not a URL.
numberA numberA JSON number
booleanYes or notrue or false. Anything else answers 422.
dateA calendar date"2026-10-01"
datetimeA moment in timeAn RFC 3339 timestamp, such as "2026-10-01T09:00:00Z"
jsonStructured dataAny JSON value
mediaA fileA string that locates the file. The admin console stores the file's public link from the media library.
relationA link to another entrySee Relations.
uidA unique identifierA UUID string

validation is a list of rules. Each names a rule and its params:

RuleParamsRefuses
email, urlnoneA value that is not an address or a URL
regexpatternA value the pattern does not match
enumvaluesA value not in the list
min, maxmin or maxA number below or above it, or a string shorter or longer than it
rangemin and maxAnything outside both bounds

A post summary limited to 160 characters would be { "name": "summary", "field_type": "text", "validation": [{ "rule": "max", "params": { "max": 160 } }] }.

A content type can also carry cross_field_validation rules that name two fields in targets. required_with refuses a write that fills the second field and leaves the first empty. lt_field, gt_field, lte_field and gte_field compare two number fields, such as a max_age that must be at least the min_age. A write that breaks any rule answers 422 with an errors list naming each field, and nothing is stored.

A relation field takes relation_to, the content type it points at, and a relation_type. In the blog, post.author is a belongs_to.

relation_typeWhere the link is storedWhen the other entry is deleted
belongs_toOn this entry. It points at one entry of the other type.This entry is deleted too when the field is required. Otherwise the link is cleared.
many_to_manyBeside both entries. Any number on each side.Only the pairing goes. The entry it was paired with stays.
has_one, has_manyOn the other side, in a belongs_to field there that points back here.Follows that belongs_to.
  • A belongs_to is the only kind where required means anything.
  • A value sent for a has_one or has_many is ignored. Set the link on the other entry. To list an author's posts, give author a has_many field with relation_to: "post".
  • A has_one or has_many finds the other side through a belongs_to named after this content type. The blog's works because the post's field is called author. A field called writer would leave the author's list empty.
  • Nothing enforces the "one" of a has_one. When several entries point here, the first is read.
  • unique and indexed are ignored on a relation.

On the Content API, a belongs_to reads back as the linked entry's id under <field>_id, such as author_id. Add ?populate=author to a read to get the author's fields in its place. populate takes a comma-separated list, and a dot path such as author.team follows a relation of the related entry. ?depth=2 follows every relation two levels down. A many_to_many is read with GET /api/v1/content/{type}/{id}/relations/{field} and replaced with PUT on the same path and {"ids": ["<id>", "<id>"]}.

Relations behave the same on PostgreSQL, MySQL and SQL Server.

Your site or app writes entries through the Content API, with the field values under data. You need a token in TOKEN. The quickstart shows how to get one.

Terminal window
curl -X POST http://localhost:3002/api/v1/content/author \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"data": {"name": "Ada Lovelace", "email": "ada@example.com"}}'
{
"id": "ce7168fd-648d-4fee-830f-db1a65b8e2af",
"schema_name": "author",
"data": { "id": "ce7168fd-648d-4fee-830f-db1a65b8e2af", "name": "Ada Lovelace", "email": "ada@example.com" },
"created_at": "2026-10-02T05:05:16.951534Z",
"updated_at": "2026-10-02T05:05:16.951534Z"
}

A post sends the author's id in its author field:

Terminal window
curl -X POST http://localhost:3002/api/v1/content/post \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"data": {"title": "Hello, world", "body": "<p>First post.</p>", "published_on": "2026-10-01", "author": "ce7168fd-648d-4fee-830f-db1a65b8e2af"}}'

Reading it with ?populate=author returns the author inside the post:

Terminal window
curl "http://localhost:3002/api/v1/content/post/$POST_ID?populate=author" \
-H "Authorization: Bearer $TOKEN"
{
"id": "909f3fb8-64ca-4a5e-a724-d60dd332e0b7",
"schema_name": "post",
"data": {
"title": "Hello, world",
"body": "<p>First post.</p>",
"published_on": "2026-10-01",
"author_id": "ce7168fd-648d-4fee-830f-db1a65b8e2af",
"author": {
"id": "ce7168fd-648d-4fee-830f-db1a65b8e2af",
"name": "Ada Lovelace",
"email": "ada@example.com",
"created_at": "2026-10-02T05:05:16.951534Z",
"updated_at": "2026-10-02T05:05:16.951534Z"
},
"_status": "published",
"deleted_at": null
},
"created_at": "2026-10-02T05:05:20.792407Z",
"updated_at": "2026-10-02T05:05:20.792407Z"
}

A list is GET /api/v1/content/{type}, newest first, 25 entries at a time by default and at most 200, with limit and offset. ?filters[<field>]=<value> keeps the entries whose field equals the value.

The admin console writes entries through the Admin API instead, at /api/admin/content. There an entry also carries a title, a slug and a status, and its field values travel under body. Both write the same entries, and an entry written through the Admin API is served by the Content API under the same id.

Content API, /api/v1/content/{type}Admin API, /api/admin/content
Used byYour site, your app, your scriptsThe admin console, admin scripts
Field valuesUnder dataUnder body, beside title, slug and status
Who may writeeditor, admin, super_adminadmin, super_admin
Unsafe HTMLStored as sentRemoved on save
RevisionsA copy before every updateNumbered, from 1

On the Admin API the title and the slug are required. A slug is unique across the whole tenant, not only within one content type, so a second entry with the same slug answers 409 and names the content type that holds it. Content lifecycle covers that API in full.

A content type with with_draft_publish gives each entry a status. The blog's post has one. The author does not, so every author is live.

StatusMeaning
draftWork in progress. A new entry in the admin console starts here.
publishedLive. Each time an entry moves to published, the Admin API records the time in published_at.
archivedRetired, but kept.

On the Content API:

  • An entry created there is published.
  • PUT /api/v1/content/{type}/{id}/unpublish makes it a draft and PUT /api/v1/content/{type}/{id}/publish publishes it again. Both answer 204.
  • A list returns published entries only. Add ?filters[_status]=draft to list the drafts.
  • A read by id returns the entry whatever its status, so a status never hides an entry from a caller who knows its id. Use access rules for that.

The admin console and the Admin API set the status directly and can schedule a publish. See Drafts and publishing for the console and Content lifecycle for the API.

A content type with with_soft_delete keeps a deleted entry and hides it from every read: a list leaves it out and a read by id answers 404. Without the switch, a delete removes the entry. The blog's posts are soft deleted, so a post deleted by mistake is still in your database.

LyEve keeps earlier versions of an entry, and each API keeps them its own way.

  • Admin API and admin console. Every write keeps a numbered revision: the title, the body, the metadata, the status and a change note. Revisions start at 1, never change once written, and can be compared two at a time. Restoring one writes a new revision with the old content, so history only grows.
  • Content API. Every update first keeps a copy of the entry as it was. GET /api/v1/content/{type}/{id}/revisions lists the copies, newest first, and PUT /api/v1/content/{type}/{id}/revisions/{revision_id}/restore writes one back. The restore keeps a copy of what it replaced.

Every revision is kept on every install. A free install reads the last 30 days of them and each entry's newest revision, and a license with content-pro reads them all. See Content lifecycle.

Send the definition again and the content type changes to match. Only what differs changes, and existing entries keep their values. Removing a field keeps its data until a super admin applies the removal, so a mistaken edit loses nothing. Schema changes covers previewing, renaming and applying those changes.