Accepted
ADR 0020 — Collections and field-definitions data layer
The content-type data model — collections, fields, and how they version
Cusp lets an editor define content types from the admin — a Recipe with a cooking time, an ingredients list, and a body — without touching code or running a deploy. That capability rests on two D1 tables and one repository pair. This decision pins their shape.
Decision
A content type is a collection (a row in collections) plus an ordered set of fields (rows in field_definitions). Both tables are versioned entities: every mutation runs through the versioned-write helper, which records an entity_versions snapshot in the same flow as the live-row write. Direct UPDATE statements on either table are forbidden, and caught by the no-direct-versioned-write ESLint rule and a runtime guard.
The repositories live at src/shared/db/repos/collections.ts and src/shared/db/repos/field-definitions.ts. Their TypeScript types — Collection, FieldDefinition, FieldType, and the create/update input shapes — are exported from src/shared/collections/types.ts, the one canonical import path for the content model.
Field types
The v1 whitelist is fixed at the data layer so the admin form, the dynamic REST surface, and the schema export all agree on one source:
- text — a single-line string
- richtext — formatted prose
- number — a numeric value
- boolean — a true/false toggle
- date — a date value
- media — a reference to an uploaded asset
- reference — a link to another document
- repeater — a repeating group of sub-fields (e.g. recipe ingredients). The document editor edits the entries; defining a new repeater field's sub-field schema in the field builder is still gated as experimental
- group — a nested set of fields (persists, but editing is gated as experimental)
- block — a block sequence inside a field (persists, but editing is gated as experimental)
repeater ships a value editor in the document form: it infers the sub-field columns from the existing entries (or lets you name the first one) and supports add / remove / reorder, round-tripping an array of string-keyed objects. group and block still have no shipped editor. For all three the server rejects creating or transitioning a field into that type in the content-type builder (assertFieldTypeEditable) — the visual sub-field-schema builder is the deferred piece. Each field's configuration is stored in config_json with the standard 200 KiB R2 spillover, the same threshold every versioned entity uses.
Deletion and reordering both version every field
Deleting a collection requires it to be empty of documents (otherwise a 409). It then writes one final entity_versions snapshot per field before the foreign-key cascade removes the live rows, so each field's history records the moment of its deletion. Reordering fields writes one snapshot per field whose position changed, tagged with a shared reorder identifier so the version-history UI can group the event. Both are sequential versioned writes — one live mutation plus its snapshot per batch — which keeps every write inside the 999-parameter D1 bind limit and is honest about the per-field atomicity D1 actually offers.
Reserved permalink prefixes
Collection permalink patterns are validated at the repository boundary, not just the route, so any future caller (an import workflow, a plugin) is held to the same rule. Patterns that start with a reserved prefix — /api, /admin, /login, /logout, /setup, /healthz, /_, or /.well-known — are rejected so a collection cannot shadow a core route.
Consequences
Everything downstream inherits a single repository pair with a stable read shape: fields always come back ordered by position, R2 spillover is resolved inside the repo so callers never see config_r2_key, and every mutation has per-field version provenance. The cost is that a large reorder or cascade-delete produces many sequential writes — bounded and comfortable for realistic field counts, and the correct trade for an honest audit trail.
Related: the document data shape explains how a document stores values against these field definitions.