Content Storage

August 3, 2026 · View on GitHub

The unified content store: every "row in a table" — blog posts, custom post types, product catalogs, form submissions, pages, Visual Components, arbitrary user-defined collections — lives in two tables: data_tables (the schema) and data_rows (the rows).

There are no other content tables. There is no pages table, no page_versions table, no posts table. Pages, posts, and components are all rows in data_tables + data_rows, distinguished only by the table's kind.


TL;DR

  • Two tables, five kinds. data_tables.kind: postType | data | page | component | layout.
  • Four system tables seeded at boot — pages (kind page), posts (kind postType), components (kind component), layouts (kind layout) — protected from rename / delete (but users can still add custom fields). listDataTables and listDataTablesWithCounts pin them at positions 0–3 in that order; custom tables follow sorted by created_at.
  • Every row's cells live in cells_json keyed by field id. slug and status are denormalized columns for index / route lookup.
  • Post-type rows have a workflow: draft | published | unpublished | scheduled, with a version history (data_row_versions) for the published copy.
  • "Data" tables are simple key-value grids — no workflow, no built-in fields.
  • Pages, components, and layouts are stored the same way: a pages table with pageTree-typed body cells; a components table with pageTree-typed body + fieldSchema-typed params; a layouts table with a pageTree-typed body snapshot plus serialized classes.
  • Source of truth: src/core/data/schemas.ts. Repos: server/repositories/data/. Handlers: server/handlers/cms/data/.

The two tables

data_tables

The schema for a collection. One row per collection.

ColumnTypeNotes
idtext PK
nametextHuman-readable
slugtextURL-safe (kebab-case)
kindtext'postType' | 'data' | 'page' | 'component' | 'layout'
singular_labeltext"Post"
plural_labeltext"Posts"
route_basetextEmpty = not publicly routable. An omitted create value defaults to /<slug>; an explicitly empty value stays empty through create, update, and import.
primary_field_idtextField id used as the row's display name in grids / pickers
fields_jsonjsonbDataField[] — the schema
systembooleantrue for seeded tables (posts, pages, components, layouts)
created_*, updated_*-Standard audit fields

data_rows

One row per content row.

ColumnTypeNotes
idtext PK
table_idtext FKdata_tables.id
cells_jsonjsonbRecord<fieldId, cellValue>
slugtextDenormalized from cells_json.slug for fast route lookup
statustext'draft' | 'published' | 'unpublished' | 'scheduled'
author_user_idtext FKThe post's author (nullable)
created_by_user_idtext FKWho created the row
updated_by_user_idtext FK
published_by_user_idtext FK
created_attimestamp
updated_attimestamp
published_attimestampNullable
scheduled_publish_attimestampSet when status = 'scheduled'; tick by publishScheduler.ts
deleted_attimestampNon-null = soft-deleted

data_row_versions

Snapshot of a published row at publish time. One row per version.

ColumnTypeNotes
idtext PK
row_idtext FKdata_rows.id
version_numberintMonotonic per row
cells_jsonjsonbSnapshot at publish time
created_attimestamp
created_by_user_idtext FKWho published this version

Used to render the currently-published page (vs. the in-progress draft on the row). The publish handler writes a new version on each Publish.


Five kinds

kindAuthored inBuilt-in fieldsWorkflowNotes
postTypeContent workspace (/admin/content)title, slug, body (text), featuredMedia, seoTitle, seoDescriptiondraft / published / unpublished / scheduled + versionsBuilt-in fields cannot be renamed or deleted, only enabled / disabled.
dataData workspace grid (/admin/data)nonenonePure user-defined fields. Like a database table.
pageSite workspace (/admin/site)title, slug, body (pageTree)same as postTypeEach row is a CMS page. body cell holds the NodeTree<PageNode>.
componentSite workspace, VC modename, slug, body (pageTree), params (fieldSchema), classIdsnoneEach row is a Visual Component. See docs/features/visual-components.md.
layoutSite workspace saved layoutsname, slug, body (pageTree), classesnoneEach row is a saved layout snapshot. See docs/editor.md → "Saved layouts".

System tables protect their kind:

  • posts is system: true, kind: 'postType' — cannot become a data table.
  • pages is system: true, kind: 'page' — cannot be renamed or deleted.
  • components is system: true, kind: 'component' — cannot be renamed or deleted.
  • layouts is system: true, kind: 'layout' — cannot be renamed or deleted.

Users can add their own custom fields to system tables.


Field types

DataFieldType (src/core/data/schemas.ts):

Field typecells_json[fieldId] shapeNotes
textstring | nullSingle-line
longTextstring | nullMulti-line
richTextstring | nullHTML (sanitized at publish)
numbernumber | null
booleanboolean | null
dateISO date string | nullDate only
dateTimeISO datetime string | null
selectoption id (string) | nullOptions stored on the field definition
multiSelectoption ids (string[])
urlstring | null
emailstring | null
mediasingle: mediaId | null; multi: string[]References media_assets
relationsingle: rowId | null; multi: string[]Relates to rows in another data_table
repeaterordered { id, cells }[]One-level structured item collection
pageTreeNodeTree<PageNode> JSONThe visual tree (pages, VC trees)
fieldSchemaJSON describing fieldsUsed by VCs to declare params

The DataField discriminated union (DataFieldSchema) carries type-specific fields (e.g. options on select, targetTableId on relation).

Reading cells safely

Cells are typed unknown at the schema level. Use the reader helpers in src/core/data/cells.ts:

readStringCell(cells, 'title')                  // → string ('' fallback)
readNumberCell(cells, 'price')                  // → number | null
readBooleanCell(cells, 'featured')              // → boolean
readStringArrayCell(cells, 'tags')              // → string[]
readRepeaterCell(cells, 'gallery')              // → { id, cells }[]
readTitleCell(cells)                            // → string  (reads 'title')
readSlugCell(cells)                             // → string  (reads 'slug')
readBodyCell(cells)                             // → string  (reads 'body')
readFeaturedMediaCell(cells)                    // → string | null
readSeoTitleCell(cells), readSeoDescriptionCell(cells)
readNodeTreeCell(cells, 'body')                 // → NodeTree<PageNode> | null
readFieldSchemaCell(cells, 'params')            // → DataField[] | null

Repeater definitions store their item schema in field.fields. RepeaterItemFieldSchema excludes repeater, pageTree, and fieldSchema, so v1 repeaters are one level deep. Stable item ids make reorder and duplicate operations deterministic; nested values remain keyed by their item field ids.

These do the boundary validation — handlers and modules read through them rather than typing cells.foo as string.

To compute the denormalized, URL-normalized slug for a row (empty string when the table has no slug field):

slugForTable(table, cells)   // → string  (applies slugFromTitle; empty for tables without a slug field)

This is the single source of truth for slug derivation used by all admin write paths (rows.ts, tables.ts). Pass the result directly to createDataRow / saveDataRowDraft.


Server side

Repositories

FileOwns
server/repositories/data/tables.tsCRUD on data_tables: list (system tables first: pages → posts → components → layouts, then custom by created_at), get, get-by-slug (indexed via data_tables_slug_active_idx), create, update, delete (system-protected)
server/repositories/data/rows/read.tsHydrated read queries: listDataRows, getDataRow, getDataRowMany (one IN-list query for bulk validation), getDataRowBySlug, countDataRows, listDataAuthorOptions
server/repositories/data/rows/mutations.tsSingle-row writes: create, save draft, soft-delete, move to table, update status / author. softDeleteDataRow returns the narrow DeletedRowSummary (not a full DataRow) — the row's deleted_at is null filter makes re-reading impossible, and callers only need id / tableId / slug / status / deletedAt.
server/repositories/data/rows/bulk.tsTransactional batch writes: createDataRowMany, saveDataRowDraftMany, softDeleteDataRowMany
server/repositories/data/rows/filter.tsOperator-object filter querying with pagination (listDataRowsWithFilter) — used by the plugin content surface
server/repositories/data/rows/search.tsCross-table slug search (searchDataRows) — used by the spotlight content provider
server/repositories/data/rows/schedule.tsScheduled-publish lifecycle: schedule, cancel, list due rows
server/repositories/data/rows/import.tsBundle-import upserts (id-preserving): upsertDataRow, insertDataRowIfAbsent, replaceDataRow
server/repositories/data/rows/mapper.tsInternal: hydrated SELECT builder + DataRowRow → DataRow mapper (not part of the public barrel)
server/repositories/data/rows/index.tsBarrel for the rows/ directory
server/repositories/data/publish.tsPublish persistence (persistDataRowPublish writes data_row_versions) + public-route lookups; the orchestration (lock, artefacts, cache bump) is server/publish/publishRow.ts
server/repositories/data/shared.tsShared helpers: userRefAt (typed accessor per prefix — unknown prefix is a compile error), userRefColumns / userRefJoin (SQL fragment builders — the single source for the four <prefix>_* user-ref join columns and LEFT JOIN clauses, spliced verbatim by both rows/mapper.ts and publish.ts), UserJoinColumns (interface for all four <prefix>_* column groups — always present via LEFT JOIN, null when no user matched)
server/repositories/data/index.tsBarrel for the whole data/ directory

All repository functions are dialect-naive ANSI SQL. JSON columns end in _json; the SQLite adapter auto-parses on read. See docs/reference/database-dialects.md.

Handlers

FileOwns
server/handlers/cms/data/Generic /admin/api/cms/data/tables[/:id] + /admin/api/cms/data/rows[/:id] endpoints
server/handlers/cms/pages.tspages read endpoint (raw DataRow list for the editor's loader). Writes go through the transactional site-document save (server/handlers/cms/siteDocument.ts) with explicit deleted-row ids — a saving client can never delete a page a sibling session created concurrently, because deletion-by-omission no longer exists
server/handlers/cms/components.tscomponents-specific endpoints
server/handlers/cms/layouts.tslayouts read endpoint for saved layout rows
server/handlers/cms/publish.tsPublish a row, write a version, emit publish.before/.html/.after hooks

Pages, components, and layouts have their own typed endpoints (because the editor mutates trees/snapshots, not arbitrary cells), but they still write to data_rows.


Conversions: row ↔ domain object

The data store holds cells_json keyed by field id. The editor and publisher work with strongly-typed Page and VisualComponent objects. The conversion layer lives in src/core/data/:

FileFunctionDirection
src/core/data/pageFromRow.tspageFromRow(row, table)DataRowPage (reads title, slug, body)
src/core/data/pageFromRow.tspageToCells(page)PageDataRowCells
src/core/data/componentFromRow.tsvisualComponentFromRow(row)DataRowVisualComponent
src/core/data/componentFromRow.tsvisualComponentToCells(vc)VisualComponentDataRowCells
src/core/data/fields.tsnormalizeDataTableFields(value)Tolerant parse of fields_json
src/core/data/fields.tsdataTableHasField(table, fieldId)
src/core/data/fields.tsisPostTypeBuiltInFieldId(fieldId)Identify the reserved post-type field ids

Handlers use these — repositories don't. Repositories return raw DataRow objects (with cells: DataRowCells); handlers convert to Page / VisualComponent before returning to the client.


Publishing flow

PATCH /admin/api/cms/data/rows/:id  { status: 'published' }


handlePublishRoute / publishDataRow

    ├─→ load the row (with draft cells)
    ├─→ insert into data_row_versions with current cells + version_number = next
    ├─→ update row: status = 'published', published_at = now, published_by_user_id, etc.
    ├─→ if dependent pages reference this row (loops / queries), republish them
    └─→ emit publish.after hook

The published SiteDocument is stored ONCE per full publish in site_snapshots (with a content hash used by the publish-status check); each page's data_row_versions row references it via site_snapshot_id and carries only its page-scoped runtime_assets_json. Readers reassemble the PublishedPageSnapshot from the join — that snapshot is the canonical audit record. After it's written, the publisher routes through the three-layer pipeline:

  • Layer A — the publisher renders every page (for postType rows, the matched entry template), runs the full applyPublishedHtmlPipeline, and writes the final HTML to uploads/published/<inactive-slot>/<route>.html — a fully-static page bakes a complete document, a page with dynamic nodes bakes a static shell with <instatic-hole> placeholders. The CSS bundles + runtime JS are baked into the same slot. After all pages are written the symlink current atomic-flips to the new slot. Served entirely from disk — no DB for HTML/CSS/JS.
  • Layer B — the in-memory render cache evicts lazily via bumpPublishVersion().
  • Layer C — when a page contains dynamic nodes (auto-detected from binding sources, loop sources, module flags, VC refs), the publisher emits <instatic-hole> placeholders in the rendered HTML; the hole runtime fetches each fragment lazily from /_instatic/hole/<nodeId>?v=<publishVersion>&u=<page-url>, forwarding the originating page path/query so route bindings and loop pagination resolve inside the fragment.

For postType rows, publishDataRow does the same but incrementally: writes the single row's artefact into the ACTIVE slot via tmp + rename (no full slot swap), bumps publishVersion, and removes the old slug's artefact if the slug changed.

See docs/features/publisher.md for the full pipeline.

For post-types, public row routes require an explicitly authored entry template (a pages row with template.target = { kind: 'postTypes', tableSlugs: [table.slug] }). When you publish a post, the renderer resolves the template chain for the entry route (resolveTemplateChain) and renders the merged tree with the post row pushed onto the entry stack as currentEntry. Dynamic bindings on the template nodes resolve currentEntry.title, currentEntry.body, etc. Without a matching entry template, the published row exists in the CMS but its public detail URL returns 404. See docs/features/templates.md for the full template model.

Scheduled publishing

status: 'scheduled' with scheduled_publish_at: <ISO datetime>. The publisher's scheduler tick (server/publish/publishScheduler.ts) polls for rows where scheduled_publish_at <= now(), fires publishDataRow(...), and flips the row to published. On failure, the row drops back to draft.


Cookbook

Add a new field to a postType

  1. Open the postType in the Data workspace.
  2. Add a field (pick a type from DataFieldType).
  3. Optionally mark it required or set a defaultValue.

The field appears in the postType's edit form in the Data workspace, in the Content page's settings panel, and is queryable from loops.

Create a custom post-type

  1. Open the Data workspace.
  2. Create a new data_table with kind: 'postType'.
  3. The system seeds the built-in fields (title, slug, body, featuredMedia, seoTitle, seoDescription).
  4. Add custom fields as needed.
  5. Add posts via the Content workspace.
  6. Create a post-type template in the Site workspace if the collection needs public detail pages.

Create a "data" table (no workflow)

  1. Open the Data workspace.
  2. Create a data_table with kind: 'data'.
  3. Add fields. No built-ins.
  4. Add rows via the grid. No publish workflow.

Useful for form submissions, settings tables, or any arbitrary key-value collection.

Read a row's content from a plugin

Plugins access content via the SDK's api.cms.storage.collection(id):

const posts = api.cms.storage.collection('posts')
const rows  = await posts.list()

See docs/features/plugin-system.md. Note the collection id matches the data_tables.slug.

Render a postType row as a page

When a published row has a non-empty data_tables.route_base, the public URL is /<routeBase>/<rowSlug>. The router's tryServePublicRoute (in server/router.ts) delegates to resolvePublicRoute in server/publish/publicRouter.ts, which resolves the row + table, picks the matching entry template from the latest published site snapshot, and renders the template with the row pushed onto the entry stack.


Forbidden patterns

PatternUse instead
Creating a new content table outside data_tablesAdd a row to data_tables. There are no other content tables.
Reading cells.foo as stringUse the readers in src/core/data/cells.ts
Renaming or deleting a system tableBlocked at the repository layer; UI hides the affordance
Renaming a builtIn: true field on a postTypeDisable instead — the underlying field id is reserved
Writing into cells_json directly without re-denormalizing slugUse slugForTable(table, cells) from src/core/data/cells.ts, then pass the result to the repository function
Computing the published URL by stringing id togetherUse routeBase + the row's slug
Skipping the version write on publishpublishDataRow always writes a data_row_versions row
Manually setting status: 'published' without going through the publish pathThe publish path runs the renderer, writes a version, and fires hooks

Plugin events on content writes

Every successful content write fires one of three events on the hook bus alongside an actor field plugins can use to skip their own writes:

EventFires whenPayload
content.entry.createdA new row is inserted (admin CMS, plugin via api.cms.content){ tableSlug, entryId, actor }
content.entry.updatedA row's cells / slug / status change (draft save, publish, schedule, move){ tableSlug, entryId, changedFieldIds, actor }
content.entry.deletedA row is soft-deleted{ tableSlug, entryId, actor }

The actor shape:

type ContentEntryActor =
  | { kind: 'user'; userId: string }
  | { kind: 'plugin'; pluginId: string }
  | { kind: 'system' }  // schedulers, scheduled-publish tick

There's also one filter — content.entry.cells — that runs over the cell bag BEFORE persistence. All write paths — admin HTTP handlers (rows.ts, tables.ts) and the plugin api.cms.content.* surface — apply it via applyContentEntryCellsFilter from server/publish/contentEvents.ts. Plugins use it to validate, normalize, or auto-fill cells:

api.cms.hooks.filter('content.entry.cells', (cells, { tableSlug, entryId, actor }) => {
  if (tableSlug !== 'pages') return cells
  if (actor.kind === 'plugin' && actor.pluginId === api.plugin.id) return cells
  if (!cells.metaDescription && typeof cells.body === 'string') {
    return { ...cells, metaDescription: cells.body.slice(0, 160) }
  }
  return cells
})

Events are emitted from server/publish/contentEvents.ts, which also exports applyContentEntryCellsFilter. Admin CMS handlers and plugin handlers both call these helpers directly; the publish scheduler emits the system actor variant.


  • docs/architecture.md — system overview ("All content lives in data_tables + data_rows")
  • docs/server.md — repository + handler layer
  • docs/features/publisher.md — how pageTree-typed cells become HTML
  • docs/features/visual-components.md — components are rows in this store
  • docs/features/templates.md — entry templates for postType rendering
  • docs/features/site-transfer.md — export / import bundles round-trip every table + row
  • docs/reference/database-dialects.md_json column convention + migration parity
  • Source-of-truth files:
    • src/core/data/schemas.tsDataTableSchema, DataRowSchema, DataField union, status enum, DeletedRowSummary / DeletedRowSummarySchema (narrow type returned by softDeleteDataRow)
    • src/core/data/cells.ts — typed cell readers + slugForTable (slug derivation)
    • src/core/data/fields.ts — field normalization, built-in field detection
    • src/core/data/pageFromRow.ts — Page ↔ row
    • src/core/data/componentFromRow.ts — VC ↔ row
    • server/repositories/data/tables.ts, rows/ (split by responsibility), publish.ts, shared.ts
    • server/handlers/cms/data/ — generic data endpoints
    • server/handlers/cms/pages.ts, components.ts, layouts.ts — typed endpoints for the system tables
    • server/publish/publishRow.ts — per-row publish orchestration
    • server/publish/publishScheduler.ts — scheduled-publish tick
  • Gate tests:
    • src/__tests__/architecture/data-tables-system-flag.test.ts
    • src/__tests__/architecture/no-legacy-content-domain.test.ts
    • src/__tests__/architecture/no-legacy-pages-table.test.ts