CMS

Headless CMS on the data layer — content types declared in code, derived draft/published tables, the ctx.cms read surface, and the saveDraft → publish write pipeline with save-time validation + derivation.

@voltro/cms is a headless CMS built on the framework's own data layer. You declare a content type once, in code; the package compiles it to two real database tables (<type>_drafts + <type>_published) that auto-migrate picks up, gives you a typed read surface, and a write pipeline that enforces the schema's validation/derivation rules on every save.

Two entry points keep the browser/server boundary clean:

  • @voltro/cms — the server entry: table derivation (@voltro/database), the write pipeline, signed preview tokens (node:crypto), the REST mount.
  • @voltro/cms/web — browser-safe: the field DSL, the validation/derivation engine + its typed errors (effect-only), and the <ContentForm> renderer (react-only). An editor page pre-validates with the SAME rules the server applies; a mutation descriptor can declare error: ContentValidationFailed without dragging the server graph into the bundle.

Declaring a content type

By convention each content type lives in a *.contentType.ts file. The field DSL is a plain-data descriptor language — validation rules travel as data and are applied at save time.

import { defineContentType, Schema, derivedFrom, slugify } from '@voltro/cms'

export const blogPost = defineContentType({
  name: 'blogPost',
  displayName: 'Blog Post',
  pluralName: 'Blog Posts',
  fields: {
    title:       Schema.String.pipe(Schema.maxLength(200)),
    slug:        Schema.String.pipe(Schema.pattern(/^[a-z0-9-]+$/), Schema.unique(), derivedFrom('title', slugify)),
    body:        Schema.RichText({ allowImages: true, allowEmbeds: false }),
    tags:        Schema.Array(Schema.String),
    publishedAt: Schema.DateTime.optional(),
    coverImage:  Schema.Media().optional(),
    author:      Schema.Reference('author'),
    visibility:  Schema.Literal('public', 'unlisted', 'private'),
  },
  list: { columns: ['title', 'updatedAt'], defaultSort: { field: 'updatedAt', dir: 'desc' } },
})
  • maxLength / pattern are enforced by the save-time validator (and maxLength also caps the shipped text widget).
  • derivedFrom(source, transform) marks a field computed-on-save: the pipeline computes it from its source and overwrites a caller-provided value — slugify is the canonical transform.
  • unique() marks a string field unique per tenant — the derived tables get a real composite (tenantId, field) DB UNIQUE constraint (the database rejects a duplicate slug within a tenant, not the app). Pair it with derivedFrom('title', slugify) for a unique slug.
  • Schema.RichText(options?) stores a JSON document (e.g. a TipTap doc); allowImages / allowEmbeds: false reject image/embed nodes (or <img> / <iframe> / <embed> markers in string values) at save time. The shipped default widget is a plain textarea placeholder — swap in a real editor via the ContentForm widgets override.
  • Schema.Media() stores a storage object key as text (e.g. one issued by @voltro/plugin-storage); resolve it to a URL after a read with resolveMedia (below).
  • list describes the consuming editor's list view; every referenced name is validated at definition time.
  • Reserved field names (defineContentType throws): id, status, tenantId, createdAt, updatedAt, createdBy, updatedBy.

contentTypeToEntities(blogPost) returns the { draft, published } tables — register them in your database/index.ts so auto-migrate creates them. Both carry the lifecycle status column (draft / published / archived), the tenant() mixin (tenant-scoped reads/writes through the request store), and are .reactive() so a publish wakes live subscriptions.

Reading — ctx.cms

cmsContext(store, types) builds the read surface over a request-scoped store; attach it to your AppContext and read it with useCms(ctx). Reads target <type>_published and exclude archived rows.

import { cmsContext, useCms } from '@voltro/cms'

const posts = await useCms(ctx)
  .contentType('blogPost')
  .where('publishedAt', '<=', new Date())
  .orderBy('publishedAt', 'desc')
  .limit(20)
  .all()

createCmsClient({ store, types }) is the same surface without a request handle (build-time / static-site generation). Signed preview tokens (previewToken / verifyPreviewToken / cms.preview) let an app read the draft row instead — see the package README for the token contract and the API-key-gated REST mount (handleCmsRest).

Writing — the pipeline

The lifecycle ops take the caller's store first. Hand them ctx.store and every write rides the runtime's auto-scope spine: tenant + audit columns are stamped, reads are tenant-scoped, ids come from the table's id scheme.

import { saveDraft, publish, unpublish, archive } from '@voltro/cms'

// derive → validate → write into blogPost_drafts (status: 'draft')
const draft = await saveDraft(ctx.store, blogPost, input)

// copy the draft into blogPost_published (same id), atomic per row
const row = await publish(ctx.store, blogPost, draft.id as string)

await unpublish(ctx.store, blogPost, draft.id as string) // remove the published copy, keep the draft
await archive(ctx.store, blogPost, draft.id as string)   // status 'archived' — reads exclude it

saveDraft applies the derivedFrom derivations first, then validates the derived row — a rule on a derived field checks the value actually written. Violations throw ContentValidationFailed carrying every per-field violation ({ field, rule, message }), so an editor can annotate the whole form in one pass. Re-saving with row.id updates the caller's own draft; publish copies into the published table (insert on first publish, update after) and marks the draft in-sync.

Tenant isolation is structural: a foreign id reads as absent through the scoped store (ContentNotFound), a caller-pinned id that exists only in another tenant is refused (ContentIdConflict), and a caller-provided tenantId never reaches a write — the pipeline owns the lifecycle columns.

Handlers written in Effect.gen use the Effect-native siblings — the same typed error instances on the error channel:

import { Effect } from 'effect'
import { saveDraftEffect, publishEffect } from '@voltro/cms'

const program = Effect.gen(function* () {
  const draft = yield* saveDraftEffect(ctx.store, blogPost, input)
  return yield* publishEffect(ctx.store, blogPost, draft.id as string)
}).pipe(
  Effect.catchTag('ContentValidationFailed', (e) => Effect.succeed({ invalid: e.violations })),
)

The engine, standalone

The validation/derivation engine is pure and exported on its own (also on /web) for custom write paths and client-side form validation:

import { validateContent, contentViolations, applyDerivations } from '@voltro/cms/web'

const derived = applyDerivations(blogPost, formValue) // derivedFrom fields computed
const violations = contentViolations(blogPost, derived) // pure — never throws
validateContent(blogPost, derived) // throws ContentValidationFailed on any violation

Scheduled publishing

A draft can be marked to go live at a future time, then flipped live by a periodic sweep. schedulePublish stamps the draft's publishAt (a draft-only nullable column) without publishing now; publishDue publishes every draft whose time has passed.

import { schedulePublish, publishDue } from '@voltro/cms'

// Mark a draft to publish later (does NOT publish now):
await schedulePublish(ctx.store, blogPost, draftId, new Date('2026-01-01T09:00:00Z'))

// Flip every due draft live (idempotent; returns the published ids):
const publishedIds = await publishDue(ctx.store, blogPost, new Date())

schedulePublish keys off a tenant-scoped read (a foreign/missing id throws ContentNotFound) and leaves status: 'draft' until the row goes live. publishDue selects publishAt <= now AND status = 'draft', runs the ordinary publish per row, and clears publishAt — idempotent, so a row is never double-published. It sweeps whatever store it is handed: a request store covers one tenant; a background/root store covers every tenant.

@voltro/cms does not ship the scheduler — you drive publishDue from a *.cron.tsx schedule:

// apps/api/schedules/content-scheduler.cron.tsx
import { defineSchedule } from '@voltro/runtime'
import { publishDue } from '@voltro/cms'
import { blogPost } from '../blogPost.contentType'

export default defineSchedule({
  name: 'contentScheduler',
  cron: '* * * * *',
  timezone: 'UTC',
  // app.store has no request subject, so the sweep flips due drafts live
  // across ALL tenants.
  handler: async ({ app }) => {
    await publishDue(app.store, blogPost, new Date())
  },
})

Media resolution

A Schema.Media() field stores a storage object key. Turning it into a served URL needs the storage service (its getUrl / mintUrl are Effect-returning and mintUrl needs the request Subject), so that context lives in your app — not the core package. Rather than couple @voltro/cms to a storage plugin, resolution is a typed hook: you supply a MediaResolver, and resolveMedia swaps every media key on a row (including media nested in Array / Struct fields) for the resolved URL.

import { resolveMedia, resolveMediaAll, type MediaResolver } from '@voltro/cms'
import { Effect } from 'effect'

const resolver: MediaResolver = (key) =>
  Effect.runPromise(storage.mintUrl(key, ctx.subject)).catch(() => null)

const post = await useCms(ctx).contentType('blogPost').where('slug', '=', 'hello').one()
const withUrls = post ? await resolveMedia(blogPost, post, resolver) : null

resolveMedia returns a new row (the input is not mutated); a key the resolver returns null for is left as its stored key. resolveMediaAll maps a whole list; mediaFields(type) lists the top-level media field names.

Versioning content

@voltro/cms ships no parallel revision system — the derived tables are ordinary database tables, so @voltro/plugin-versioning gives full row history

  • time-travel with no new machinery. List the derived table names in the plugin's tables option, then read a timeline or restore a snapshot:
import { versioningPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-versioning'

// Register in your app's plugin list:
versioningPlugin({ tables: ['blogPost_published', 'blogPost_drafts'] })

const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)

The editor form

<ContentForm> (from /web) renders one labelled widget per field, driven by each field's editor hint — stateless and unstyled. Override any widget (e.g. a TipTap-backed richText) via the widgets prop:

import { ContentForm } from '@voltro/cms/web'

<ContentForm contentType={blogPost} value={row} onChange={setRow}
  widgets={{ richText: MyTipTapWidget }} />