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
@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}.
@spec autosave_label() :: String.t()
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.
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.
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.
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.
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}.
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.
@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.
@spec keep() :: pos_integer()
How many drafts a document keeps.
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.
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?/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.
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.
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.
@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.
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.
Whether this section keeps drafts at all.