Save the arrangement inside the document
The last two sheets built a board out of parts: a notes panel, a counter, a task list, an activity log, and the connections a user drew between them. The component data belongs to the shared document, but the host also needs to save the arrangement so it can rebuild the board when you reopen the tab.
A workspace stores that arrangement within the document. It holds three things, whether your interface is a board or something else:
- Which parts exist. One entry for each component instance: its kind, its format version, its config, and a handle to its own data.
- Where they sit. The presentation order the users arranged.
- How they are wired. The port connections from the last sheet.
All three are shared, collaborative state, the same as the data inside the components. Two people arranging one board see each other do it. That is the whole point, and it is also where the difficulty starts.
A saved workspace outlives the code that made it. Open one tomorrow and this build may not carry the counter any more. Someone may have deleted a panel that another connection still names. Two people can each add a safe connection and get a cycle when their edits merge. A workspace cannot answer those problems by deleting what it fails to understand: one client's ignorance would become everybody's data loss.
So this sheet does two jobs. It stores the workspace, and it derives the components and connections this client can safely run — while keeping the rest exactly as written.
All of it is host work. The last two sheets each split in half, one part for using components and one for writing them. This sheet has no second half: a component never sees the workspace that holds it.
One root slot opens the whole workspace
Those three lists become three channels, and one slot on the document
root opens all of them. A composed app still has one typed document
root. Give the workspace one ChildField there, and it owns
a map of component instances, a sequence for presentation order, and a
sequence of port connections.
/// The workspace manifest.
pub fn manifest_field() -> schema.ChannelField(
WorkspaceSchema,
schema.MapChannel,
) {
schema.channel_field("manifest")
}
/// The ordered component instance IDs.
pub fn layout_field() -> schema.ChannelField(
WorkspaceSchema,
schema.SequenceChannel,
) {
schema.channel_field("layout")
}
/// The stored component port connections.
pub fn connections_field() -> schema.ChannelField(
WorkspaceSchema,
schema.SequenceChannel,
) {
schema.channel_field("connections")
} The JavaScript and BEAM adapters create those channels while they are detached, wire their handles into the workspace map, then attach the complete subtree through the root. A peer never needs three magic keys at the application root, and component data never shares the workspace's control namespace.
The manifest points at data; it doesn't swallow it
Each manifest value names the stable instance and component version, carries its config as JSON, and embeds a handle to that component's own child map. The config can vary by component kind. The child map remains a separate collaborative structure with one owner.
src/watershed/workspace.gleam/// One component instance in the manifest.
pub type ManifestEntry {
ManifestEntry(
instance_id: String,
kind: String,
version: Int,
config: Json,
child_handle: Json,
)
} Handle discovery walks nested JSON. Writing this record therefore attaches the child map before the manifest operation reaches another client. A late joiner reads the same entry and resolves the same map.
Stored data and usable data are different lists
Those three reopening problems — the unknown kind, the repeated layout entry, the merged cycle — all arrive as stored data this client cannot use. None of them makes the data wrong. They make it unusable here, today, by this build.
workspace.snapshot keeps the raw values, decodes what it can,
removes duplicate or dangling layout references from the effective view,
and asks port_graph.effective for the deterministic acyclic
graph. Every rejected value comes back as a diagnostic. Nothing gets
rewritten.
/// Derive one effective workspace view without changing stored values.
pub fn snapshot(
manifest raw_manifest: List(#(String, Json)),
layout raw_layout: List(Json),
connections raw_connections: List(Json),
catalog catalog: component.Catalog(context, running),
) -> Snapshot {
let stored_manifest =
list.map(raw_manifest, fn(item) {
StoredManifestEntry(item.0, item.1, decode_manifest(item.1))
})
let #(entries, manifest_diagnostics) = valid_manifest(stored_manifest)
let #(layout, layout_diagnostics) = effective_layout(raw_layout, entries)
let #(stored_connections, connection_diagnostics) =
valid_connections(raw_connections)
let ports_for = fn(instance_id) { ports_for(entries, catalog, instance_id) }
let graph = port_graph.effective(stored_connections, ports_for)
let graph_diagnostics =
port_graph.errors(graph)
|> list.map(InvalidGraph)
Snapshot(
manifest: stored_manifest,
entries: entries,
raw_layout: raw_layout,
layout: layout,
raw_connections: raw_connections,
stored_connections: stored_connections,
graph: graph,
diagnostics: list.flatten([
manifest_diagnostics,
layout_diagnostics,
connection_diagnostics,
graph_diagnostics,
]),
)
} Prepared stops before Ready
Persistence can answer three questions without pretending it ran a
component: does the catalog know this exact kind and version, does the
config decode, and does the child map resolve? A successful answer is Prepared. An unsupported version is Unavailable. Bad stored data or config is Failed. A handle still in flight stays Loading.
/// Local preparation state before component runtime startup.
pub type PreparationState(subtree) {
/// The entry is valid, but its child map is not available yet.
Loading(entry: ManifestEntry, reason: String)
/// The descriptor, config, and child map are available.
Prepared(entry: ManifestEntry, subtree: subtree)
/// The local catalog does not support the stored kind and version.
Unavailable(entry: ManifestEntry, reason: component.LookupError)
/// The stored entry or component config is invalid.
Failed(instance_id: String, reason: PreparationError)
} /// Prepare every stored manifest entry against a local catalog.
pub fn prepare(
snapshot: Snapshot,
catalog: component.Catalog(context, running),
resolve_child: fn(Json) -> Result(subtree, String),
) -> List(PreparationState(subtree)) {
list.map(snapshot.manifest, fn(stored) {
case stored.decoded {
Error(reason) -> Failed(stored.key, InvalidStoredManifest(reason))
Ok(entry) if entry.instance_id != stored.key ->
Failed(stored.key, StoredIdMismatch(entry.instance_id))
Ok(entry) ->
case component.find(catalog, entry.kind, entry.version) {
Error(reason) -> Unavailable(entry, reason)
Ok(descriptor) ->
case component.validate_config(descriptor, entry.config) {
Error(reason) ->
Failed(entry.instance_id, InvalidComponentConfig(reason))
Ok(Nil) ->
case resolve_child(entry.child_handle) {
Ok(child) -> Prepared(entry, child)
Error(reason) -> Loading(entry, reason)
}
}
}
}
})
} Prepared is not running. The JavaScript component
runtime calls the descriptor's start closure, waits while the instance
is Starting, and reaches Ready only after
bootstrap and subscriptions finish. Keeping the names separate makes a
startup failure impossible to hide behind a resolved handle.
Deletion removes reachability, not history
One delete touches three channels, so order matters. The adapter removes every layout reference, removes every connection that reaches the instance, and deletes the manifest key last. Repeating the operation finishes a partial attempt instead of failing on already-missing data.
src/watershed/workspace_beam.gleam@target(erlang)
/// Remove one instance from workspace reachability.
///
/// The attached child map remains readable through a handle retained before
/// this operation.
pub fn delete_instance(
store: Workspace(root),
catalog: component.Catalog(context, running),
instance_id: String,
) -> Result(Nil, WorkspaceError) {
let snapshot = read(store, catalog)
use _ <- result.try(delete_indices(
store.layout,
workspace.layout_removal_indices(snapshot, instance_id),
))
use _ <- result.try(delete_indices(
store.connections,
workspace.instance_connection_indices(snapshot, instance_id),
))
watershed_beam.delete(store.manifest, instance_id)
Ok(Nil)
} Watershed doesn't have channel garbage collection. The instance map stays attached and disappears only from workspace reachability. A caller that retained its handle can still read it—the test pins that behavior instead of calling the orphan “deleted.”
test/watershed/workspace_beam_test.gleam@target(erlang)
pub fn workspace_lifecycle_converges_and_preserves_deleted_data_test() -> Nil {
let sluice = start("workspace-beam-lifecycle")
let document_a = connect(sluice, "user-a")
sluice.settle(sluice)
let assert Ok(store_a) =
workspace_beam.ensure(
document_a,
watershed_beam.root_typed(document_a),
workspace_field(),
)
sluice.settle(sluice)
let assert Ok(tasks_child) =
workspace_beam.add_instance(
store_a,
catalog(),
"tasks-1",
"tasks",
1,
json.null(),
)
let assert Ok(_) =
workspace_beam.add_instance(
store_a,
catalog(),
"notes-1",
"notes",
1,
json.null(),
)
watershed_beam.set(tasks_child, "kept", json.string("yes"))
let edge =
port_graph.connection(
"tasks-notes",
port_graph.PortRef("tasks-1", "selected"),
port_graph.PortRef("notes-1", "focus"),
)
workspace_beam.add_connection(store_a, catalog(), edge)
|> expect.to_equal(Ok(Nil))
sluice.settle(sluice)
let document_b = connect(sluice, "user-b")
sluice.settle(sluice)
let assert Ok(store_b) =
workspace_beam.resolve(
document_b,
watershed_beam.root_typed(document_b),
workspace_field(),
)
workspace.layout(workspace_beam.read(store_b, catalog()))
|> expect.to_equal(["tasks-1", "notes-1"])
workspace_beam.move_instance(store_a, catalog(), "notes-1", 0)
|> expect.to_equal(Ok(Nil))
sluice.settle(sluice)
workspace.layout(workspace_beam.read(store_b, catalog()))
|> expect.to_equal(["notes-1", "tasks-1"])
let child_handle = watershed_beam.handle_of(tasks_child)
workspace_beam.delete_instance(store_a, catalog(), "tasks-1")
|> expect.to_equal(Ok(Nil))
workspace_beam.delete_instance(store_a, catalog(), "tasks-1")
|> expect.to_equal(Ok(Nil))
sluice.settle(sluice)
let snapshot_b = workspace_beam.read(store_b, catalog())
workspace.layout(snapshot_b) |> expect.to_equal(["notes-1"])
port_graph.connections(workspace.graph(snapshot_b))
|> expect.to_equal([])
let assert Ok(preserved) = watershed_beam.resolve(document_b, child_handle)
watershed_beam.get(preserved, "kept")
|> expect.to_equal(Ok(json.string("yes")))
} The runtime watches the workspace, not the view tree
Persistence still stops at Prepared. The JavaScript runtime
subscribes to the manifest, layout, and graph; starts, replaces, and stops
only the affected instances; and routes output events through one graph
snapshot per trace. Layout and connection edits don't restart healthy
components. The runtime exposes running values and lifecycle states, but
it doesn't mount a view—the browser host decides what those values look
like.
One child map owns the manifest, layout, and wiring. Raw collaborative data stays intact; the local catalog derives the view it can safely use. From here, the survey procedure returns to the document runtime watershed ships end to end today.