StatifierBlocks.Edit (StatifierBlocks v0.14.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.

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()]}

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 runs its validate_config/1 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).

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.