StatifierBlocks.Edit.Session (StatifierBlocks v0.30.0)

Copy Markdown View Source

The commit funnel: one place a gesture reaches the document, over StatifierBlocks.Edit.History.commit/4.

A session is the editing state a surface holds between gestures - the palette it resolves types through, the document it is editing, the undo history behind it, the per-block config drafts the document has refused with the findings each refusal carried, and the last refusal that had nowhere else to go. Every function here is a pure function of that value: it takes a session and returns a session, with no socket, no assigns and no process state in it.

Why the funnel is a module and not four copies

The package's own editor and every host embedding it face the same four lines of Edit.History.commit/4 handling, and the decisions inside them are the package's, not the surface's:

  • a refused {:update_config, id, config} is held as a draft of what the author typed, keyed by block id, rather than being blanked back to the document's value (ADR-0005 decision 9). A host that re-implements the funnel re-implements that decision, and gets it wrong by keeping the keystrokes it should drop or dropping the ones it should keep;
  • a refusal that is not about a config lands in last_error and moves nothing else, and refusal/1 is what a surface draws from it: the reason is a tuple aimed at a caller, and the sentence for it is the funnel's decision rather than each surface's (ADR-0005's Note of 2026-09-08, item 6);
  • an undo or a redo drops every draft: a draft is by definition config the document never accepted, and carrying one across a history move shows the author a value belonging to a document state they just stepped out of;
  • a list gesture reads and writes through the field's own value_path (ADR-0002 decision 7, amended 2026-08-27) rather than through its key, and descends into nested members the same way the control that draws them does.

The {:ok, session} / {:error, session} result

Every funnel function answers with a tagged session, and the tag says exactly one thing: did the document move. {:ok, session} means it did, and is the caller's cue to run whatever it does on a change - the package editor notifies its on_change host callback, a host store persists. {:error, session} means it did not, and the session still comes back because a refusal is a state change too: it is where the draft was recorded, or where last_error was set. There is nothing for a caller to do with it but render it.

Where this module is, and is not

It lives beside StatifierBlocks.Edit.History and StatifierBlocks.Edit.Targets because it operates on exactly what they operate on - a document, a palette and a history - and because, like them, it compiles and runs with no LiveView in the tree. The alternative home, under StatifierBlocks.Editor, is the LiveView component's own namespace: ADR-0005 decision 1 compiles it out entirely when LiveView is absent, and it is where the socket lives. The socket stays there; the document, the history and the drafts come here.

Building one

A session is an ordinary struct with no constructor to learn:

%StatifierBlocks.Edit.Session{
  palette: palette,
  document: document,
  history: StatifierBlocks.Edit.History.new()
}

drafts and draft_findings default to %{} and last_error to nil.

Summary

Types

Why each draft was refused: the per-field findings the refusal carried, keyed by the same block id the draft is keyed by.

Per-block config the document refused, keyed by block id.

What a list control asks of one member list: append a blank, or drop the member at an index.

A gesture, optionally addressed at a nested member list.

t()

Functions

The rows field would hold after gesture, as a value.

Writes config onto the block id, holding a refused config as a draft.

Applies command to the session's document through the gated history.

What is in last_error, as one sentence for a surface to draw.

Steps the history one move: :undo or :redo.

Applies a list gesture to field on the block id, and commits it.

Types

draft_findings()

@type draft_findings() :: %{
  optional(StatifierBlocks.Block.id()) => [StatifierBlocks.BlockType.finding()]
}

Why each draft was refused: the per-field findings the refusal carried, keyed by the same block id the draft is keyed by.

A key here always has a key in drafts/0, and the findings are the ones StatifierBlocks.Edit.History.commit/4 stated about that exact config - not a re-derivation of them.

drafts()

@type drafts() :: %{
  optional(StatifierBlocks.Block.id()) => StatifierBlocks.Block.config()
}

Per-block config the document refused, keyed by block id.

gesture()

@type gesture() :: :add | {:remove, integer()}

What a list control asks of one member list: append a blank, or drop the member at an index.

list_gesture()

@type list_gesture() :: gesture() | {[non_neg_integer()], gesture()}

A gesture, optionally addressed at a nested member list.

The bare form is the field's own list. A {path, gesture} pair steps into that list's member at each index in turn - the same descent the control makes when it nests - and applies the gesture there.

t()

@type t() :: %StatifierBlocks.Edit.Session{
  document: StatifierBlocks.Document.t(),
  draft_findings: draft_findings(),
  drafts: drafts(),
  history: StatifierBlocks.Edit.History.t(),
  last_error: term() | nil,
  palette: StatifierBlocks.Palette.t()
}

Functions

apply_gesture(field, gesture)

@spec apply_gesture(StatifierBlocks.ViewModel.Field.t(), list_gesture()) :: [term()]

The rows field would hold after gesture, as a value.

The document is not consulted and nothing is committed: this is the list half of update_list/4 on its own, for a surface that needs the rows before it has somewhere to put them. The field's type says what a blank member looks like and how a stored value reads as rows, and its value is what the gesture is applied to.

A {:list, t} wraps whatever it finds, which is what a stored scalar has always meant there. A {:type_expr, opts} does not: its inline arm is a list and its name arm is a string, and wrapping the string would turn a type name into a nameless member.

Examples

iex> alias StatifierBlocks.Edit.Session
iex> alias StatifierBlocks.ViewModel.Field
iex> field = %Field{key: "tags", type: {:list, :string}, label: "Tags", required?: false, default: [], value: ["a", "b"]}
iex> Session.apply_gesture(field, :add)
["a", "b", ""]
iex> Session.apply_gesture(field, {:remove, 0})
["b"]

change_config(session, id, config)

@spec change_config(t(), StatifierBlocks.Block.id(), StatifierBlocks.Block.config()) ::
  {:ok, t()} | {:error, t()}

Writes config onto the block id, holding a refused config as a draft.

This differs from commit/2 in one way and only one: an {:invalid_config, id, findings} refusal is recorded in drafts under id instead of in last_error. That is ADR-0005 decision 9's draft treatment - a value the document refuses is still the value the author is holding, and blanking it back to the document's would delete their keystrokes to punish a typo. Every other refusal lands in last_error exactly as commit/2's does.

A config the document accepts clears that block's draft: the author has resolved the refusal that produced it.

The refusal's per-field findings are kept beside the draft, in draft_findings under the same id. They are the findings Edit.History.commit/4 already stated about this exact config, so a surface drawing the refused form reads them rather than re-running StatifierBlocks.BlockType.validate_config/1 to re-derive the answer the funnel was handed and threw away. StatifierBlocks.ViewModel.overlay_findings/2 is the other half: it routes them onto the form's fields.

commit(session, command)

@spec commit(t(), StatifierBlocks.Edit.t()) :: {:ok, t()} | {:error, t()}

Applies command to the session's document through the gated history.

The one place a command reaches the document, so the gate, the undo stack and the caller's change notification each have one implementation rather than one per gesture.

On {:ok, session} the document and the history have both moved and last_error is cleared; drafts are untouched, because a command that is not a config change says nothing about a config the document refused. On {:error, session} the refusal is in last_error and nothing else moved.

Examples

iex> alias StatifierBlocks.{Block, Document, Palette}
iex> alias StatifierBlocks.Edit.{History, Session}
iex> document = Document.new(Block.new("core.sequence", id: "blk_ROOT", slots: %{"body" => []}))
iex> session = %Session{palette: Palette.core(), document: document, history: History.new()}
iex> {:ok, block} = Palette.new_block(session.palette, "core.placeholder")
iex> {:ok, moved} = Session.commit(session, {:insert, {"blk_ROOT", "body", 0}, block})
iex> length(Document.blocks(moved.document))
2
iex> {:error, refused} = Session.commit(session, {:remove, "blk_NOPE"})
iex> refused.last_error
{:no_such_block, "blk_NOPE"}

refusal(envelope)

@spec refusal(term()) :: String.t()

What is in last_error, as one sentence for a surface to draw.

commit/2, change_config/3 and step/2 each answer a refusal by putting its reason in last_error and moving nothing else, and a reason is a tuple aimed at a caller rather than at an author. This is the other half: the same vocabulary said once, in the words the editor uses for the gesture that was refused, so that a refused gesture reads on screen as a refusal rather than as a gesture that did nothing (ADR-0005's Note of 2026-09-08, item 6).

The sentence never names a block by its id. Ids under blk_ are minted by the package and an author never sees one, so a sentence that quoted one would name the block in a language the author does not read; what it quotes instead is what the author typed or picked - a slot name, a type name, a recipe name, a config key.

A term this function does not recognize gets the generic sentence rather than an inspected tuple, for the reason StatifierBlocks.Declarations.refusal/1 gives about its own: a term a surface cannot phrase is a term the author cannot act on either. A datamodel envelope is handed to that function rather than re-phrased here, so the declarations panel and this surface say one thing about one refusal.

iex> StatifierBlocks.Edit.Session.refusal(:nothing_to_undo)
"There is nothing to undo."

iex> StatifierBlocks.Edit.Session.refusal({:unknown_block_type, "myapp.nope"})
~s(The palette has no block type "myapp.nope".)

iex> StatifierBlocks.Edit.Session.refusal({:composite_expansion_failed, "blk_GS", :empty})
"That block's declaration cannot be expanded, so nothing was replaced."

iex> StatifierBlocks.Edit.Session.refusal(:something_a_later_amendment_adds)
"That change was refused."

step(session, direction)

@spec step(t(), :undo | :redo) :: {:ok, t()} | {:error, t()}

Steps the history one move: :undo or :redo.

Both directions arrive at the same reconciliation, because History has already done the part that differs by the time this runs. Drafts are dropped wholesale on a successful step - see the moduledoc for why - and an empty stack answers {:error, session} with :nothing_to_undo or :nothing_to_redo in last_error.

Examples

iex> alias StatifierBlocks.{Block, Document, Palette}
iex> alias StatifierBlocks.Edit.{History, Session}
iex> document = Document.new(Block.new("core.sequence", id: "blk_ROOT", slots: %{"body" => []}))
iex> session = %Session{palette: Palette.core(), document: document, history: History.new()}
iex> {:error, nothing} = Session.step(session, :undo)
iex> nothing.last_error
:nothing_to_undo
iex> {:ok, block} = Palette.new_block(session.palette, "core.placeholder")
iex> {:ok, moved} = Session.commit(session, {:insert, {"blk_ROOT", "body", 0}, block})
iex> {:ok, back} = Session.step(moved, :undo)
iex> length(Document.blocks(back.document))
1

update_list(session, id, field, gesture)

@spec update_list(
  t(),
  StatifierBlocks.Block.id(),
  StatifierBlocks.ViewModel.Field.t(),
  list_gesture()
) ::
  {:ok, t()} | {:error, t()}

Applies a list gesture to field on the block id, and commits it.

The rows are read from the block's effective config - the draft if one is held, the document's otherwise - and written back through the field's own value_path/1, which is where the form's other writes go. The commit is change_config/3's, so a list edit that leaves the config invalid is held as a draft like any other refused config rather than being lost.

gesture is a list_gesture/0: :add, {:remove, index}, or either of those addressed at a nested member list by a {path, gesture} pair.