MishkaCmsCore.Runtime.PageBuilder.History (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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".

Summary

Functions

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

Clear one document's whole recorded history.

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

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

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

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

Delete snapshots no row points at.

Functions

adopt_draft(token, draft_id, actor)

@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(source_type, source_id, actor)

@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(source_type, source_id, draft_id, summary, actor)

@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(token, actor)

@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?(op)

@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(token, row_id, actor)

@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()

@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.