# `MishkaCmsCore.Runtime.PageBuilder.History`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/page_builder/history.ex#L5)

Turns the editor's settled entries into durable rows, and reads them back.

The undo stack in `MishkaCmsCore.Runtime.PageBuilder.EditorSession` stays what Ctrl+Z walks — in
memory, instant, gone with the session. This is the half that survives: what the history sheet
reads, what a colleague on the same draft sees, and what records who made a change.

## The write is batched, off-process, and idempotent

`EditorSession` mints an id when an entry settles, so a row is identifiable before it exists. The
flush drains everything queued and upserts it — which means a write that times out after
succeeding, and is retried, updates the row rather than growing a second one. It also means a
coalescing gesture that has already been flushed is corrected in place: the entry keeps its id, so
the finished slider value replaces the first tick's row.

Nothing here may run on the LiveView process. Autosave learned that the hard way — a database round
trip inline froze the editor for its duration, with every click and keystroke queued behind it.

## The snapshot

Each row points at a `MishkaCmsCore.Runtime.PageBuilderSnapshot` holding gzipped HEEx: the document
as it was BEFORE that change, so reverting to a row undoes it and everything after. A document past
the snapshot threshold gets a row with no snapshot and `refused: true`, which the timeline says
out loud rather than offering a Revert that cannot work.

## What it is anchored to

`source_type` + `source_id` are the LIVE record, always. `draft_id` says which branch, and is `nil`
for the editor's published stage — where nothing is written to the page but the work is still
recorded, which is the whole reason that stage stopped meaning "lose it".

# `adopt_draft`

```elixir
@spec adopt_draft(String.t(), String.t(), map() | nil) ::
  {:ok, non_neg_integer()} | :error
```

Give this sitting's un-drafted rows to the draft that has just been created for it.

The editor makes a draft LAZILY — it is named and written on the first autosave, not when the
editor opens — so the first changes of a session are recorded before there is a draft to attach
them to. Left alone they stay on the live record's timeline while their siblings sit on the
draft's, which splits one afternoon's work across two lists for a reason nobody could infer.

Bounded by the session's own start, so it can only ever adopt what this sitting wrote, and only
when the editor is on the draft stage: rows written in published stage genuinely belong to no
draft, and that is the point of recording them.

# `clear`

```elixir
@spec clear(atom(), String.t(), map() | nil) ::
  {:ok, non_neg_integer()} | {:error, term()}
```

Clear one document's whole recorded history.

A whole anchor at a time, never a single row: removing one entry and leaving its neighbours is the
operation that lets somebody quietly delete the evidence of one change, which is the thing a record
of what happened exists to prevent.

# `collect`

```elixir
@spec collect(atom(), String.t(), String.t() | nil, map(), map() | nil) ::
  {:ok, %{dropped: non_neg_integer()}} | {:error, term()}
```

What a publish leaves behind: the snapshots go, the rows stay, and one summary row is written.

Costed rather than chosen. A snapshot is ~1.3 kB against a row's 122 B, so the payloads are 91% of
the weight — and the rows are the per-author record this whole thing exists for. Deleting them at
publish would erase who wrote what at the exact moment the work shipped.

What is given up is reverting INTO the middle of a shipped draft, which nobody wants: the published
state is right there, and the summary row is itself a revert point.

Idempotent, so a retried job that finds the payloads already gone still succeeds — and it does not
care whether the draft it is collecting still exists, which is what makes it safe to call at any
point of a publish.

THE SUMMARY ROW GOES FIRST, because it is the only half nothing writes later. This runs in a task
the LiveView is linked to, so an author who publishes and leaves the builder in the same breath
kills it in the middle of the sweep — and a sweep that dies half done costs weight the next publish
takes anyway, where the row saying the page was published would be gone for good. It is read back
by the query below and carries no payload, so it sweeps nothing of its own.

# `flush`

```elixir
@spec flush(String.t(), map() | nil) :: {:ok, non_neg_integer()} | {:error, term()}
```

Write everything the session has queued, and return how many rows landed.

Runs in a task, not on the LiveView. On failure the entries are put back, so the next flush retries
them and a flaky connection costs nothing but a delay.

# `publish_op?`

```elixir
@spec publish_op?(atom()) :: boolean()
```

Whether an op reads as a step through the document rather than a change to it.

The timeline shows these by their own name — RESTORE, UNDO, PUBLISH — instead of the bucket they
file under, so it asks here rather than keeping a fourth list of its own.

# `revert`

```elixir
@spec revert(String.t(), String.t(), map() | nil) ::
  {:ok, term()} | {:error, :gone | :unreadable | term()}
```

Restore the document a row was taken before, into the live editor session.

Forward-only: `EditorSession.put_tree/3` pushes a "Restored" entry whose own ref holds the tree
being left, so an undo immediately afterwards takes you back. Nothing is deleted.

The snapshot is self-sufficient, which is why this works for a row written by a colleague, by the
assistant, or last week — there is no in-memory ref to consult, and that is the whole reason
snapshots were chosen over replaying a log of inverse operations.

# `sweep_orphan_snapshots`

```elixir
@spec sweep_orphan_snapshots() :: {:ok, non_neg_integer()}
```

Delete snapshots no row points at.

They accumulate from two directions: a publish that dropped a row's payload, and the rarer race
where two flushes of one entry both mint a snapshot before either row lands. Neither is worth
preventing with a lock — a payload nothing references is garbage whatever produced it, and one
statement removes all of it.

Runs as the system, with no user: whether a payload is referenced is a question about every
site's rows, and a user reaches only their own site's.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
