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
- A target's index is read against the slot's current children,
where an absent slot key means
[]. Valid indices are0..length(children)inclusive; anything else is{:error, {:index_out_of_range, target}}. - 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
slotsmap (Document.to_json/1omits it, ADR-0001 decision 8), so refusing to create it would break the commonest drop there is. - 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) == dhold as struct equality, not merely as equal canonical bytes. :movereads 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 samei, because removing atiand re-inserting atiin the shortened list is the identity, and that is true whether or notP == Q and s == t.
The four inverses
| Command | Inverse |
|---|---|
{: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:insertwhose block (or whose subtree) carries an id already present in the document. ADR-0001 decision 1 makes document-wide id uniqueness an invariant andDocument.validate/1enforces it, soapply/2must refuse rather than produce a document that fails its own validator. The spelling matchesDecode's existing{:duplicate_block_id, id}.{:cannot_remove_root, Block.id()}- a:removeor:movenaming 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
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
@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).
@type target() :: {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), non_neg_integer()}
A position, not a block. ADR-0001 decision 5's path element.
Functions
@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.
@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.