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.
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.
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.
A fingerprint of original, a section's live document: :erlang.phash2/1 of every field its draft
carries. 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.
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.
@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. 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.
@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.