# {{capProjectName}} {{capAppName}} — headless CMS backend

A headless CMS composed from [`@voltro/cms`](https://voltro.cloud/docs): content
types declared as **code**, compiled to draft/published tables, driven by the
write pipeline, and exposed as a typed rpc surface an editor consumes. Content is
tenant-scoped and gated by real editor auth. Boots **zero-infra** (`store:
'memory'`, in-process users).

Pair it with the **frontend-cms** editor web template:

```bash
voltro init my-cms --api=api-cms --web=frontend-cms
```

## Content types as code

Each `content/*.contentType.ts` file declares a type with the CMS `Schema` field
DSL. `@voltro/cms` is a **library, not a plugin** — there is no discovery of
`*.contentType.ts`; `database/schema.ts` imports the types and registers their
derived tables, and THAT is what makes them migrate.

```ts
// content/blogPost.contentType.ts
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 }),
  },
})
```

`contentTypeToEntities(blogPost)` compiles this to a `blogPost_drafts` +
`blogPost_published` table pair — both tenant-scoped (`tenant()`), both carrying
the lifecycle `status` column, both reactive so a publish wakes subscriptions.

## The rpc surface

| Procedure | Kind | Does |
|---|---|---|
| `content.types` | action | the registered types, wire-safe — the editor renders forms from this |
| `content.list` | query (live) | one type's rows, tenant-scoped; `status` picks drafts vs published |
| `content.get` | action | one row by id, for the edit form |
| `content.saveDraft` | mutation | derive → validate → upsert `<type>_drafts`; invalid input → typed `{ ok: false, violations }` |
| `content.publish` | mutation | copy draft → published (atomic); wakes consumer subscriptions |
| `content.unpublish` | mutation | remove the published copy, keep the draft |
| `session.me` | action | the resolved Subject (the editor's SSR gate reads it) |

Validation and not-found are RESULT UNIONS, not thrown errors — `saveDraft`
returns `{ ok: false, violations }` so the editor annotates the form, and
`publish` returns `{ ok: false }` for a missing/foreign draft. The descriptors
stay pure effect/Schema (browser-safe with nothing extra pulled in).

## Run it

```bash
voltro dev .
# → http://localhost:4000   (rpc + /auth/* HTTP routes)
```

`voltro dev` mints a unique `VOLTRO_SESSION_SECRET` into a gitignored
`.env.local` on first boot — no secret ships with the template. It signs both
the session cookie and CMS preview tokens.

## Files

```
app.config.ts                 authRoutesPlugin + voltroPasswordStrategy (editor auth)
content/
  blogPost.contentType.ts     a content type as code (derived slug, rich text)
  page.contentType.ts         a second type (Literal layout → <select>)
  index.ts                    the type registry + name lookup
database/schema.ts            core actors/tenants + contentTypeToEntities(...) tables
actions/
  content.types.*             the wire-safe type descriptors the editor renders
  content.get.*               one row by id
  me.action.*                 session.me
queries/content.list.*        live, tenant-scoped, drafts-or-published
mutations/
  content.saveDraft.*         derive + validate + upsert draft
  content.publish.*           draft → published
  content.unpublish.*         take a published row down
tests/                        write pipeline vs a real store + descriptor pins
```

## Going to production

| Want… | Do |
|---|---|
| Durable content + editors | `store: 'postgres'` + `postgresUserStore({ sql })` |
| A REST read API for consumers | mount `handleCmsRest(...)` (from `@voltro/cms`) — `GET /v1/cms/<type>`, API-key scoped |
| Draft preview from a consumer app | `previewToken(type, id)` / `verifyPreviewToken` (HMAC over `VOLTRO_SESSION_SECRET`) |
| Media fields | `Schema.Media()` + `@voltro/plugin-storage`; resolve keys with `resolveMedia(...)` |
| A real rich-text editor | swap the `richText` widget via `<ContentForm widgets={{ richText: … }} />` in the editor |

## Anti-patterns

- **Expecting `*.contentType.ts` to be auto-discovered.** It isn't — register the
  derived entities in `database/schema.ts` or the tables never migrate.
- **Importing a content-type file into the browser.** The editor fetches
  `content.types` instead; content-type files import the server `@voltro/cms`.
- **Reusing a reserved field name** (`status`, `tenantId`, `publishAt`, the audit
  columns). `defineContentType` throws — rename the field.
