Skip to main content

CHANGELOG.md

# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.0] - 2026-06-24

### Added
- **Routing introspection** (#6) — two public functions on `NebulaAPI.APIServer`, keyed by
  `{fn_name, arity}`:
  - `configured_nodes/2` — the method's compile-time serving set (the selector resolved over the
    topology, connected or not). Persisted as module metadata, so it answers for any method on
    any node.
  - `available_nodes/2` — the nodes that currently have a live worker for the method (from
    `:pg`); a subset of the configured set.

- **`mix nebula.routes` + `NebulaAPI.Server.print_routes/0`** (#8) — print the per-node routing
  map "git lola"-style: one continuous vertical rail per node (name + `@short`/`&tag` selectors),
  a `●` marking each node that serves a method and the rail (`|`) continuing where it isn't local;
  current node in bold, serves-nothing nodes greyed. `NebulaAPI.Routes` holds the logic; the mix
  task works in a single app or at an umbrella root, `print_routes/0` is the iex entry point.
  Scope: lists only the modules and `defapi` present in this build (compiled for `compiled_node()`).
  - **Static view** — `●` local · `|` not local here. Asserts only locality (compile-time,
    config-known); it makes no claim that a method actually runs remotely — that's `--available`.
  - **`--available`** — live overlay from `:pg` + `Node.list`: `●` local · `∆` remote-reachable ·
    `x` worker down · `X` node down · `-` not served here · `|` unknown (this node can't observe
    the cluster, e.g. run offline); a disconnected node's column is greyed.
  - **`--follow`** — refresh every 5s (implies `--available`); `--no-color`.
  - **Sorting** — `sort:` / `--sort`: `:module` (default), `:name`, or `:locality` (most-local first).

- **`:nebula` compiler warning** (#5) — warns (without failing the build) when an app wires
  `nebula_api_server()` but defines no `defapi` methods at all (a server with nothing to serve).
  An app whose methods are merely all-remote on this build still has `defapi`, so it does not warn.

### Changed
- **Single source of truth for method metadata** (#6) — `{fn_name, arity} -> configured_nodes`
  (`:nebula_configured_nodes`) is now the only per-method attribute. The former
  `:nebula_local_api_methods` / `:nebula_remote_api_methods` lists are dropped;
  `registered_local_methods/1` and `registered_remote_methods/1` derive local/remote from the
  configured set against the build's compiled `self_node`. Internal — public behaviour unchanged.

### Fixed
- A user `@doc` written above a `defapi` now attaches to the **public router** function instead
  of the first generated `defp __nbapi_*` helper (where Elixir discarded it with a warning). The
  router is emitted first in the expansion, so `@doc`/`@spec` above a `defapi` document the public
  API as written.
- A **no-selector `defapi` compiled off-topology** (only reachable via `allow_unknown_self_node`,
  for a throwaway/generic node not in the configured cluster) now emits a remote stub instead of a
  dead local body. Such a node serves nothing, so this aligns codegen with the persisted serving
  set, with `registered_local/remote_methods`, and with the boot policy (which runs it in generic
  mode anyway). Normal builds (self_node in the topology) are unchanged — still local everywhere.
- `mix nebula.routes --available` no longer paints the **current** node's rail as `local` when it
  isn't actually connected. When this node can't observe the cluster (an offline invocation where
  `node()` is `nonode@nohost` and `current` is only the config `self_node` fallback), every cell is
  reported `:unknown` (`|`) rather than asserting a misleading green ● / `:node_unavailable`.

### Documentation
- Clarify that the `:nebula` compiler is **per-app**: it must be in each child app's
  `compilers:` and is **not** invoked at the umbrella root (a root-only placement does nothing).

### Tooling
- `mix precommit` alias (compile `--warnings-as-errors`, `deps.unlock --check-unused`,
  `format --check-formatted`, the distributed test suite) wired as a tracked git pre-commit hook
  (`.githooks/`, enabled by `mix setup`).

## [0.5.1] - 2026-06-15

### Documentation
- Added `ABOUT-LLMS.md` — a transparency note on how LLMs were (and were not) used in this
  project: the concept and first version are human (first committed March 2024), LLMs assist
  with code generation, automated review, and docs under human architectural direction, and
  no version reaches `main` without human validation. Published to the docs.

## [0.5.0] - 2026-06-15

### Added
- **`quorum:` mode — the default quorum is now a majority of the *configured* set, not the
  connected workers.** A quorum needs a fixed set to take a majority of (otherwise two sides
  of a partition each reach "their" quorum). On `strategy: :quorum`, a new `quorum:` option
  picks the denominator:
  - `:configured` (**the default**) — the configured nodes serving the method that match the
    selector, connected or not. `call_on_nodes &db, strategy: :quorum` over three configured
    `&db` nodes needs 2, so a single live node refuses (`:quorum_unreachable`). The method's
    configured serving set is baked into its generated remote stub (`:__method_configured_nodes`),
    config-derived and identical on every build.
  - `:available` — a majority of the connected workers (`div(present, 2) + 1`), the previous
    behaviour; "most of whoever is up", not a durability quorum.
  - A **function selector** with `strategy: :quorum` must state its count explicitly —
    `quorum: :available` or `at_least: n`; `:configured` (the default) is a compile error, since
    a runtime function has no static set to take a majority of (no silent downgrade). `at_least:`
    (an exact count) is mutually exclusive with `quorum:`.
- **Boot-time node policy.** NebulaAPI bakes routing in per node at compile time, so a
  release must run as the node it was compiled for. `nebula_api_server()` records the
  compile-time node; `NebulaAPI.Server` decides at boot (`server_mode/3`):
  - **running as exactly the compiled (real) node → serves normally.** This is the only case
    that starts workers and serves.
  - **compiled nameless AND running nameless** (`nonode@nohost` both sides) → a noop generic
    node, no escape hatch needed (it's the deliberate nameless build running as intended).
  - **any other case refuses to boot** with an explicit message — a worker build run as
    `api@host`, a nameless (*forgot `--name`?*) build run under a real name, or a real build
    run as `nonode@nohost` — *unless* the escape hatch is set.
  - **`ALLOW_RUNTIME_NEBULA_NODE_MISMATCH=1`** turns the mismatch into a **generic node**:
    `nebula_api_server()` becomes a no-op (no workers, a boot warning) and every `defapi`
    call routes **remote** (the node serves nothing). Run as a real name it can still reach
    the cluster (a quick prod console that calls but serves nothing); run as `nonode@nohost`
    it's fully inert (out of cluster). The generated routers consult a boot-set flag /
    `node()` so even locally-compiled bodies route remote in generic mode.
- **`allow_nonode_nohost: true`** registers `nonode@nohost` as an empty, tagless node so a
  nameless build compiles cleanly. Without it, compiling with no `--name` (so `node()` is
  `:nonode@nohost`) is now a **CompileError** — a *missing* node name is distinct from an
  *unknown* one, so `allow_unknown_self_node` deliberately does not cover it; you opt into a
  nameless build explicitly. `nonode@nohost` may also **never** be listed in
  `config :nebula_api, :nodes` directly (reserved identity — doing so raises); the flag is
  the only way to admit it, always empty.

### Changed
- **BREAKING: juxtaposed positive tags `&a &b` now mean intersection (AND), not union (OR).**
  `&a &b` matches nodes carrying *both* tags, consistent with `@n &t`, `&t !&u` and `!&a !&b`
  (all of which narrow). Express a union by giving both node groups a shared tag in config and
  selecting that. A combination that matches no node is a `CompileError`
  (`No nodes found for execution`), so an over-broad reliance on the old OR surfaces at build.
- **BREAKING: `call_on_*` arguments must be literal at the call site.** The selector must be a
  `&tag`/`@node` selector, a literal `fn`, or omitted; the options must be a literal keyword
  list with literal `strategy:`/`quorum:` atoms (individual values like `timeout:` may still be
  dynamic). A variable or computed selector or opts list is now a `CompileError` — branch in
  plain Elixir and write a separate `call_on_*` per case. This removes the silent `:available`
  downgrade for function selectors and makes the quorum denominator fully decidable at compile
  time.
- The method's configured quorum set (`:__method_configured_nodes`, baked into the generated
  stub) is now authoritative: a caller can no longer override it from the call site to shrink a
  quorum.

### Removed
- **The wildcard selector is gone.** To make a `defapi` body run on every node, **omit the
  selector entirely** — `defapi name(args) do ... end` — the same way
  `call_on_nodes`/`call_on_all_nodes` with no selector means "everyone". "No selector = all
  nodes" is the natural reading, with no special-case selector to learn.

### Fixed
- **Canonical space-juxtaposed multi-selectors now compile in `defapi` and
  `call_on_node` / `call_on_nodes`.** The canonical NebulaAPI syntax juxtaposes
  selectors with a space (`defapi &db !@backup, get(id)`), never a bracketed
  list. Elixir folds a juxtaposed chain's trailing argument (the `defapi`
  signature, or the `call_on_*` opts) into the chain's deepest selector
  (`&db !@backup, get(id)` parses as `&db(!@backup, get(id))`); the macros now
  lift that trailing argument back out before handing the pure chain to the
  parser. Previously only a single selector or the bracketed `[&db, !@backup]`
  form compiled for these macros. The bracketed list keeps working as a
  tolerated, non-canonical alternative. Covered by `nebula_ast_parsing_test`.
- **The inline `do:` (and `else:`) form now works with multi-selectors too**, across
  `defapi`, `on_nebula_nodes` and `call_on_node` / `call_on_nodes` — e.g.
  `defapi &db !@backup, get(id), do: ...` and
  `on_nebula_nodes &worker !@backup, do: ..., else: ...`. The paren-less parse folds the
  `do:`/`else:`/opts keyword list into the selector chain (arity 1); the macros lift it
  back out alongside the signature. Block (`do ... end`) and inline forms now behave
  identically for one selector or many.

## [0.4.0] - 2026-06-13

### Changed
- **Breaking: transparent return contract.** A `defapi` body's return value is now passed
  through verbatim — there is no automatic `{:ok, value}` wrapping. `add(3, 7)` returns
  `10`; `Repo.get/2` returns `%User{}` or `nil`; an `{:ok, _}` / `{:error, _}` you return is
  preserved as-is and always means a *business* result.
- **Breaking: dedicated `:nebula_error` status for library/transport failures** (timeout,
  no worker available, worker/network crash, a body exception, quorum not reached):
  `{:nebula_error, reason}`. `:ok` / `:error` therefore never collide with library faults.
- **Breaking: multicast result shape.** Per-node results are now `{node, value}` (a
  transport failure for a node is `{node, {:nebula_error, reason}}`). `:first` without a
  qualifying success returns `{:nebula_error, :no_success, results}` — it never returns a
  bare list (a bare-list result was the one library failure outside the `:nebula_error`
  channel, and the same "list" shape meant success for `:quorum` but failure for `:first`).
  `:quorum` returns the list of `{node, value}` when reached, otherwise
  `{:nebula_error, :quorum_not_reached, results}` or `{:nebula_error, :quorum_timeout, results}`.
  Migration: expect raw body values instead of `{:ok, _}`; match `{:nebula_error, _}` for
  transport faults; update multicast matches to `{node, value}`; replace `quorum_count:`
  with `at_least:`.
- Node-info is now refreshed by a per-node background `NebulaAPI.APIServer.NodesInfoCache` on a fixed
  interval instead of being rebuilt lazily on every read — this removes the refresh stampede
  under concurrency. `get_nodes_info/0` is a pure read: it never builds the snapshot itself,
  not even on a cold cache (during the boot window it returns `%{}`; selectors still see
  every pg-registered node through synthesized entries). The background tick is the one and
  only builder.
- Internal worker wire format: calls now ship as `{:nebula_call, fn_call}`.
- `use NebulaAPI` now generates a `__nebula_api__/1` accessor for its options
  (`:default_timeout`, `:max_concurrent_calls`): a function head on a literal,
  so per-call timeout resolution no longer scans the module's attribute list.
  The persisted `:nebula_api` attribute remains (server discovery, compile-time
  `self_node`).

### Added
- `success:` / `failure:` options on `call_on_nodes` (`:first` / `:quorum`): a predicate
  `fn value -> boolean` defining what counts as a business success. Default: any worker that
  responded. Example: `success: &match?({:ok, _}, &1)`. Passing either option outside
  `:first`/`:quorum` (unicast, `strategy: :all`) raises an `ArgumentError` up front;
  `call_on_node` also rejects them at compile time. A predicate that raises, throws or
  exits is contained like a body would be: the call returns `{:nebula_error, exception}`
  / `{:nebula_error, {kind, reason}}` — it never crashes the caller.
- `at_least:` option on `call_on_nodes` (`:quorum`): the number of successes required,
  as a positive integer — an absolute durability floor ("at least 2 nodes hold this
  write"), legitimately below majority. Without it the quorum defaults to a strict
  majority of the targeted workers. Malformed values raise `ArgumentError` up front.
- Options-only form for `call_on_node` / `call_on_nodes`: the selector argument can be
  omitted entirely. `call_on_node timeout: 30_000 do ... end` is a unicast to any
  available worker — a semantic with_options, free of the trailing-routing-opts
  positional gotcha. `call_on_nodes strategy: :quorum, at_least: 2 do ... end` fans out
  to every node serving the method; `call_on_all_nodes` is now the named alias of that
  form. Unambiguous by construction: a nebula selector list contains `@`/`&`/`!` AST
  nodes, never keyword pairs (`[]` stays an empty selector, rejected at compile time —
  see Fixed).
- `nodes_info_refresh_interval` config option (ms, default `5000`).
- `max_concurrent_calls` option on `use NebulaAPI` (default `:infinity`): caps how many
  calls a module's worker executes concurrently, per node. Excess calls queue (callers
  keep their own timeout); each queued entry is monitored through its caller and purged
  unexecuted the moment nobody awaits it anymore (timeout, early `:first` resolution,
  caller crash, disconnect). `max_concurrent_calls: 1` restores strict serialization,
  explicitly — per node, like the limit itself.
- Configurable timeouts: per call (`timeout:`) > per module (`use NebulaAPI,
  default_timeout: ...`) > global (`config :nebula_api, default_timeout:`) > 5000 ms.
  Both options are also accepted in `config :nebula_api, default_opts: [...]` as
  inherited defaults for every `use NebulaAPI` module.
  Timeouts are validated up front like every other call option: a non-positive,
  non-integer or `:infinity` timeout raises `ArgumentError` at the call site
  (previously, `:infinity` half-worked: fine on unicast, melted into
  `{:nebula_error, %ArithmeticError{}}` on multicast). `timeout: nil` is the one
  documented exception: it means "not set" and inherits the default resolution —
  a computed `timeout: maybe_timeout` holding nil behaves as if the option were
  absent (`false` does NOT: like any other non-integer, it raises).

### Fixed
- `:quorum` strategy no longer silently clamps an impossible `at_least:` requirement to the
  available worker count — asking for 3 confirmations and "reaching quorum" with 2 would lower the
  caller's durability guarantee behind their back. An impossible quorum now returns
  `{:nebula_error, :quorum_unreachable, %{workers: n, required: m}}` before making any
  call — for a write quorum, no partial non-quorate write is even attempted.
- `:quorum` with zero available workers no longer returns `[]` (an empty-list pseudo-success);
  it returns `{:nebula_error, :quorum_unreachable, %{workers: 0, required: m}}`.
- A function selector returning duplicate nodes no longer makes a node count twice —
  toward the `:quorum` requirement especially, where two replies from one physical node
  passed for two confirmations. Selected nodes are deduplicated before the fan-out.
- An unknown `strategy:` no longer falls into the `:all` catch-all (`strategy: :qourum`
  silently turned a quorum write into a plain broadcast); it raises `ArgumentError` up
  front, as does `strategy:` on a non-multicast call, where it would be silently ignored.
- The `nil`-means-"not set" convention now covers every call opt: `strategy: nil`
  resolves to the `:all` default (it used to raise), and `success: nil` / `failure: nil`
  are absent for the applicability check too (they used to raise "would be silently
  ignored" on unicast while already counting as unset on `:first`/`:quorum`).
- A `node_selector:` that is not a 1-arity function raises `ArgumentError` up front,
  like every other malformed call opt — it used to melt into
  `{:nebula_error, {:selector_failed, {:badfun, _}}}` at selection time, the one
  programming error reported on the transport channel. `nil` still means "not set";
  what the function does remains a contained runtime concern.
- Unknown call option keys raise `ArgumentError` up front — the option set is closed
  (`timeout:`, `node_selector:`, `multicast:`, `strategy:`, `at_least:`, `success:`,
  `failure:`), so a typo'd key (`timout:`) or a stale one (`quorum_count:`, replaced by
  `at_least:`) was silently dropped and the call ran with defaults the caller never
  chose — for a quorum, a durability requirement quietly replaced by the majority
  default.
- An empty selector list (`[]`) raises a clear `CompileError` everywhere a selector is
  accepted (`defapi`, `on_nebula_nodes`, `call_on_node`/`call_on_nodes`) — it used to
  silently select every **configured** node. `[]` selects no node, so nothing could ever
  run; to run on every node, omit the selector entirely (in `call_on_*`, that means "no
  restriction").
- The `call_on_*` macros validate their literal options at compile time: an option the
  mode can never consume (`strategy:`/`at_least:`/`success:`/`failure:` on the unicast
  `call_on_node`), an unknown key, a malformed literal value (`timeout: :infinity`,
  `strategy: :qourum`, `at_least: 0`) or a statically-impossible combination
  (`at_least:` when the block resolves to a non-`:quorum` strategy, a predicate with
  `strategy: :all`) now fails the build at the call site instead of the first runtime
  call. Dynamic values (a variable `strategy:`, a whole-opts variable) keep the runtime
  `ArgumentError` backstop, where `nil` still means "not set".
- The "Invalid nebula selector" compile error now points out that dynamic selection (a
  variable or a selector function) only works in `call_on_node`/`call_on_nodes` —
  `defapi` and `on_nebula_nodes` are resolved statically at compile time.
- Fan-out tasks no longer grant a worker a 100 ms grace window past the multicast
  deadline (a reply earned there was always discarded — the worker just ran a body
  nobody collected); a task that starts with no budget left skips the call and reports
  `{node, {:nebula_error, :timeout}}` directly.
- The generated router detects an active `call_on_node`/`call_on_nodes` block through the
  context MODE, not the selector value: a selector expression that evaluates to `nil` at
  runtime now means "no restriction" (unicast: first available worker; multicast: every
  node serving the method) with the block's options still applying — previously the whole
  context was silently skipped, dropping `timeout:`/`strategy:`/`at_least:` and degrading
  a multicast block to a default unicast call. A selector **function** returning `nil`
  keeps its meaning: "nothing matched", zero calls — a no-match never widens the target.
- Inside a `call_on_*` block, the innermost explicit routing now wins: a call carrying
  its own truthy `node_selector:`/`multicast:` trailing opts routes itself (the block's
  routing and options are ignored for that call, like an inner block replaces the outer
  one) instead of being silently overwritten by the block. A routing key explicitly set
  to `nil` (or `multicast: false`) opts the call out of the block, back to default
  routing — `MyMod.f(x, multicast: false)` inside a multicast block is a plain default
  call. General rule inside a block: the block's opts are defaults, the call's own opts
  override them, an explicit `nil` opts out of the block's default back to the lib's.
- Literal `success:`/`failure:` values that can never be a predicate
  (`success: :not_a_fun`) now fail the build at the call site in the `call_on_*`
  macros, like every other malformed literal option value — no literal is ever a
  1-arity function; `nil` keeps meaning "not set", `fn`/`&` predicates stay a
  runtime concern.
- Routing opts are now validated on locally-resolved calls too: an invalid opt
  (`timeout: :infinity`, `strategy:`/`success:`/`failure:` without `multicast:`)
  raises `ArgumentError` identically on every node, instead of being silently
  ignored wherever the call happened to resolve local. Valid-but-inapplicable opts
  (a `timeout:` on a local call) stay a silent no-op; calls without opts skip
  validation entirely.
- Selectors now see every node with a registered worker, snapshot or not: pg decides WHO
  serves a method, the node-info snapshot only enriches HOW. A node whose worker just
  registered (not in the snapshot yet) gets a synthesized entry — name/host/config
  tags/connected derived locally, `runtime`/`last_seen_at` `nil` until the next refresh.
  Previously such a node was invisible to selectors (and to `call_on_all_nodes`) for up
  to `nodes_info_refresh_interval`.
- Unicast calls no longer crash the caller when a worker times out or is dead — the
  `GenServer.call` exit is caught and returned as `{:nebula_error, reason}`, with the late
  reply confined to a throwaway task (no stray messages reach the caller).
- The `:all` multicast strategy no longer exits the caller on timeout; it returns partial
  results, marking unanswered nodes `{node, {:nebula_error, :timeout}}`.
- Workers are non-blocking: each call runs in a supervised task and replies asynchronously,
  so a slow method no longer serializes a module's whole API and a re-entrant call no longer
  deadlocks (except under `max_concurrent_calls: 1`, where a re-entrant call into the same
  module waits out its own timeout — the slot is held by its parent).
- An unknown method, a malformed call, or a raising body returns `{:nebula_error, ...}`
  instead of crashing the worker. Stray info messages and casts are likewise logged and
  ignored — the worker (and its pending queue) survives anything that reaches its
  registered name.
- `NodesInfoCache` gets the same hardening as the worker: a stray info message, cast or
  call no longer crashes it (its `handle_info(:refresh)` clause had replaced the permissive
  `use GenServer` default, and the default `handle_call`/`handle_cast` raise) — repeated
  strays would have exhausted the supervisor's restart intensity.
- `build_nodes_info` no longer aborts the whole snapshot when one node's health collection
  crashes (a non-timeout task exit) — the faulty node is simply dropped.
- A body that throws or exits now yields `{:nebula_error, {kind, reason}}` locally
  too, matching the remote behavior — previously the throw/exit escaped the
  generated local function and propagated into the caller, so the same call could
  behave differently depending on where it ran.
- Invalid `defapi` selectors and signatures, using `defapi` without `use NebulaAPI`, and
  malformed node tags now raise clear `CompileError`s instead of internal crashes.
  Literal atoms in a `defapi` signature are rejected like every other pattern — they
  used to slip through and compile into a single-clause router whose misses crashed the
  caller with a `FunctionClauseError`.
  The same now holds for `call_on_node` / `call_on_nodes` nebula selectors: a typo'd
  node or unknown tag fails the build at the call site instead of melting into a
  runtime `{:nebula_error, {:selector_failed, ...}}`. Node selectors are compile-time
  by design — runtime selection goes through a function selector.
- `call_on_all_nodes timeout: 5_000 do ... end` — the block-with-options form the README
  has always advertised — now actually compiles: it parses as two arguments and no
  arity-2 head existed to receive it.
- `mix docs` (and therefore `mix hex.publish`) no longer fails — the `docs` extras point at
  files that exist.
- `defapi` no longer emits compiler warnings in consumer modules ("default values for
  the optional arguments ... are never used" on every defapi, "variable is unused" on
  remote-compiled defapis with arguments) — defaults now live only on the generated
  public function. Consumers building with `warnings_as_errors` compile cleanly.
- The generated router no longer carries a branch whose outcome is known at codegen
  time: its default branch is emitted directly as local (matching nodes) or remote
  (everywhere else), and the raising `__nbapi_local_*` stub — which only existed to
  keep that dead branch compilable on remote nodes — is not generated at all anymore.

### Documentation
- Rewrote all return-value documentation for the transparent contract. Corrected the
  local-call overhead figure (~0.00002 ms — a few process-dictionary reads, not zero) and
  clarified that `call_on_all_nodes` targets the nodes that serve the method, not every
  configured node.
- Documented nesting and process scope of the `call_on_*` blocks: an inner block
  replaces the whole context (no option merge) and the outer one is restored on exit,
  exceptions included; the context is per process — it follows neither a spawned
  process nor the RPC boundary (a block governs one hop, the calls written directly in
  it). Also documented that a `defapi` inside `on_nebula_nodes` disappears entirely
  (router included) on non-matching nodes — no transparent RPC from there.

## [0.3.0] - 2026-06-08

### Added
- `use NebulaAPI.Server` + `nebula_api_server/0` macro: wire it into an OTP application's
  supervision tree to start a per-app `NebulaAPI.Server`, which discovers that app's
  modules using `NebulaAPI` and supervises one worker per locally-served module.
  `use NebulaAPI.Server` is the lightweight host-module entry point — it brings the macro
  (and the `NebulaAPI.AST` macros) into scope without the `defapi` bookkeeping that
  `use NebulaAPI` performs.
- Optional `:nebula` Mix compiler (`compilers: Mix.compilers() ++ [:nebula]`): fails
  compilation with an explanatory error when an app has modules with local methods but
  no `nebula_api_server()` wired into its supervisor.
- Select a full node name with an atom selector `@:"node@host"` in `defapi` /
  `on_nebula_nodes` — the parser previously accepted only short-name identifiers (`@db`).

### Changed
- **Breaking:** removed the `registered_modules` config option. Module workers are now
  discovered per app at runtime (via `nebula_api_server/0`) instead of being listed in
  config. Migration: drop `registered_modules` and add `nebula_api_server()` to each
  consuming app's supervisor children.
- Workers now live in the supervision tree of the app that owns their module, so they
  share the app's lifecycle — when the app stops or crashes, its workers go down and
  `:pg` drops them (no more stale routing entries). The central `APIServer` is reduced
  to the `:pg` scope, the node-health ETS cache, and routing.

### Documentation
- Rewrote the README and `docs/` to be generic and library-only, and added a 5-node
  `docker compose` demo under `demo/`.

## [0.2.0] - 2026-06-07

First standalone release, extracted from the podCloud Nebula umbrella with its
full git history preserved.

### Added
- Unicast/multicast remote calls — `call_on_node`, `call_on_nodes`,
  `call_on_all_nodes` — with `:all` / `:first` / `:quorum` strategies.
- `nodes_info` cache with `last_seen_at` tracking for intelligent routing.

### Changed
- Zero external dependencies: `libcluster` removed — clustering is the
  consumer's concern (use libcluster, epmd, DNS, Kubernetes, etc.). The
  podCloud-specific cluster strategy now lives in the consuming application.

### Documentation
- Expanded README: "Wrap any single-node library" (cluster-wide Hammer, counters,
  cron, singletons, feature flags, cache caveat), "When NOT to use NebulaAPI", a
  "compile per release" callout, and an indicative performance table.