One recorded change to a document in the visual editor — the durable half of the editor's history.
The undo stack in MishkaCmsCore.Runtime.PageBuilder.EditorSession is still what Ctrl+Z walks: in
memory, fast, and gone when the session ends. This is what the history sheet reads, what a
colleague sees, and what says who changed what — including the assistant.
What it anchors to
source_type + source_id name the LIVE record, always, for all four editable kinds. draft_id
only says which branch the change happened on, and is nil for work done with no draft (the
editor's published stage). Two consequences fall out of that shape rather than being coded:
- "show me this draft's history" is a filter, not a second system;
- publishing deletes the draft row,
ON DELETE SET NULLclearsdraft_id, and the rows simply join the live record's own timeline. Nothing re-anchors them.
What it is not
Lean and append-only, deliberately unlike its neighbours: no archive, no unarchive, no version actions, no JSON:API routes. The house template is built for content a person edits through a form; this is a log written by the editor. Every action it does not have is one that cannot be misused.
The one mutable field is note — an annotation ABOUT a row rather than a claim about what happened,
so editing it labels history instead of rewriting it. note_author_id travels with it, because
otherwise a colleague's note on an assistant's row reads as the assistant's own words.
Attribution
author_id is never accepted from input: it is set from the actor, and it is ALWAYS the human —
including on an :ai row, where the human is whoever's editor ran the prompt. author_type says
who performed the change. Rendering the pair is MishkaCmsCoreWeb.Templates.HistoryTimeline's job,
and it must branch on author_type BEFORE the email-masking ladder, or an assistant's row prints
the operator's address.
Both author_id and note_author_id are released when their account is deleted
(on_delete: :nilify): the row stays, and a :human row with no author reads as a deleted user.
See MishkaCmsCore.Runtime.PageBuilderSnapshot (the document a row restores) and
MishkaCmsCore.Runtime.PageBuilder.History (how rows are written).
Summary
Types
@type t() :: %MishkaCmsCore.Runtime.PageBuilderEvent{ __lateral_join_source__: term(), __meta__: term(), __metadata__: term(), __order__: term(), aggregates: term(), author: term(), author_id: term(), author_master?: term(), author_type: term(), backend: term(), calculations: term(), detail: term(), draft_id: term(), id: term(), inserted_at: term(), kind: term(), label: term(), note: term(), note_author: term(), note_author_id: term(), note_author_master?: term(), note_edited_at: term(), op: term(), prompt: term(), refused: term(), site: term(), site_id: term(), snapshot: term(), snapshot_id: term(), source_id: term(), source_type: term(), target: term() }
Functions
@spec buckets() :: [atom()]
The three buckets the timeline's pills filter on.
Validates that the keys in the provided input are valid for at least one action on the resource.
Raises a KeyError error at compile time if not. This exists because generally a struct should only ever
be created by Ash as a result of a successful action. You should not be creating records manually in code,
e.g %MyResource{value: 1, value: 2}. Generally that is fine, but often with embedded resources it is nice
to be able to validate the keys that are being provided, e.g
Resource
|> Ash.Changeset.for_create(:create, %{embedded: EmbeddedResource.input(foo: 1, bar: 2)})
|> Ash.create()
Same as input/1, except restricts the keys to values accepted by the action provided.
@spec sources() :: [atom()]
The four editable kinds a change can be anchored to.