StatifierBlocks.Edit (StatifierBlocks v0.1.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 four 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}

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

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

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