StatifierBlocks.Document (StatifierBlocks v0.29.0)

Copy Markdown View Source

A block document: one tree, one envelope (ADR-0001).

This module is the package's single public entry point over the tree: construction, the shared pre-order walk, and path lookup live here. Canonical encoding, content identity, and structural decoding land in later phases of the same bead.

The document also carries its own datamodel key: a list of StatifierBlocks.Document.DatamodelEntry structs naming the <data> roots the document's own guards and assigns read (ADR-0001 decision 11). It follows, never leads, the compile call's :declare option - see StatifierBlocks.Compiler's moduledoc for the precedence rule.

committed_config/2 and effective_config/3 are the two config readers a surface asks the document, public because the reference embedder's Plan view - the first consumer of both - had written them out privately in order to draw a config form over a document at all. They are lookups over blocks/1: what the document says, and what a held draft says instead.

Summary

Types

"bdoc_" <> uxid.

Derived, never stored. Identifies a position, not a block.

t()

ADR-0001's typespec block: everything validate/1 and from_json/1 can reject, defined once.

Functions

Every block in document, pre-order, root first.

The config the document holds for the block carrying id, or %{} when no block in document carries it.

SHA-256 over to_json/1. Stable document identity.

The config a surface should act on for the block carrying id: the draft drafts holds for it, else committed_config/2.

The path from the root to the block carrying id.

Structural decode. Never consults the block-type registry (ADR-0001 decision 9); unknown type names decode successfully.

Wraps root in a document envelope.

Canonical JSON per ADR-0001 decision 8. Deterministic: sorted object keys, no insignificant whitespace, empty slots/config/metadata/datamodel omitted, no floats.

Checks document against ADR-0001's structural rules: schema version, envelope shape (including the datamodel key's own entry shape and id uniqueness - decision 11), per-block shape (id, type, type_version, config, slots), and document-wide id uniqueness. Never consults a block-type registry - config is opaque here and type is never resolved against anything.

Types

id()

@type id() :: String.t()

"bdoc_" <> uxid.

path()

Derived, never stored. Identifies a position, not a block.

t()

@type t() :: %StatifierBlocks.Document{
  datamodel: [StatifierBlocks.Document.DatamodelEntry.t()],
  id: id(),
  metadata: %{optional(String.t()) => StatifierBlocks.Block.json()},
  revision: non_neg_integer(),
  root: StatifierBlocks.Block.t(),
  schema_version: pos_integer()
}

validation_error()

@type validation_error() ::
  :not_a_block_document
  | {:unsupported_schema_version, pos_integer()}
  | {:duplicate_block_id, StatifierBlocks.Block.id()}
  | {:malformed_block, StatifierBlocks.Block.id() | nil, term()}
  | {:malformed_envelope, term()}

ADR-0001's typespec block: everything validate/1 and from_json/1 can reject, defined once.

Functions

blocks(document)

@spec blocks(t()) :: [StatifierBlocks.Block.t()]

Every block in document, pre-order, root first.

Within a block, slots are visited in UTF-8-sorted slot-name order so every consumer of this walk sees one deterministic order regardless of how the slots map happened to be built. Later phases (encoding, validation) build on this walk rather than re-deriving their own.

committed_config(document, id)

@spec committed_config(t(), StatifierBlocks.Block.id()) ::
  StatifierBlocks.Block.config()

The config the document holds for the block carrying id, or %{} when no block in document carries it.

A lookup over blocks/1 and nothing else: what the document says, with no draft, no palette and no schema anywhere near the answer. The first consumer is the reference embedder's Plan view, which needs the committed config in order to show what an editor's unaccepted draft would change; the package's own editor asks the same question for the same reason.

%{} for an absent block rather than nil, because every caller is about to read config keys out of the answer and a block that is not there has none of them - the same shape as a block that carries no config at all.

iex> alias StatifierBlocks.{Block, Document}
iex> root =
...>   Block.new("core.sequence",
...>     id: "root",
...>     slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
...>   )
iex> document = Document.new(root)
iex> Document.committed_config(document, "wait")
%{"duration" => "30s"}
iex> Document.committed_config(document, "absent")
%{}

content_hash(document)

@spec content_hash(t()) :: binary()

SHA-256 over to_json/1. Stable document identity.

effective_config(document, id)

@spec effective_config(t(), StatifierBlocks.Block.id()) ::
  StatifierBlocks.Block.config()

effective_config/3 for a caller holding no drafts: committed_config/2.

It exists so that a surface that has not grown drafts yet, and a test that is not about them, need not invent an empty map in order to say there are none.

iex> alias StatifierBlocks.{Block, Document}
iex> root =
...>   Block.new("core.sequence",
...>     id: "root",
...>     slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
...>   )
iex> Document.effective_config(Document.new(root), "wait")
%{"duration" => "30s"}

effective_config(document, id, drafts)

The config a surface should act on for the block carrying id: the draft drafts holds for it, else committed_config/2.

A draft is config the document refused, held so the author keeps their keystrokes (ADR-0002 decision 9). Reading a form back, or offering a value to a field control, has to start from the draft where one exists or the author's second edit is applied to the value their first one replaced.

drafts is the map a surface keeps of block id to unaccepted config.

iex> alias StatifierBlocks.{Block, Document}
iex> root =
...>   Block.new("core.sequence",
...>     id: "root",
...>     slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
...>   )
iex> document = Document.new(root)
iex> Document.effective_config(document, "wait", %{"wait" => %{"duration" => "48h"}})
%{"duration" => "48h"}
iex> Document.effective_config(document, "wait", %{})
%{"duration" => "30s"}

fetch_path(document, id)

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

The path from the root to the block carrying id.

A path names the {parent block id, slot name, index} steps taken from the root, so the root's own path is {:ok, []} - it has taken none. Returns :error when no block in document carries id.

from_json(binary)

@spec from_json(binary()) :: {:ok, t()} | {:error, validation_error()}

Structural decode. Never consults the block-type registry (ADR-0001 decision 9); unknown type names decode successfully.

Decoding is total and ordered: bytes that are not a JSON object carrying a "schema_version" key are :not_a_block_document; a recognizable document that is wrong in a specific way gets an envelope-, block-, or id-level arm instead. Nothing is rescued to a default and nothing raises.

An envelope key outside the known set (id, revision, root, schema_version, metadata, datamodel - decision 11 added the last) is refused rather than silently dropped, the same discipline the block-level decode already applies to an unrecognized block key.

new(root, opts \\ [])

@spec new(
  StatifierBlocks.Block.t(),
  keyword()
) :: t()

Wraps root in a document envelope.

Options: :id (default a freshly minted StatifierBlocks.Id.document/0), :revision (default 0), :metadata (default %{}), :datamodel (default [], a list of StatifierBlocks.Document.DatamodelEntry structs - ADR-0001 decision 11). :schema_version is not an option - decision 7 fixes it at 1 for this ADR's envelope.

to_json(document)

@spec to_json(t()) :: binary()

Canonical JSON per ADR-0001 decision 8. Deterministic: sorted object keys, no insignificant whitespace, empty slots/config/metadata/datamodel omitted, no floats.

Runs validate/1 first and raises ArgumentError carrying the validation reason when it fails, so an invalid document can never produce bytes that claim to be canonical.

validate(document)

@spec validate(t()) :: :ok | {:error, validation_error()}

Checks document against ADR-0001's structural rules: schema version, envelope shape (including the datamodel key's own entry shape and id uniqueness - decision 11), per-block shape (id, type, type_version, config, slots), and document-wide id uniqueness. Never consults a block-type registry - config is opaque here and type is never resolved against anything.