Collections — the auto-generated API engine

Declare a collection — a name plus a set of typed fields — and the framework gives you a full REST resource over the embedded database: create, read, list, update, delete, and a baseline search. No hand-written endpoints, no ORM, no external dependencies. The engine is the akurai-collections crate.

The schema model

A collection is a name and an ordered list of fields. Each field declares:

| Property | Meaning | |------------|---------------------------------------------------------------------| | name | The field's key in the JSON record. Must be unique in the collection. | | kind | One of text, int, float, bool, relation, file. | | required | When true, the field must be present and non-null on create. | | embed | Marks a text field for later semantic indexing (see below). |

The CLI builds these schemas from your collections.toml at startup. The engine itself is decoupled from TOML — it consumes plain schema values.

Field types

Relations below). The stored value is the referenced record's integer id; the collection property names the target.

{ blob, filename, content_type, size } (or null when unset); the bytes live in the content-addressed blob store. Files arrive over multipart/form-data, not in a JSON body — see Uploads and the auto-API download endpoint.

Reserved fields the engine owns

Every stored record carries two engine-assigned keys that your input may not set:

(even after deletes).

A stored record is a JSON object whose first two keys are always id and created, followed by the schema fields you supplied, in declaration order. Optional fields you omit are left out of the record entirely (not stored as null).

Record key layout

Records live in the B+tree under a per-collection prefix:

coll:<name>:<id>     <id> is a big-endian u64   →  the record (JSON bytes)
coll:<name>:_seq     the auto-increment counter (big-endian u64, last id used)

Because ids are fixed 8-byte big-endian, they sort numerically, so a range scan over the prefix returns records in insertion order. The _seq counter shares the prefix but has a different key length, so scans filter on the exact record key length and never confuse the two. Each collection has its own counter, so ids in posts and tags are independent.

The REST endpoints

For a collection named posts, the framework exposes:

| Method & path | Action | |--------------------------------------------|---------------------------------| | GET /api/collections/posts/records | List records (newest first). | | POST /api/collections/posts/records | Create a record from a JSON body. | | GET /api/collections/posts/records/<id> | Fetch one record by id. | | PATCH /api/collections/posts/records/<id>| Partially update a record. | | DELETE /api/collections/posts/records/<id>| Delete a record. | | GET /api/collections/posts/records?search=<query> | Substring search. |

list and search return newest-first (highest id first) and accept an optional limit.

These same descriptors are emitted by the engine's manifest and merged into the framework-wide /api/_meta document, so clients can discover every collection, its fields, and its endpoints at runtime.

Validation rules

The engine never panics on bad input — it returns a validation error.

On create:

where a float is expected and coerced).

On update (PATCH): the patch is partial — only the keys you send change.

Search

?search=<query> is an honest baseline: a case-insensitive substring match across the collection's text fields. A record matches if any of its text fields contains the query. Non-text fields are never searched. An empty query matches everything. Results are newest-first and honor limit.

This is a deliberate, predictable baseline — semantic ranking is layered on top by the framework's vector layer, not by this engine.

Relations

A relation field references a record in another collection by its id. The field stores a plain integer (the referenced record's id); the schema records which collection it points at.

Declaring a relation

In collections.toml, a relation field is type = "relation" plus a collection key naming the target:

[[collections]]
name = "authors"
[[collections.fields]]
name = "name"
type = "text"
required = true

[[collections]]
name = "posts"
[[collections.fields]]
name = "title"
type = "text"
required = true
[[collections.fields]]
name = "author"
type = "relation"
collection = "authors"   # the target collection

This maps to FieldKind::Relation("authors") in the engine. In /api/_meta, the field reports "type": "relation" and adds "collection": "authors":

{ "name": "author", "type": "relation", "required": false,
  "embed": false, "collection": "authors" }

Validation

anything else is a validation error. A relation field can be required and is cleared with null like any other optional field.

type-checks the id — it does not verify the referenced record exists, so a dangling id is accepted. To enforce existence, the caller uses the checked path (create_checked / update_checked), passing the set of known collections. If the relation's target collection is in that set, the referenced id must exist (else a validation error); if the target is not in the set, the id is accepted without an existence check. This lets the CLI enforce integrity for collections it knows while staying lenient about cross-store or not-yet-loaded targets.

Expansion — ?expand=

By default a record carries only the relation id. Pass ?expand=<field> to inline the referenced record alongside it. For each named relation field, the engine looks up the referenced record in its target collection and adds a sibling key <field>_expanded holding the full target record (the original id key is left untouched):

GET /api/collections/posts/records/1?expand=author
{
  "id": 1,
  "created": 1750000000,
  "title": "Hello",
  "author": 3,
  "author_expanded": { "id": 3, "created": 1749990000, "name": "Ada" }
}

Rules:

target collection isn't known expands to "<field>_expanded": null.

_expanded key is added for them).

?expand= is supplied. Multiple fields can be expanded (?expand=author&expand=editor).

The embed flag

Setting embed on a text field marks it for semantic indexing. The collections engine stores and exposes the flag (it appears in /api/_meta) but does not embed anything itself. The CLI's vector crate reads the flag and builds the semantic index on top of the records this engine stores. So embed is a contract between your schema and the semantic layer — the substring ?search= above works regardless.