StatifierBlocks.Edit (StatifierBlocks v0.31.0)

Copy Markdown View Source

The editor's command algebra. Pure, serializable, invertible - and deliberately free of any UI framework dependency, so it is tested with no editor shell present at all (ADR-0005 decision 1).

apply/2 takes no palette (ADR-0005 decision 3's spec), so it is a purely structural rewrite: it never consults a block-type registry, never runs validate_config/1, and never checks whether a slot is declared. That gate lives one layer up, in check_config/3 and Edit.History.

The four structural rules

  1. A target's index is read against the slot's current children, where an absent slot key means []. Valid indices are 0..length(children) inclusive; anything else is {:error, {:index_out_of_range, target}}.
  2. An absent slot key is created by an insert or a move into it. This is not optional: a declared slot with no children carries no key in the document's slots map (Document.to_json/1 omits it, ADR-0001 decision 8), so refusing to create it would break the commonest drop there is.
  3. A remove prunes a slot key that it empties. Symmetric with rule 2 and with canonical JSON's omission of empty slots, and it is what makes apply(apply(d, e), inverse) == d hold as struct equality, not merely as equal canonical bytes.
  4. :move reads its target index against the slot with the moved block already removed (decision 4). Concretely: detach, then insert. The inverse of a move from {P, s, i} to {Q, t, j} is a move to {P, s, i} - the same i, because removing at i and re-inserting at i in the shortened list is the identity, and that is true whether or not P == Q and s == t.

The five inverses

CommandInverse
{:insert, target, block}{:remove, block.id}
{:remove, id}{:insert, original_target, detached_subtree}
{:move, id, target}{:move, id, original_target}
{:update_config, id, config}{:update_config, id, previous_config}
{:set_datamodel, entries}{:set_datamodel, previous_entries}

The fifth command (ADR-0005's 2026-09-01 amendment, 2g-2h)

{:set_datamodel, entries} replaces the document's whole datamodel list - ADR-0001 decision 11's ordered declaration entries - in one command. The four structural rules above are about the tree and none of them reaches it: an entry is not a block, the list is not a slot, and there is no target() to read an index against.

Whole-list replacement rather than per-entry insert, remove and move, for decision 2's own reason for collapsing seven commands into four: one code path that writes the key means the grammar check, the ordering and the inverse each have one implementation. Add, edit, remove and reorder are all the same command carrying a different list, the inverse is the list that was there before, and the round-trip law of decision 3 holds by construction because the command is its own kind of inverse.

This is the one command whose content is checked here rather than in check_config/3. check_config/3 is decision 9's block-type gate and it asks a palette; a declaration has no block type and no palette to ask. What a declaration has is a grammar - ADR-0001 11b's {id, expr, description} and 11c's structural id uniqueness - and that is the same kind of question rules 1 to 4 answer for the tree, so it is answered here. StatifierBlocks.Validation.datamodel/1 is the one implementation of it, so a list this command accepts is a list Document.validate/1 accepts, and to_json/1 can never raise on a document this command produced.

The deliberate widening

The record's typespec block lists four error arms. This module ships two more, and neither contradicts a decision - each names a case the record's list does not enumerate:

  • {:duplicate_block_id, Block.id()} - an :insert whose block (or whose subtree) carries an id already present in the document. ADR-0001 decision 1 makes document-wide id uniqueness an invariant and Document.validate/1 enforces it, so apply/2 must refuse rather than produce a document that fails its own validator. The spelling matches Decode's existing {:duplicate_block_id, id}.
  • {:cannot_remove_root, Block.id()} - a :remove or :move naming the document root. The root occupies no slot, so there is no position to detach it from and no inverse to write. {:no_such_block, id} would be a lie about a block that plainly exists.

The record's own {:no_such_slot, block_id, slot_name} arm is given the one meaning left to it once rule 2 above allows slot creation: a target whose slot name is not a usable slot key - not a binary, or empty. Commands are serializable values that can arrive from a replayed log, so this is a real arm, not a dead one.

The composition (ADR-0005 clause 2n)

{:compound, [t()]} carries a non-empty list of commands. apply/2 applies them left to right against the intermediate documents, and the inverse it returns is the compound of each step's inverse in reverse order. A member that refuses refuses the whole compound: apply/2 answers {:error, term()} with the member's own error term, unchanged, and no document at all, so there is no partially applied document for a caller to mistake for a result.

A compound is not a sixth edit. Its leaves are drawn from the five and nothing else - a list that is empty, or that holds a :compound of its own, is refused rather than flattened - so every edit a document can undergo is still one of the five. What the constructor buys is that Edit.History pushes one inverse per commit, which makes a compound one undo entry: one gesture in, one gesture out, and no state between the halves that an author can stop in.

The two refusals a malformed compound produces reuse the {:malformed_envelope, term()} arm above rather than minting an arm of their own. An empty or nested list is a value claiming to be a shape it is not, which is what that arm already means everywhere else in this package, and clause 2n's consequence that nothing reading these terms learns a new shape is why it is not a new one here.

Summary

Types

t()

Ids in an :inserted block are already minted, which is what keeps the command replayable (ADR-0005 decision 2).

A position, not a block. ADR-0001 decision 5's path element.

Functions

Applies one command, returning the new document and the command that undoes it. Total: refuses rather than raises. See the moduledoc's four structural rules and five inverses.

ADR-0005 decision 9's config gate. apply/2 above is purely structural and cannot ask a block type whether a config is valid; this is where that question actually gets asked, one layer up from the structural rewrite and one layer below Edit.History, which is the only caller (see its moduledoc).

Types

t()

@type t() ::
  {:insert, target(), StatifierBlocks.Block.t()}
  | {:remove, StatifierBlocks.Block.id()}
  | {:move, StatifierBlocks.Block.id(), target()}
  | {:update_config, StatifierBlocks.Block.id(), StatifierBlocks.Block.config()}
  | {:set_datamodel, [StatifierBlocks.Document.DatamodelEntry.t()]}
  | {:compound, [t()]}

Ids in an :inserted block are already minted, which is what keeps the command replayable (ADR-0005 decision 2).

target()

A position, not a block. ADR-0001 decision 5's path element.

Functions

apply(document, arg)

@spec apply(StatifierBlocks.Document.t(), t()) ::
  {:ok, StatifierBlocks.Document.t(), t()}
  | {:error, {:no_such_block, StatifierBlocks.Block.id()}}
  | {:error,
     {:no_such_slot, StatifierBlocks.Block.id(),
      StatifierBlocks.Block.slot_name()}}
  | {:error, {:index_out_of_range, target()}}
  | {:error, {:would_cycle, StatifierBlocks.Block.id()}}
  | {:error, {:duplicate_block_id, StatifierBlocks.Block.id()}}
  | {:error, {:cannot_remove_root, StatifierBlocks.Block.id()}}
  | {:error, {:malformed_envelope, term()}}

Applies one command, returning the new document and the command that undoes it. Total: refuses rather than raises. See the moduledoc's four structural rules and five inverses.

check_config(palette, document, arg)

@spec check_config(StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), t()) ::
  :ok
  | {:error,
     {:invalid_config, StatifierBlocks.Block.id(),
      [StatifierBlocks.BlockType.finding()]}}

ADR-0005 decision 9's config gate. apply/2 above is purely structural and cannot ask a block type whether a config is valid; this is where that question actually gets asked, one layer up from the structural rewrite and one layer below Edit.History, which is the only caller (see its moduledoc).

:ok for the three commands that are not :update_config - they never touch a block's config, so there is nothing for a block type to validate. For an :update_config, resolves the named block's current type through palette and asks it the same two questions the compiler's :config stage and the editor's view model ask, against the candidate config the command carries (not the block's current config - that one already validated, or the block would not exist in a valid document): the type's own validate_config/1, and StatifierBlocks.BlockType.type_expr_findings/2, the one implementation of what a {:type_expr, opts} field's value may be (ADR-0002 decision 7, amended 2026-09-06). A value that is no arm the field admits is refused here, at the edit gate, rather than passing the edit algebra and being refused first at compile. The findings of both are reported together on one {:invalid_config, id, findings}, type_expr_findings/2's first, in the order the compiler and the view model already use.

Also :ok when the block does not resolve through palette at all - unknown type, too-new version, or a failed migration. There is no authority to consult in that case, and ADR-0005 decision 12 already forbids the editor from offering a config form for an unresolvable block's config in the first place; refusing here would be this function inventing a rule that belongs to the editor, not restating one. The same :ok covers a block id apply/2 will itself refuse as {:no_such_block, id} - there is no config to check, and this function is not the one that reports that error.