CMS / foldkit-cms-drizzle

Example

The following is wiring pseudocode: Blog, Posts, posts, User, and the insert/update handlers belong to your application (a compiled version is test/readme.test-d.ts). Use the runnable server for complete handlers, a database layer, and transaction setup. sqliteTables() describes tables; include them in your migrations before running the server.

import { CmsServer, Transaction, sqliteTables } from 'foldkit-cms-drizzle'
import { Guards } from 'foldkit-guard'
import { GuardDrizzle } from 'foldkit-guard/drizzle'

const cmsTables = sqliteTables() // or pgTables(); add them to your schema and migrations

// Who is an author, who may publish, and what a visitor sees: a guard set.
const G = Guards.declare('Cms', User, guard => ({
  isAuthor: guard.principal(),
  isEditor: guard.principal(),
  isPublished: guard.row(Blog.Post, () => Expr.isNotNull(Blog.Post.fields.publishedAt)),
}), { key: user => user.id, unguarded: 'allow' })
const Policy = G.implement({
  isAuthor: principal => Option.exists(principal, user => user.role !== 'reader'),
  isEditor: principal => Option.exists(principal, user => user.role === 'editor'),
})
// A visitor sees what is published; an author sees every row.
const Post = Blog.Post.pipe(Guards.attach(G, { row: { Read: [G.isPublished, G.isAuthor] } }))

const Db = bind({ ...Blog, Post }, {
  Post: { table: posts, visible: GuardDrizzle.visible(Policy, [Post]).Post },
})

// Your own handlers of the two mutations the content type publishes through.
const CreatePost = RemoteServer.mutation(Posts.publish.create, ({ input }) => /* insert */)
const UpdatePost = RemoteServer.mutation(Posts.publish.update, ({ input }) => /* update */)

const cms = CmsServer.make({
  tables: cmsTables,
  content: [{ type: Posts, entity: Post, binding: Db.Post, create: CreatePost, update: UpdatePost }],
  transaction: Transaction.statements, // one connection; Transaction.drizzle for Postgres
  policy: Policy,
  authors: [G.isAuthor],
  // An author writes; only an editor puts it in front of visitors.
  transitions: { publish: [G.isEditor], schedule: [G.isEditor], unpublish: [G.isEditor] },
})

RemoteServer.make({
  entities: [...cms.sources, source(Db.Author)],
  queries: [...cms.queries],
  mutations: [...cms.mutations],
})
  • Register cms.sources, not source(Db.Post). They are the content types' sources and the CMS's own, each behind the boundary.
  • A content type that can be unpublished must be guarded by whether it is. CmsServer.make throws for a type with a published role whose Read rule does not read that field, and, through GuardServer.validate, for a binding whose visible is not the rule your guard set built for it. entity is the type's Entity with its guards attached, the one its table was bound from.
  • A content type with no published role has nothing to hide, and needs none.
  • Who is an author is any of authors, principal guards; anyone else is a visitor. Their bodies are asked with the interaction it is for, Read for reading unpublished work and Update for any operation on it, so a reviewer can read what they may not change. Who may make a transition is an author any of its transitions guards lets; a transition not named is any author's. Archiving an entry on show takes it off, so it asks unpublish's guards too. allow stays for a rule about the entry itself. A refusal is Forbidden, with its sentence.
  • create and update go to the CMS, not to RemoteServer.make. Registered there too, they are a way to publish with no draft, no revision and no allow.
  • Content that exists already is imported, not inserted. cms.import({ type, values, as, at, entry }) makes the entry and a draft of values, then publishes it as a publish does: your create handler writes the row, as as, and revision 1 records values, dated at. It is one transaction, and the server's own act, so allow is not asked. Writing the CMS's tables by hand has to match what a publish writes, and drifts.

Seeding

A seed is a runner over cms.import — one item per entry, in dependency order:

  • Fix the clock. Pass now to CmsServer.make and at to each import, so every run writes the same dates: the build's pages and every sandbox agree.
  • Name entries that are pointed at. Pass entry where another import names it (a page naming its featured post), and import what is pointed at first so the id is known. Anything else takes the default new id.
  • Fix as once. Import as an editor and vary the rest; the input is ImportItem<P> less as, so the runner and the tool cannot drift.

The CMS example's seed.ts is the worked shape: dated posts, then the pages that point at them.

Verify the audience boundary

Before connecting the editor, exercise the same content read as an author and as a visitor. An author may see working content; a visitor must see only rows allowed by the binding's visible rule. Repeat through a relation and a query, not just a direct id read. CMS state and allowed actions are separate from the identity established by your HTTP or RPC authentication.

For the first write, save a draft and read the public row again: it must be unchanged. Publishing should then update the row, append a revision, and remove the draft within the supplied transaction. A scheduled time alone executes nothing; your host must call cms.due as described below.