Powered by AppSignal & Oban Pro

bloccs · 01 — Your first network

notebooks/01_first_network.livemd

bloccs · 01 — Your first network

Mix.install([
  {:bloccs, "~> 0.9"}
])

What this notebook builds

bloccs turns a TOML manifest into a running Broadway supervision tree. This notebook runs the whole loop — parse → validate → compile → run — on the smallest graph there is: one pure node that greets a name.

entry ─▶ [greeter] ─▶ exit
         (pure)

You will:

  1. Register two versioned schemas that describe the payloads.
  2. Write a node manifest (hello_node.bloccs) and a network manifest (hello.bloccs).
  3. Define the node module — a pure core that computes and an effect shell that emits.
  4. Compile the network in-process, start it, push a message, and read the reply.

Everything runs in the BEAM inside this notebook. No external services, no files to clone.

Vocabulary in one paragraph

A node has typed input and output ports; each port carries a versioned schema (Name@1). A node splits into a pure core (transform/2, no effects, deterministic) and an effect shell (execute/2, the only place effects happen). A network wires nodes together with edges and exposes named entry/exit ports. The manifest is the source of truth; the runtime is generated from it.

1 · Register the schemas

Schemas declare the shape of what flows between ports. They live in a :persistent_term-backed registry, so registering them is all the setup the schema layer needs.

Bloccs.Schema.register("Name@1", name: :string)
Bloccs.Schema.register("Greeting@1", message: :string)

2 · Write the manifests

A network manifest references its nodes by file path, and use Bloccs.Node (next section) reads the node manifest at compile time. The macro needs a literal path, so this notebook writes both manifests to a fixed directory under /tmp and points at them by their absolute paths.

dir = "/tmp/bloccs_first_network"
File.mkdir_p!(dir)

File.write!(Path.join(dir, "hello_node.bloccs"), """
[node]
id      = "hello"
version = "0.1.0"
kind    = "transform"

[doc]
intent = "Greet a name. The smallest possible node: pure, no effects."

[ports.in]
greeting = { schema = "Name@1" }

[ports.out]
reply = { schema = "Greeting@1" }

[effects]

[contract]
pure_core    = "BloccsNotebook.Hello.transform/2"
effect_shell = "BloccsNotebook.Hello.execute/2"
""")

File.write!(Path.join(dir, "hello.bloccs"), """
[network]
id      = "hello"
version = "0.1.0"
runtime = "beam"

[nodes]
greeter = { use = "hello_node.bloccs" }

[expose]
in  = { entry = "greeter.greeting" }
out = { exit  = "greeter.reply" }

[supervision]
strategy = "one_for_one"
""")

File.ls!(dir)

The node declares an empty [effects] block — it touches nothing outside itself, so the pure core is the whole story. The [contract] lines bind the manifest to the two functions defined next; the network exposes greeter‘s ports as entry (in) and exit (out).

3 · Define the node

use Bloccs.Node reads the manifest at compile time, validates it, and confirms the pure_core/effect_shell functions exist with the right arity. The generated runtime calls back into this module, so the manifest path must point at the file written above.

  • transform/2 is the pure core: a name in, a greeting out. No IO, no effects, fully deterministic — the part you unit-test in isolation.
  • execute/2 is the effect shell: here it only emits the pure result to the reply port. In a node that declared effects, this is where HTTP/DB calls would live, reached through ctx.effects.
defmodule BloccsNotebook.Hello do
  use Bloccs.Node, manifest: "/tmp/bloccs_first_network/hello_node.bloccs"

  def transform(req, _ctx) do
    case req[:name] || req["name"] do
      name when is_binary(name) and name != "" ->
        {:ok, %{message: "Hello, #{String.capitalize(name)}!"}}

      _ ->
        {:error, :invalid_name}
    end
  end

  def execute(reply, _ctx), do: {:emit, :reply, reply}
end

4 · Parse → validate → compile → run

This is the loop bloccs runs every time. Each step is a function you can call directly:

  • Parser.parse_network/1 turns the TOML into a typed network struct (and pulls in each referenced node manifest).
  • Validator.validate_network/1 checks that ports are declared, edges schema-match, and the contract functions exist — failures here happen before anything starts, not in production.
  • Compiler.compile_and_load/1 generates a Broadway supervisor for the network and loads it into memory. (The CLI’s mix bloccs.compile writes the same source to _build/bloccs_generated/ for review; in-process we skip the file step.)
  • The returned module is a plain OTP Supervisorstart_link/1 boots the tree.
{:ok, network} = Bloccs.Parser.parse_network("/tmp/bloccs_first_network/hello.bloccs")
:ok = Bloccs.Validator.validate_network(network)
{:ok, sup} = Bloccs.Compiler.compile_and_load(network)
{:ok, _pid} = sup.start_link([])

network.id

Push a message, read the reply

Router.register_sink/4 subscribes this process to an exposed output port, so the reply lands in the notebook’s mailbox. Producer.push/2 feeds a payload into an input port. The message flows through the compiled Broadway pipeline and comes back out greeter.reply.

Bloccs.Router.register_sink(:hello, :greeter, :reply, self())

Bloccs.Producer.push(
  Bloccs.Router.producer_name(:hello, :greeter, :greeting),
  %{name: "ada"}
)

receive do
  {:bloccs_sink, :hello, :greeter, :reply, %{message: message}} -> message
after
  2_000 -> "(no reply within 2s)"
end

You should see "Hello, Ada!" — a name pushed into a TOML-described network, processed by a pure core, emitted by an effect shell, and routed back to you.

Try it

  • Push a different name, or an empty string (%{name: ""}) — the pure core returns {:error, :invalid_name} and nothing reaches the sink.
  • Open /tmp/bloccs_first_network/hello.bloccs and change id; re-run from section 4 and watch the network boot under the new name.

Where next

  • 02_events_webhook — the same loop at realistic scale: HTTP + DB effects, retry, timeout, branching, and coverage.
  • 03_routing_primitives — filter, split, and merge, each as a small runnable network.
  • 04_aggregation_primitives — batch, join, and rate.
  • The library guides — the manifest reference and the architecture behind the generated Broadway tree.