Journal

September 24, 2026 · 2 min read

Saving is not publishing

Drafts beside the row, revisions kept, and a state nobody stores: how foldkit-cms gives this blog an editor that saves as you type and publishes only when asked.

Open a post in the studio and type. Pause, and the status line says “Draft saved.” Nothing a visitor reads has changed. Press Publish, and it has. That split is most of what foldkit-cms does, and it takes little.

A draft is an unsent form

The blog’s posts live in an ordinary posts table, and the CMS adds no columns to it. A draft is kept beside the row, in a table of the CMS’s own, and it holds what the post’s form holds: the saved input of the mutation that would publish it.

So publishing is not a special operation. It is the application’s own mutation, CreatePost or UpdatePost, run with the draft’s value, in one transaction with the CMS’s bookkeeping. A post that was never published has no row at all, which is why a visitor cannot reach it.

Declared once

The CMS reads what the application already declared. The post is an Entity, and one step says which of its fields play a part in the CMS (trimmed):

export const Post = Entity.define(
  'Post',
  Schema.Struct({ /* id, title, slug, … */ }),
).pipe(
  Cms.roles({
    label: 'title',
    slug: 'slug',
    published: 'publishedAt',
  }),
)

And one declaration says how posts are authored:

export const Posts = Cms.content('posts', {
  entity: Post,
  form: PostForm,
  publish: { create: CreatePost, update: UpdatePost },
  words: { one: 'Post', many: 'Posts' },
})

A state nobody stores

The badge beside a post says New, Published, Changed, Unpublished or Archived. No column holds it. Cms.state works it out from facts: is there a row, may a visitor see it, is there a draft over it, is the entry archived. A published post with a draft over it is Changed; publish the draft, and it is Published again. Nothing can fall out of step, because nothing is kept in step.

Saving as you type

The editor, Cms.editor, saves once the author rests: here, 800 milliseconds after the last change. If the save fails, the status line says so and the text stays. If someone else saved the same post after you opened it, the editor says that too, and keeps your text on screen rather than writing over theirs.

Revisions and a schedule

Every publish keeps a revision: the value that was published, when, and by whom. The History beside a post lists them, and restoring one makes it a draft again, so it is read before it goes live. A post can also be scheduled, and the CMS publishes it when its time comes, through the same mutation.

Who may publish

Anyone may write here; only an editor may put writing in front of readers. That rule is one function the server asks before every change of state:

allow: (principal, transition) =>
  !['publish', 'schedule', 'unpublish'].includes(transition) ||
  principal?.role === 'editor',

The studio hides the Publish button from a writer, but that is only a courtesy. The server is what refuses.

← More from the blog