Skip to content
Strata is pre-1.0. APIs, protocol, and storage format may change.

Components and Bindings

strata_component defines typed headless components. It targets Erlang and JavaScript. It does not own a socket, persistence, presence, or a UI framework.

A host owns those services. The host sends component commands through strata_client. The host also routes document events to the correct component instance.

Build a component with strata_component.definition. The function takes nine arguments:

strata_component.definition(
kind: "strata/intake",
version: 1,
config_encoder: encode_config,
config_decoder: decode_config,
initial_document: make_initial_document,
init: do_init,
update: do_update,
receive: do_receive,
owned_document_ids: do_owned_document_ids,
)

The result is a typed Definition(root, context, config, model, message, output). Each type parameter has one purpose:

  • root is the schema tag for the root document.
  • context contains host-local values such as a replica id.
  • config is the persisted component configuration.
  • model is the headless runtime state.
  • message represents local operations.
  • output represents typed local events.

The constructor returns InvalidDefinition when kind is empty or version is not positive.

Use a stable kind string for each component contract. Increase the version when persisted config or behavior becomes incompatible.

The definition stores a config encoder and decoder. Use these accessors when a host stores or loads component metadata:

let kind = strata_component.kind(definition)
let version = strata_component.version(definition)
let config_json = strata_component.encode_config(definition, config)
let decoded = strata_component.decode_config(definition, config_dynamic)

decode_config returns InvalidConfig when the component rejects the value. The current Intake, Profile, Board, Checklist, and Composite examples use an empty JSON object as config.

Use strata_component.initial_document when the host creates a new component root. This call returns the initial typed document. The host can send it in a Create command.

Call strata_component.init with the definition, host context, config, and typed root id.

case strata_component.init(definition, context, config, root_id) {
Error(error) -> report_component_error(error)
Ok(strata_component.Init(model, commands)) ->
send_commands(model, commands)
}

Init returns the initial model and client commands. A component normally returns one Join command for its root document.

Call strata_component.update for a local component message:

case strata_component.update(definition, context, model, message) {
Error(error) -> report_component_error(error)
Ok(strata_component.Update(model, commands, outputs)) -> {
send_commands(model, commands)
route_outputs(outputs)
}
}

Keep the returned model. Send every returned command. Route each output after the local update succeeds.

Call strata_component.receive for a document event from strata_client:

case strata_component.receive(definition, context, model, event) {
Error(error) -> report_component_error(error)
Ok(strata_component.Receive(model, commands)) ->
send_commands(model, commands)
}

receive does not return outputs. A remote delta must not repeat work that a local output already started.

The functions report_component_error, send_commands, and route_outputs in these snippets are application helpers.

Each example component stores a strata_component.Status:

  • Loading means that the component waits for a usable root snapshot.
  • Ready means that local operations can run.
  • Failed(error) preserves a decode, schema, or merge failure.
  • Deleted means that the root received a Deleted event.

Return InvalidState when an operation cannot run in the current status. Do not replace invalid state with a new empty document.

ComponentError also reports invalid definitions and config, initial document failures, schema failures, document merge failures, and ownership failures. Report these errors through the host.

Call strata_component.owned_document_ids after init, update, and receive. The result contains the root id and any child document ids that the component owns.

Map each owned document id to one component root. Use that map to route Snapshot, RemoteDelta, and Deleted events. Return or report NotOwned when no component owns the event id.

The host must handle Welcomed, presence events, ServerError, and Pong. The component examples reject these host events in receive.

The workspace example uses this host model. It owns one WebSocket connection, one root PageStore, a dictionary of component models, and the ownership map. It uses closed sum types for all supported component models, messages, and outputs. It does not use a runtime registry.

The generic core delegates owned_document_ids and receive to component callbacks. It does not enforce host-wide uniqueness. The workspace already rejects duplicate routing claims; a new host must provide the same check.

ObligationRequired host behavior
Routing indexRebuild claims after init, local update, and receive. Map each document ID to one instance. Repeated claims by the same instance are harmless; claims by different instances are errors.
Atomic local adoptionValidate the new index before retaining the proposed model or sending its commands. A duplicate must leave the prior models, index, and outbox intact.
Unknown eventsReport an unknown document ID. A host can discard late events for an explicitly retired ID; keep this retirement policy separate from an unknown ID.
Page demandPair each open page that needs remote state with Join. page_store.open does not join. Close a page and send Leave when demand ends. Multiple consumers must share demand before the final leave.
ResumeKeep the connection's join set consistent with demand. Use frames_for_resume, send joins before queued document commands, and reject stale socket-generation callbacks.
Command deliveryPreserve command order, including create-before-join. Retain unsent commands on a send failure. Browser send completion does not prove store acceptance or persistence.
Local outputsDispatch typed outputs only from a successful local update. A Receive has no output list. Do not synthesize local integration output from remote state.
LifecycleDefine loading, usable state, failure, and deletion policy. Keep errors visible. Do not replace damaged state with an empty model.

Loading, Ready, Failed, and Deleted describe model state. They do not create a restart policy, durable queue, replay engine, or recovery process. Each host must state whether failure requires retry, rejoin, model recreation, or user action. The workspace keeps network connectivity separate from these component states. Selecting another component does not close the old model. Its fixed workspace root is a special host-owned document.

Use packages/strata_component/test/host_contract_test.gleam as a small page/join fixture. Run the component suite on Erlang and JavaScript. The workspace reducer suite exercises duplicate claims, unknown events, retired-event races, root retirement and leave, reconnect command order, and remote receive without local workflow dispatch.

Before you extract runtime code, run the same cases against a second real component host. The standalone Todo and Kanban apps provide useful single-document reconnect comparisons, but they do not host strata_component.Definition instances. They cannot validate component-index or binding-dispatch behavior. No shared runtime is extracted here. Preserve the typed public contracts, closed component sums, and application-owned adapters until a second host demonstrates duplicated mechanisms.

An OutputPort(output, payload) selects an optional payload from a component output:

port.output_port(
component_kind: "strata/intake",
key: "request_submitted",
version: 1,
select: fn(output) {
case output {
RequestSubmitted(submission) -> Some(submission)
}
},
)

An InputPort(message, input) converts a typed input into a component message:

port.input_port(
component_kind: "strata/board",
key: "create_card",
version: 1,
to_message: fn(input) { Ok(CreateCard(input)) },
)

Both constructors validate the component kind, key, and positive version.

A Binding(output, message, payload, input, config) connects one output port to one input port. It also defines a stable kind, a positive version, config codecs, and a mapping function.

port.binding(
kind: "strata/intake-to-board",
version: 1,
output: intake_component.request_submitted_output(),
input: board_component.create_card_input(),
config_encoder: encode_config,
config_decoder: decode_config,
map: intake_to_card,
)

Use port.encode_config and port.decode_config to persist binding config. Use binding_kind and binding_version to read binding metadata. The module also provides component-kind, key, and version accessors for each port.

Call port.route(binding, config, output) for each local output. The function returns:

  • Ok(None) when the output port does not select a payload or the mapping skips it;
  • Ok(Some(message)) when the route creates a target message;
  • MappingFailed when the mapping rejects the payload;
  • InputFailed when the target port rejects the mapped input.

Apply the returned message to the target component with strata_component.update. Queue the returned client commands. Route any new local outputs through the next bindings.

The package does not provide a stored binding graph or a type-erased binding collection. The workspace example keeps an exhaustive set of supported routes.

The workspace's direct dispatcher is session-local and best-effort. port.route produces a target message, and a successful local update produces commands and outputs. Neither result proves server acceptance. The host dispatches downstream outputs before it sends the source and target commands. Those commands do not form one atomic workflow.

Routes, outputs awaiting dispatch, and the outbox are in browser memory. Closing or reloading the tab, or a browser crash, loses them. Server-stored component state can survive, subject to checkpoint timing, without enough information to recover unfinished workflow actions.

Interruption pointCurrent behavior
After a source output, before target command submissionBrowser termination loses the pending local work. A later snapshot does not emit the source output again.
After target submission, before server acceptance is knownWebSocket.send success only means the browser accepted the send. Disconnect or termination leaves the operation's outcome unknown; it may still complete.
After target acceptance, before the browser learns the resultThe target may have changed even though the browser has no confirmation. Repeating the source action can create a new effect.
After acceptance, before a durable workflow completion recordNo such record exists. Another tab or runtime cannot identify and resume incomplete work. Acceptance in memory can also be lost before the server checkpoint.

A surviving tab retains unsent outbox frames during a disconnect and sends joins before that queue on reconnect. It drops frames after browser send success, not after server acknowledgement. This cannot recover every lost operation. The workspace uses legacy commands and does not request correlated operation receipts.

The opt-in operation API from #26 distinguishes rejection, acceptance in memory, and unknown outcomes. Its records are session-local. A request ID correlates replies; the server does not retain an ID-based deduplication cache. Do not treat send success, disconnect, or an acceptance receipt as durable workflow completion. Reuse the exact request and payload only under the documented operation-specific retry rules; replaying a component message can generate new IDs and effects.

Use the lightweight dispatcher where partial execution and manual reconciliation are acceptable. Work that must survive browser loss needs a durable record of source identity, target identity, intended action, idempotency rule, and completion state, plus an owner that resumes retries. Strata has no built-in intent store or recovery runtime. Those choices and browser-termination recovery tests remain deferred in #27. Persisting binding configuration alone would not provide this guarantee.

examples/intake defines a headless single-document component. Submit writes a request, returns SendDelta, and emits RequestSubmitted. Its request_submitted_output exposes that payload to a binding.

examples/profile defines a headless fixed-record component. save_profile_input converts a whole profile into SaveProfile. profile_saved_output exposes successful local saves. This example has no host or workflow bindings.

examples/workspace hosts Intake, Board, Checklist, and Composite components in one browser app. It routes document events with an ownership map. It sends all component commands through one connection.

The workspace also defines four direct typed bindings. One binding maps an Intake submission to a Board card. Other bindings connect Board and Checklist outputs. These routes are session-local and best-effort. The app does not persist or replicate them, and it cannot resume workflow work after browser loss. See the delivery boundary.

Use Composing Documents when you only need typed document references and PageStore. Add strata_component when you also need a reusable lifecycle, typed local messages, outputs, ports, and bindings.