MishkaCmsCore.Runtime.Authoring.Drafts (Mishka CMS Core v0.0.3)

Copy Markdown View Source

The editor's autosave: the working document written to a draft row, not the live one.

Why drafts and not the live record

Every write to a live runtime resource enqueues a compile job through SendToOban. An editor that saved to the live row as you worked would therefore recompile the site on every change and publish half-finished work to visitors. The draft resources exist precisely for this — they deliberately carry no SendToOban, so writing one is cheap and invisible until someone publishes it.

A row belongs to the session that made it, and a session is one sitting, so the count grows with use. prune/3 caps it per document — keep/0 is the number — dropping the oldest as a new one is made.

That also makes the draft the shareable preview: /_preview/:resource/:token already serves a draft by its preview_token, so "open in a new tab" needs no new route and no new auth story.

Component drafts do not exist as a resource, so a component's autosave is a no-op and its Save button is the only persistence — reported honestly rather than silently doing nothing.

Publishing what a draft holds

A draft is the whole document, its settings included: the layout, the SEO, the path, who may open it. Its own :publish writes all of that over the original, and an editor that publishes its working copy straight to the original writes carried/2 beside its markup, so neither publish leaves a setting behind in a draft it then throws away. The original keeps its own code either way; the fingerprint original_changed?/3 compares covers exactly what a publish writes over, and follow/5 keeps a draft in step with what its own editor saved to the original meanwhile.

Summary

Functions

Writes the current document to this session's draft, creating it on the first call and updating it afterwards.

The label the automatic save owns.

What publishing draft writes over the document it was made from — every field it carries but the document's code — exactly as the draft's own :publish writes it. %{} for anything that is not a draft of section.

Deletes a draft — what "Discard" on the resume bar does to the autosave.

The name to show for a draft: the automatic save's label in the caller's language, any other name as it was typed.

Keeps draft in step with a change its own editor made to the original while it was open — a setting saved to the page itself, say. before is the original as it stood, now as it is.

The draft draft_id of section, read as actor: {:ok, draft}, or {:error, reason} for a draft that is gone, is not theirs to read, or a section that keeps none.

A fingerprint of original, a section's live document: :erlang.phash2/1 of every field its draft carries and publishing it writes over. A draft keeps the one it was made from (source_hash), so a different one now means the original was changed since — see original_changed?/3.

How many drafts a document keeps.

Every draft of this record, newest first — what the editor's draft picker lists.

The draft this record already keeps under label, or nil.

original_changed?/3 for a draft on its own: the document it was made from is read by its source id, as the system.

Whether the document a pending draft was made from has changed since — a notice for its author, nothing more. A draft that holds no fingerprint, or was published, answers false.

The /_preview/:resource/:token segment for a section.

Drops everything past the newest 20 drafts of a record.

The Ash resource holding a section's drafts — what the editor points its drafts table at.

Saves the document under a NAME, creating that draft or updating it.

The draft attribute pointing back at the live record — :source_page_id and its siblings.

Whether this section keeps drafts at all.

Functions

autosave(section, record, heex, actor, draft_id, label, status)

@spec autosave(
  String.t(),
  map() | nil,
  String.t(),
  map(),
  String.t() | nil,
  String.t() | nil,
  integer() | nil
) :: {:ok, struct()} | :unsupported | {:error, term()}

Writes the current document to this session's draft, creating it on the first call and updating it afterwards.

label is the name to keep. nil falls back to the autosave's own label, but when the editor has a NAMED draft open it passes that name through — otherwise the automatic save would quietly rename the author's draft to "Autosave" on the next keystroke.

It takes no source to copy from, unlike save_as/8: a sitting with no row yet is one that started from the live document, and from its first write on it is the row itself that is read.

status says which of an error page's templates is being written, and is nil for every section that keeps one. IT IS ASKED FOR RATHER THAN DEFAULTED because a caller who leaves it off is not writing nothing — Section.save_fields/3 falls back to the 404 and replaces THAT entry, so the work lands on a template nobody opened and buries the one that was there. A default made that silent, and the one caller that took it up was "Keep it as a draft" on the way out.

Returns {:ok, draft}, :unsupported for a section with no draft resource, or {:error, reason}.

autosave_label()

@spec autosave_label() :: String.t()

The label the automatic save owns.

carried(section, draft)

@spec carried(String.t(), map()) :: map()

What publishing draft writes over the document it was made from — every field it carries but the document's code — exactly as the draft's own :publish writes it. %{} for anything that is not a draft of section.

For an editor that publishes its working copy straight to the original: written beside the markup, in the same write, these are the draft's layout, SEO, path, title and permissions, which would otherwise go with the draft when the publish discards it.

discard(section, draft_id, actor)

@spec discard(String.t(), String.t(), map()) :: :ok | {:error, term()}

Deletes a draft — what "Discard" on the resume bar does to the autosave.

Deleting rather than ignoring is deliberate: a bar that offers to restore work you have already said you do not want, every time you open the page, is a bar people learn to dismiss without reading.

display_label(label)

@spec display_label(String.t() | nil) :: String.t() | nil

The name to show for a draft: the automatic save's label in the caller's language, any other name as it was typed.

follow(section, draft, before, now, actor)

@spec follow(String.t(), struct(), map(), map(), map()) ::
  {:ok, struct()} | {:error, term()}

Keeps draft in step with a change its own editor made to the original while it was open — a setting saved to the page itself, say. before is the original as it stood, now as it is.

Every setting the change moved is written to the draft too, so publishing the draft does not put the older one back; and the draft takes now's fingerprint, so the change is not reported as somebody else's by original_changed?/3. The draft's own markup is never replaced: when the original's moved as well, the settings are carried and the fingerprint is kept, notice and all.

A draft that was already behind before — someone else changed the original first — is returned as it is. Returns {:ok, draft} or {:error, reason}.

get(section, draft_id, actor)

@spec get(String.t(), String.t(), map()) :: {:ok, struct()} | {:error, term()}

The draft draft_id of section, read as actor: {:ok, draft}, or {:error, reason} for a draft that is gone, is not theirs to read, or a section that keeps none.

hash(section, original)

@spec hash(String.t(), map()) :: non_neg_integer()

A fingerprint of original, a section's live document: :erlang.phash2/1 of every field its draft carries and publishing it writes over. A draft keeps the one it was made from (source_hash), so a different one now means the original was changed since — see original_changed?/3.

The original's code is not in it. The editor's code drawer saves code to the original itself while a draft is open, and no publish of the draft writes it, so a change to it is nothing the draft would overwrite.

keep()

@spec keep() :: pos_integer()

How many drafts a document keeps.

list(section, record, actor)

@spec list(String.t(), map() | nil, map()) :: [struct()]

Every draft of this record, newest first — what the editor's draft picker lists.

Returns [] for a section with no draft resource rather than raising, so a caller does not have to ask supported?/1 first.

named(section, record, actor, label)

@spec named(String.t(), map() | nil, map(), String.t()) :: struct() | nil

The draft this record already keeps under label, or nil.

What "a draft called X already exists" is asked with, so the editor can offer to update it instead of quietly making a second one with the same name.

original_changed?(draft)

@spec original_changed?(struct()) :: boolean()

original_changed?/3 for a draft on its own: the document it was made from is read by its source id, as the system.

original_changed?(section, original, arg3)

@spec original_changed?(String.t(), map() | nil, map() | nil) :: boolean()

Whether the document a pending draft was made from has changed since — a notice for its author, nothing more. A draft that holds no fingerprint, or was published, answers false.

preview_segment(section)

@spec preview_segment(String.t()) :: String.t() | nil

The /_preview/:resource/:token segment for a section.

prune(section, record, actor)

@spec prune(String.t(), map() | nil, map()) :: :ok

Drops everything past the newest 20 drafts of a record.

Runs where the count can grow and nowhere else — the moment a row is CREATED — so the realtime save, which writes the same row over and over, costs one update and no extra read.

Newest is by updated_at, so a draft someone is working in right now cannot be the one dropped: every change moves it back to the front of the list. A session whose row is pruned while it sits idle is not broken by it either — its next write finds the id gone and starts a new row, which is the same thing that happens when a colleague discards it from the picker.

A published draft is never touched: list/3 reads pending drafts only, and a row with an applied_at is the record of a publish rather than work in progress.

resource(section)

@spec resource(String.t()) :: module() | nil

The Ash resource holding a section's drafts — what the editor points its drafts table at.

Returns nil for a section that keeps none, the same answer supported?/1 gives.

save_as(section, record, source, heex, actor, draft_id, label, status)

@spec save_as(
  String.t(),
  map() | nil,
  map() | nil,
  String.t(),
  map(),
  String.t() | nil,
  String.t(),
  integer() | nil
) :: {:ok, struct()} | :unsupported | {:error, term()}

Saves the document under a NAME, creating that draft or updating it.

draft_id names the row to update — pass nil to create. Autosave has its own entry point, autosave/7, and its own label, so naming a draft never collides with it.

source is the document a NEW row copies its fields from — the draft being forked or saved under another name, and nil when the save starts from the live document, which is then the source. It is asked for rather than defaulted for the reason attrs/8 gives: a copy taken from the live record hands the author today's published fields back with a success flash. Pass only a document the author chose; the row the editor's own autosave made for a sitting is the live document's working copy, not another document to copy from.

status says which of an error page's templates is being written, and is nil for every section that keeps one template — see autosave/7 for why it is asked for rather than defaulted.

source_key(section)

@spec source_key(String.t()) :: atom() | nil

The draft attribute pointing back at the live record — :source_page_id and its siblings.

The drafts table scopes itself to one document by this key, so it has to be asked for by section rather than assumed.

supported?(section)

@spec supported?(String.t()) :: boolean()

Whether this section keeps drafts at all.