watershed Collaborative data structures for Gleam

← watershed · Component model / Components and catalogs

Components and catalogs

Optional · user-constructable apps

Let users, not the author, decide what is on the page: package each part of the app behind a versioned descriptor so it can be rebuilt from a saved document, and keep unlike parts in one catalog.

Traces Descriptor · Catalog · register · find · start

Why a components layer exists

Picture a board that two people build together while they watch it: one of them drops a notes panel on the left, puts a vote counter beside it, and connects the counter to the panel. Nobody wrote that arrangement in code. The people using the application made it, and it must look the same to everyone who opens the board tomorrow.

The board is our example interface. Its shared state belongs to a document; a workspace within that document stores which components exist, their presentation order, and their connections.

A component is one part of an application: a notes panel, a counter, a task list. It keeps its own state in its own channel of the shared document, draws its own view, and knows nothing about what else is on screen. The same kind can appear many times with different settings — one counter that steps by 5, another that steps by 1 — and every copy of the board converges the same way the rest of watershed does.

A part like that cannot be a function the author calls. The arrangement is data the users edit, and it outlives the session that made it. So the application must build each part from stored JSON, months later, from code that has changed since.

A descriptor is that recipe: everything needed to turn stored JSON into one running part, and back. Package a component once, and the rest of watershed can:

  • Rebuild it from a saved workspace, with the settings the user chose.
  • Check stored component config before it runs anything, and name the exact part that is wrong.
  • Run last year's version beside this year's, because each descriptor states its own format version.
  • Let a user connect two parts that were written apart, because their ports are listed data.

One word runs through this sheet and the two after it: the host. The host is the program that embeds watershed and puts the components on screen, meaning your application shell, not a server. It owns the set of component kinds it can build, decides which versions it accepts, mounts the views, and supplies whatever context every component needs to start. Watershed defines the contract. The host is the program around it.

That makes two jobs, and this sheet does them in that order. First you use components: the host holds a catalog of kinds and builds instances from stored config. Then you write one: a single component packaged so a host it never meets can build it. Stop after part one if you are assembling an application out of components somebody else wrote.

This board spans three sheets. Here it gets its parts: the next sheet adds a task list and an activity log and wires them together, and the one after saves the arrangement in a workspace so tomorrow's tab can rebuild it.

If your application ships one screen that you lay out yourself, you do not need any of this: declare your channels in the schema and stop there. All three sheets are for the case where the user, not the author, decides what is on the page.

Part one · using components

One catalog, exact versions

A host holds one descriptor for each kind it can build. Treat a descriptor as opaque for now: it is one component kind packaged so the host can build it without knowing what it is. Part two writes one.

A catalog keys each descriptor by its stable kind and integer format version. Several versions of one kind can coexist. Registering the same pair twice returns DuplicateRegistration.

src/watershed/component.gleam
/// A set of registered descriptors, keyed by kind and version.
///
/// Every descriptor in one catalog shares the same `context` and `running`
/// types, but each descriptor can hide a different config type.
pub opaque type Catalog(context, running) {
  Catalog(entries: Dict(#(String, Int), Descriptor(context, running)))
}

Registration is where the notes panel and the counter land in the same structure for the first time.

src/watershed/component.gleam
/// Add a descriptor to a catalog.
///
/// Returns `Error(DuplicateRegistration(..))` when the catalog already
/// holds a descriptor with the same kind and version.
pub fn register(
  catalog: Catalog(context, running),
  descriptor: Descriptor(context, running),
) -> Result(Catalog(context, running), RegistrationError) {
  let key = #(descriptor.kind, descriptor.version)
  case dict.has_key(catalog.entries, key) {
    True -> Error(DuplicateRegistration(descriptor.kind, descriptor.version))
    False -> Ok(Catalog(entries: dict.insert(catalog.entries, key, descriptor)))
  }
}
test/watershed/component_test.gleam
pub fn catalog_holds_different_config_types_test() -> Nil {
  let assert Ok(with_notes) =
    component.register(component.new_catalog(), notes_descriptor())
  let assert Ok(catalog) = component.register(with_notes, counter_descriptor())

  component.find(catalog, "watershed/notes", 1)
  |> result.map(component.kind)
  |> expect.to_equal(Ok("watershed/notes"))
}
notes and counter configs differ; the host contract matches

Lookup requires the requested version, and that exactness buys two useful failure states. NotRegistered(kind) means the host knows no version of the kind at all. UnsupportedVersion(kind, requested, available) means it can name the versions it does know. A host can report the missing kind or the unsupported version without guessing which one went wrong.

src/watershed/component.gleam
/// Find a descriptor in a catalog by kind and version.
///
/// Returns `Error(NotRegistered(kind))` when the catalog holds no version of
/// the kind. Returns `Error(UnsupportedVersion(..))` when the catalog holds
/// the kind at other versions. The two errors let a caller show the correct
/// cause to a user: this host does not know the kind, or this host does not
/// register the requested version.
pub fn find(
  catalog: Catalog(context, running),
  kind: String,
  version: Int,
) -> Result(Descriptor(context, running), LookupError) {
  case dict.get(catalog.entries, #(kind, version)) {
    Ok(descriptor) -> Ok(descriptor)
    Error(Nil) ->
      case registered_versions(catalog, kind) {
        [] -> Error(NotRegistered(kind))
        available -> Error(UnsupportedVersion(kind, version, available))
      }
  }
}

The catalog requires an exact version. Register an older descriptor when a host still supports it. The host owns migration and any fallback or code-loading policy.

Validation and startup stay separate

Checking that stored config is readable and actually running a component are different jobs. A manifest reader wants the first without the second.

validate_config runs the descriptor's decoder and stops there. A builder or manifest reader can check stored JSON without opening subscriptions or creating a running component.

src/watershed/component.gleam
/// Check config JSON against a descriptor's decoder, without starting the
/// component.
pub fn validate_config(
  descriptor: Descriptor(context, running),
  config: Json,
) -> Result(Nil, ComponentError) {
  descriptor.validate_config(config)
}

start decodes the same JSON, then calls the typed start closure. Readiness waits for that closure's callback, so channel bootstrap and subscriptions can finish first.

src/watershed/component.gleam
/// Decode config JSON and start one running instance of a descriptor's
/// component.
///
/// Returns `Error(InvalidConfig(..))` when the config JSON does not match
/// the descriptor's decoder, or `Error(StartFailed(..))` when the decoded
/// config is valid but the component's start function fails.
pub fn start(
  descriptor: Descriptor(context, running),
  context: context,
  config: Json,
  done: fn(Result(running, ComponentError)) -> Nil,
) -> Nil {
  descriptor.start(context, config, done)
}
src/watershed/component.gleam
/// Build an executable descriptor for one component kind.
///
/// `start_component` calls `done` after the component has bootstrapped its
/// channels and installed its required subscriptions. The runtime does not
/// mark the instance ready before that callback.
///
/// Call `done` exactly once. The first completion consumes the invocation.
/// An `Ok` first completion transfers the running value to the host.
/// The host stops a first late success if its generation is obsolete.
/// A duplicate completion transfers no ownership:
/// the starter must release any extra resources without stopping the accepted
/// value. The JavaScript host reports the violation and does not use the value.
/// An incomplete start stays pending until removal or shutdown. It has no
/// timeout.
///
/// A starter that throws before it transfers resources through `done` must
/// release those resources itself. The host can release only resources it
/// owns. Rejecting returned state does not undo earlier channel mutations.
///
/// `inputs` must contain the handlers for the input descriptors in `ports`.
/// Delivery rejects a missing handler or metadata that does not match the
/// handler's typed port.
pub fn executable_descriptor(
  kind kind: String,
  version version: Int,
  config_decoder config_decoder: Decoder(config),
  start start_component: fn(context, config, fn(Result(running, String)) -> Nil) ->
    Nil,
  inputs inputs: List(InputHandler(running)),
  stop stop_component: fn(running) -> Result(Nil, String),
  ports ports: List(port.Descriptor),
) -> Descriptor(context, running) {
  let decode_config = fn(encoded: Json) {
    case json.parse(json.to_string(encoded), config_decoder) {
      Ok(config) -> Ok(config)
      Error(reason) -> Error(InvalidConfig(kind, version, reason))
    }
  }

  Descriptor(
    kind: kind,
    version: version,
    ports: ports,
    validate_config: fn(encoded) {
      decode_config(encoded)
      |> result.map(fn(_) { Nil })
    },
    start: fn(context, encoded, done) {
      case decode_config(encoded) {
        Error(reason) -> done(Error(reason))
        Ok(config) ->
          start_component(context, config, fn(started) {
            done(
              started
              |> result.map_error(fn(reason) {
                StartFailed(kind, version, reason)
              }),
            )
          })
      }
    },
    inputs: inputs,
    stop: fn(running) {
      stop_component(running)
      |> result.map_error(fn(reason) { StopFailed(kind, version, reason) })
    },
  )
}

Because the two stay separate, the error tells you which side failed:

  • InvalidConfig reports the kind, version, and JSON decode error. The stored config is wrong.
  • StartFailed reports the same identity plus the reason component code returned. The config was fine and the component could not start.

Input decoding, input handling, output validation, and cleanup keep their own errors too. The runtime never has to flatten a lifecycle failure into “component failed.”

Part two · writing a component

A descriptor is the catalog boundary

From here on you are writing a component, not placing one. Everything above treated the descriptor as opaque. This is what is inside it, and why it has to hide what it hides.

Two kinds run through this sheet: a notes panel and a counter. A host that holds both descriptors can build either one from stored JSON, without knowing what either one is.

Their configuration is where the uniformity breaks. Notes needs a placeholder string. The counter needs an integer step. Gleam requires one type for all values in a catalog, and a broad config union would expose each component's private choices to the host.

Descriptor(context, running) keeps the config type out of its public type. The descriptor holds the decoder, callback-completed start closure, typed input handlers, and cleanup closure that know that type. The catalog sees only the two types every component in one host must share: what the host passes in, and what it gets back. It also carries erased port metadata, which the next sheet picks up.

src/watershed/component.gleam
/// One component kind's start function, port list, and config decoder.
///
/// The `config` type parameter stays hidden: two descriptors with
/// different config types can share one `Catalog(context, running)`, because
/// `config` never appears in this type's public signature.
pub opaque type Descriptor(context, running) {
  Descriptor(
    kind: String,
    version: Int,
    ports: List(port.Descriptor),
    validate_config: fn(Json) -> Result(Nil, ComponentError),
    start: fn(context, Json, fn(Result(running, ComponentError)) -> Nil) -> Nil,
    inputs: List(InputHandler(running)),
    stop: fn(running) -> Result(Nil, ComponentError),
  )
}

Each component keeps its config type

You still write ordinary typed Gleam inside a component. The notes definition decodes JSON into NotesConfig, then starts with that exact type. component.descriptor closes over both functions and removes the config type from the host-facing API.

test/watershed/component_test.gleam
fn notes_decoder() -> decode.Decoder(NotesConfig) {
  use placeholder <- decode.field("placeholder", decode.string)
  decode.success(NotesConfig(placeholder: placeholder))
}

fn notes_descriptor() -> component.Descriptor(String, String) {
  component.descriptor(
    kind: "watershed/notes",
    version: 1,
    config_decoder: notes_decoder(),
    start: fn(context, config) { Ok(context <> ":" <> config.placeholder) },
    ports: [
      port.output_descriptor(port.output("selected", "subject@1", json.string)),
    ],
  )
}

The two String parameters in the return type are the host contract in this test. A string context goes in and a string running value comes back. The counter descriptor hides a different config type behind the same two parameters.

The workspace preserves the definitions

Use this module to define descriptors, build catalogs, validate config, look up versions, start and stop instances, and execute typed handlers. The workspace layer persists instances and assigns their child maps. The JavaScript component runtime reconciles those definitions, owns their lifecycle, and executes port deliveries. The host still mounts views and owns migration policy.

The contract

A descriptor hides one component's config type behind its decoder and start and cleanup closures. A catalog keeps those unlike definitions together under one host contract, looks them up by exact kind and version, and exposes their erased port metadata and typed handlers. Next: how those ports become a checked graph and an origin-aware dispatch trace.