watershed Collaborative data structures for Gleam

Two people.
One living brief.

Change the owner while someone else moves the target date. Add a milestone without taking their input away. These two runnable editors put the same nested form on native SharedTree—one in Fluid and React, one in watershed and Lustre. Here's where their APIs meet, and where they don't.

More than a shared array.

A title, an owner, a schedule, an optional review. Milestones sit inside the schedule's only array. The interesting part isn't the form—it's keeping an edit attached to the right object while another person changes what's around it. Start with the item-counter comparison if you'd rather trace one array first.

Both columns are locally authored example code in this repository. The Fluid app is not an official Fluid example or an upstream snapshot. It uses Fluid 3.1.0 and the experimental Signaler helper for field selection. Both apps run independently, against separate local services; neither opens the other's documents.

A brief is an object, not a bag of fields.

Owner and schedule are nested objects; review is optional, and schedule.milestones is the only array. Fluid declares live node classes with SchemaFactory. watershed declares typed properties and a Root(ProjectBrief); its constructors decode immutable Gleam values. The excerpts show the root, optional review, and nested schedule—the linked modules contain the remaining property declarations. This is native SharedTree, not a SharedSequence pretending to enforce the same nested schema.

Fluid · TypeScript

examples/shared_tree_project_brief_lustre/fluid/src/schema.ts
const factory = new SchemaFactory("shared_tree_project_brief");

export class Milestone extends factory.object("Milestone", {
  id: factory.string,
  title: factory.string,
  completed: factory.boolean,
}) {}

export class Owner extends factory.object("Owner", {
  name: factory.string,
  email: factory.string,
}) {}

export class Schedule extends factory.object("Schedule", {
  start: factory.string,
  target: factory.string,
  milestones: factory.array("Milestones", Milestone),
}) {}

export class Review extends factory.object("Review", {
  reviewer: factory.string,
  approved: factory.boolean,
}) {}

export class ProjectBrief extends factory.object("ProjectBrief", {
  title: factory.string,
  owner: Owner,
  schedule: Schedule,
  review: factory.optional(Review),
}) {}

export const treeConfiguration = new TreeViewConfiguration({
  schema: ProjectBrief,
});

watershed · Gleam

examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre/schema.gleam
pub fn configuration() -> builder.Root(brief.ProjectBrief) {
  builder.object4(
    factory(),
    "ProjectBrief",
    brief.ProjectBrief,
    brief_title(),
    owner(),
    schedule(),
    review(),
  )
  |> builder.root
}

pub fn review() -> builder.Property(brief.ProjectBrief, Option(brief.Review)) {
  builder.optional(
    key: "review",
    node: review_node(),
    get: fn(value: brief.ProjectBrief) { value.review },
  )
}

pub fn milestones() -> builder.Property(brief.Schedule, List(brief.Milestone)) {
  builder.required(
    key: "milestones",
    node: builder.array(factory(), "Milestones", milestone_node()),
    get: fn(value: brief.Schedule) { value.milestones },
  )
}

fn schedule_node() -> builder.Node(brief.Schedule) {
  // Schedule has three properties, but object3 requires an infallible constructor.
  // Use object to decode the properties and return an error if validation fails.
  builder.object(
    factory: factory(),
    name: "Schedule",
    properties: [
      builder.property(schedule_start()),
      builder.property(schedule_target()),
      builder.property(milestones()),
    ],
    construct: fn(reader) {
      use start <- result.try(builder.read_property(
        reader:,
        property: schedule_start(),
      ))
      use target <- result.try(builder.read_property(
        reader:,
        property: schedule_target(),
      ))
      use milestones <- result.try(builder.read_property(
        reader:,
        property: milestones(),
      ))
      // The form uses milestone IDs to identify rows and presence selections.
      // Reject duplicate IDs so two rows cannot share the same identity.
      use _ <- result.try(
        brief.validate_unique_ids(milestones)
        |> result.map_error(fn(detail) {
          types.InvalidEdit(["schedule", "milestones"], detail)
        }),
      )
      Ok(brief.Schedule(start, target, milestones))
    },
  )
}

Open once. Close what you opened.

Fluid creates or loads a container, initializes only a new tree, and attaches before publishing its URL. watershed creates with the schema and initial values, then connects and opens with the same declaration. The Gleam update branches open the tree and register change notifications; neither app reinitializes a loaded brief or silently upgrades its schema. The complete apps dispose subscriptions, views, presence, and connections on Close, including resources that arrive late. An uncertain create or attach is an error, never an automatic retry.

Fluid · TypeScript

examples/shared_tree_project_brief_lustre/fluid/src/fluid.ts
export const containerSchema = {
  initialObjects: { brief: SharedTree, selection: Signaler },
} satisfies ContainerSchema;

/** Called once by the entry point, never by a React mount effect. */
export async function openSession(url: URL, signal: AbortSignal): Promise<BriefSession> {
  const name = Array.from(url.searchParams.get("name") || "Alex").slice(0, 80).join("");
  const sessionId = crypto.randomUUID();
  const client = new AzureClient({
    connection: {
      type: "local", endpoint: "http://localhost:7070",
      tokenProvider: new InsecureTokenProvider("fooBar", { id: sessionId, name }),
    },
  });
  let documentId = url.searchParams.get("document") || "";
  const creating = documentId === "";
  const { container } = creating
    ? await client.createContainer(containerSchema, "2.0.0")
    : await client.getContainer(documentId, containerSchema, "2.0.0");
  let view: TreeView<typeof ProjectBrief> | undefined;
  const abort = () => { if (!container.disposed) container.dispose(); };
  signal.addEventListener("abort", abort, { once: true });
  try {
    signal.throwIfAborted();
    view = container.initialObjects.brief.viewWith(treeConfiguration);
    if (creating) {
      const data = initial(crypto.randomUUID(), crypto.randomUUID());
      validateUniqueIds(data.schedule.milestones);
      view.initialize(data);
      validateBrief(view.root);
      // An uncertain attach is never retried. The URL changes only on success.
      documentId = await container.attach();
      signal.throwIfAborted();
      url.searchParams.set("document", documentId);
      history.replaceState(null, "", url);
    }
    signal.throwIfAborted();
    return new BriefSession(container, view, container.initialObjects.selection, documentId,
      { name, color: "blue", token: 0, selected: null }, sessionId);
  } catch (error) {
    view?.dispose();
    abort();
    throw error;
  } finally { signal.removeEventListener("abort", abort); }
}
examples/shared_tree_project_brief_lustre/fluid/src/fluid.ts
close(): void {
  if (this.closed) return;
  this.closed = true;
  this.clearSelection();
  this.presence?.stop();
  for (const stop of this.cleanup.splice(0)) stop();
  this.policy = { ...this.policy, peers: [] };
  this.view.dispose();
  if (!this.container.disposed) this.container.dispose();
  this.emit();
}

watershed · Gleam

examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre.gleam
fn init(_: Nil) -> #(Model, Effect(Msg)) {
  proxy_floodgate_http("http://127.0.0.1:4000")
  let document_id = query_parameter("document", "")
  let model =
    Model(
      status: case document_id {
        "" -> "creating"
        _ -> "connecting"
      },
      connection: "connecting",
      session: None,
      document_id:,
      document: None,
      tree: None,
      subscription: None,
      rendered: None,
      review_generation: 0,
      inputs: dict.new(),
      metadata: s.Metadata(query_parameter("name", "Editor"), s.Blue, 0, None),
      policy: s.new(),
      fresh: dict.new(),
      presence: None,
      generation: 0,
      change_count: 0,
      pending: 0,
      errors: [],
    )
  #(model, case document_id {
    // Initialize only a new document. A shared URL connects to existing content.
    // These helpers use development credentials, not production authentication.
    "" ->
      tree.create_dev(
        query_parameter("base", current_origin()),
        query_parameter("tenant", "dev-tenant"),
        query_parameter("secret", "levee-dev-secret-change-in-production"),
        schema.configuration(),
        brief.initial(id.uuid_v4(), id.uuid_v4()),
        Created,
      )
    _ -> connect(document_id)
  })
}

fn connect(document_id: String) -> Effect(Msg) {
  watershed_lustre.connect_dev(
    url: "ws://127.0.0.1:4000/socket/websocket?vsn=2.0.0",
    tenant: query_parameter("tenant", "dev-tenant"),
    secret: query_parameter("secret", "levee-dev-secret-change-in-production"),
    document: document_id,
    user_id: "project-brief-editor",
    got_document: GotDocument,
    connected: Connected,
  )
}
examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre.gleam
Connected(Error(detail)) -> fatal(model, detail)
Connected(Ok(_)) ->
  case model.document {
    Some(document) -> #(
      model,
      tree.open(document, schema.configuration(), Opened),
    )
    None -> fatal(model, "Connection completed without a document handle")
  }
Opened(Error(error)) -> fatal(model, tree_error.to_string(error))
// Subscribe before the first capture so later changes trigger fresh reads.
Opened(Ok(handle)) -> #(
  Model(..model, tree: Some(handle)),
  tree.subscribe_changes(handle, Subscribed, fn(_) { TreeChanged }),
)
Subscribed(Error(error)) -> fatal(model, tree_error.to_string(error))
Subscribed(Ok(subscription)) ->
  capture(Model(..model, subscription: Some(subscription)))
TreeChanged -> capture(Model(..model, change_count: model.change_count + 1))

A live node and a snapshot aren't the same thing.

Fluid keeps the live view.root and listens for treeChanged and rootChanged; its session notifies React to read those live nodes again. watershed subscribes to changes, captures an initial read after registration, and captures again on each notification. read_root returns an immutable ProjectBrief snapshot: yesterday's value won't change under your feet. capture pairs that value with live Ref handles for edits. Failed reads stay errors, not empty forms; duplicate milestone IDs invalidate the form rather than aliasing two rows.

Fluid · TypeScript

examples/shared_tree_project_brief_lustre/fluid/src/fluid.ts
const changed = () => {
  if (this.closed) return;
  this.treeChangeCount++;
  this.read();
  this.emit();
};
this.cleanup.push(Tree.on(this.root, "treeChanged", changed));
this.cleanup.push(view.events.on("rootChanged", () => {
  if (this.closed) return;
  this.read();
  this.emit();
}));

watershed · Gleam

examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre/edits.gleam
pub fn capture(
  handle: tree.Tree(brief.ProjectBrief),
) -> Result(RenderState, error.Error) {
  // Read a new snapshot after each notification. Earlier values do not change.
  // Keep references separately so edits modify existing nodes, not copied values.
  use value <- result.try(tree.read_root(handle))
  use root <- result.try(tree.required_root(handle))
  use owner <- result.try(tree.child(root, schema.owner()))
  use schedule <- result.try(tree.child(root, schema.schedule()))
  use milestones <- result.try(tree.child(schedule, schema.milestones()))
  use milestone_nodes <- result.try(capture_milestones(milestones, 0, []))
  use review <- result.try(tree.optional_child(root, schema.review()))
  Ok(RenderState(
    value:,
    root:,
    owner:,
    schedule:,
    milestones:,
    milestone_nodes:,
    review:,
  ))
}

Change the property. Keep the object.

Typing a name assigns owner.name on the existing owner node—it doesn't replace owner or rebuild the brief. Fluid's assign helper checks that the captured node still belongs to this brief before assigning its property. Gleam's captured Ref and schema property go straight to shared_tree.set. That leaves an email edit on the same owner, or a target-date edit on schedule, independent. Two writers to the same string still contend over a whole-string assignment; there is no character-level merge.

Fluid · TypeScript

examples/shared_tree_project_brief_lustre/fluid/src/edits.ts
export function assign<T extends EditableNode, K extends Property<T>>(
  brief: ProjectBrief, node: T, property: K, value: T[K],
): void {
  requireCurrent(brief, node);
  node[property] = value;
}

watershed · Gleam

examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre.gleam
// Assign one property on the captured owner node. Do not replace the owner.
// Each assignment replaces the whole string; it does not merge characters.
html.fieldset([], [
  html.legend([], [html.text("Owner")]),
  text_field(model, s.Fixed(s.OwnerName), "Name", fn(value) {
    shared_tree.set(state.owner, schema.owner_name(), value)
  }),
  text_field(model, s.Fixed(s.OwnerEmail), "Email", fn(value) {
    shared_tree.set(state.owner, schema.owner_email(), value)
  }),
]),

The row moves. Its identity doesn't.

Every inserted milestone gets a fresh UUID in its application id, which the apps never edit. Both render keyed rows and address field selection by that ID, not an array index. Edits capture the actual node; watershed removes through array_remove_node, while Fluid finds the captured node's current index immediately before removal. Remove an earlier row while someone types and the input, caret, and badge stay with their milestone. Removed-node actions fail rather than touching a neighbor; there are no reorder controls.

Fluid · TypeScript

examples/shared_tree_project_brief_lustre/fluid/src/edits.ts
export function appendMilestone(brief: ProjectBrief): void {
  brief.schedule.milestones.insertAtEnd({
    id: crypto.randomUUID(), title: "", completed: false,
  });
}

export function removeMilestone(brief: ProjectBrief, node: Milestone): void {
  requireCurrent(brief, node);
  const index = Array.from(brief.schedule.milestones).indexOf(node);
  if (index < 0) throw new Error("Milestone was removed");
  brief.schedule.milestones.removeAt(index);
}
examples/shared_tree_project_brief_lustre/fluid/src/App.tsx
function Form({ session }: { session: BriefSession }) {
  const root = session.root;
  const owner = root.owner;
  const schedule = root.schedule;
  const review = root.review;
  const fixed = (field: Extract<Field, { kind: "fixed" }>["field"]): Field => ({ kind: "fixed", field });
  const text = (label: string, field: Field, node: EditableNode, value: string,
    edit: (value: string) => void) =>
    <TextField {...{ label, field, node, value, edit, session }} />;
  const action = (name: string, label: string, edit: () => void) =>
    <button type="button" data-action={name} disabled={!session.editable}
      onClick={() => session.edit(edit)}>{label}</button>;
  return <div className="brief">
    <fieldset><legend>Brief</legend>
      {text("Title", fixed("title"), root, root.title, (value) => assign(root, root, "title", value))}
    </fieldset>
    <fieldset><legend>Owner</legend>
      {text("Name", fixed("owner.name"), owner, owner.name, (value) => assign(root, owner, "name", value))}
      {text("Email", fixed("owner.email"), owner, owner.email, (value) => assign(root, owner, "email", value))}
    </fieldset>
    <fieldset><legend>Schedule</legend>
      {text("Start", fixed("schedule.start"), schedule, schedule.start,
        (value) => assign(root, schedule, "start", value))}
      {text("Target", fixed("schedule.target"), schedule, schedule.target,
        (value) => assign(root, schedule, "target", value))}
      <div className="milestones">{Array.from(schedule.milestones).map((node) => {
        const title: Field = { kind: "milestone", id: node.id, field: "title" };
        const completed: Field = { kind: "milestone", id: node.id, field: "completed" };
        return <div key={node.id} data-milestone-id={node.id} className="milestone">
          {text("Milestone title", title, node, node.title, (value) => assign(root, node, "title", value))}
          <Checkbox session={session} field={completed} node={node} label="Completed"
            checked={node.completed} edit={(value) => assign(root, node, "completed", value)} />
          {action("remove-milestone", "Remove milestone", () => removeMilestone(root, node))}
        </div>;
      })}</div>
      {action("add-milestone", "Add milestone", () => appendMilestone(root))}
    </fieldset>
    <fieldset><legend>Review</legend>
      {review ? <>
        {text("Reviewer", fixed("review.reviewer"), review, review.reviewer,
          (value) => assign(root, review, "reviewer", value))}
        <Checkbox session={session} field={fixed("review.approved")} node={review} label="Approved"
          checked={review.approved} edit={(value) => assign(root, review, "approved", value)} />
        {action("remove-review", "Remove review", () => removeReview(root, review))}
      </> : action("add-review", "Add review", () => {
        if (root.review) throw new Error("Review was already added");
        root.review = new Review({ reviewer: "", approved: false });
      })}
    </fieldset>
  </div>;
}

watershed · Gleam

examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre/edits.gleam
pub fn append(
  schedule: tree.Ref(brief.Schedule),
  id: String,
) -> Result(Nil, error.Error) {
  use array <- result.try(tree.child(schedule, schema.milestones()))
  use current <- result.try(tree.read(array))
  tree.array_insert(array, list.length(current), [
    brief.Milestone(id, "", False),
  ])
}

pub fn remove(
  array: tree.Ref(List(brief.Milestone)),
  item: tree.Ref(brief.Milestone),
) -> Result(Nil, error.Error) {
  // Remove the captured node even if another removal changed its array index.
  // A detached reference returns an error instead of targeting another row.
  tree.array_remove_node(array, item)
}
examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre.gleam
// Key DOM rows by application ID and capture their actual tree nodes.
// An earlier row can disappear without moving this input or its edit target.
keyed.div(
  [attribute.class("milestones")],
  list.filter_map(state.milestone_nodes, fn(pair) {
    let #(id, node) = pair
    use item <- result.try(
      list.find(state.value.schedule.milestones, fn(item) {
        item.id == id
      }),
    )
    Ok(#(
      id,
      html.div([attribute.attribute("data-milestone-id", id)], [
        text_field(
          model,
          s.Milestone(id, s.MilestoneTitle),
          "Milestone title",
          fn(value) {
            shared_tree.set(node, schema.milestone_title(), value)
          },
        ),
        checkbox(
          model,
          s.Milestone(id, s.Completed),
          "Completed",
          item.completed,
          fn(value) {
            shared_tree.set(node, schema.milestone_completed(), value)
          },
        ),
        button(
          "remove-milestone",
          "Remove milestone",
          Action(fn() { edits.remove(state.milestones, node) }),
          !editable(model),
        ),
      ]),
    ))
  }),
),
button(
  "add-milestone",
  "Add milestone",
  Action(fn() { edits.append(state.schedule, id.uuid_v4()) }),
  !editable(model),
),

Who's here isn't part of the brief.

Fluid uses the experimental Signaler exported by @fluid-experimental/data-objects 3.1.0, alongside SharedTree. watershed explicitly configures presence.Ripple and updates the presence handle on focus. These announcements travel outside the durable tree operation stream: no tree edit, commit, or saved summary. Selection is transient and advisory, never a lock. Session-attributed names use a two-second heartbeat and a 6.5-second expiry window. Reconnect accepts fresh awareness; removed milestones and removed or replaced review objects invalidate stale selections so old badges can't attach to new objects.

Fluid · TypeScript

examples/shared_tree_project_brief_lustre/fluid/src/selection.ts
/** Experimental Fluid Signaler; no document/tree writes belong in this adapter. */
export function startPresence(
  signaler: ISignaler,
  container: IFluidContainer,
  initial: SelectionMetadata,
  onPeers: (peers: Peer[]) => void,
  onError: (error: unknown) => void,
): PresenceSession {
  let metadata = decode(initial);
  let peers = new Map<string, SeenPeer>();
  let stopped = false;
  const connected = () => container.connectionState === ConnectionState.Connected;
  const publish = () => {
    if (!stopped) onPeers(Array.from(peers, ([sessionId, peer]) => ({ sessionId, metadata: peer.metadata })));
  };
  const send = () => {
    if (stopped || !connected()) return;
    try { signaler.submitSignal(signalName, encode(metadata)); }
    catch (error) { onError(error); }
  };
  const listener: SignalListener<unknown> = (clientId, local, payload) => {
    if (stopped || !connected() || local) return;
    try {
      if (typeof clientId !== "string" || clientId.length === 0) throw new Error("Missing signal sender");
      peers = receive(peers, clientId, decode(payload), performance.now());
    } catch (error) { onError(error); return; }
    publish();
  };
  const disconnected = () => {
    if (stopped) return;
    peers = new Map();
    publish();
  };
  signaler.onSignal(signalName, listener);
  container.on("connected", send);
  container.on("disconnected", disconnected);
  const timer = setInterval(() => {
    if (stopped || !connected()) return;
    peers = expire(peers, performance.now());
    publish();
    send();
  }, 2_000);
  send();
  return {
    update(next) {
      if (stopped) return;
      try {
        const value = decode(next);
        if (value.token < metadata.token) throw new Error("Selection token regressed");
        if (value.token === metadata.token &&
            JSON.stringify(value.selected) !== JSON.stringify(metadata.selected)) {
          throw new Error("Selection changed without a new token");
        }
        metadata = value;
      } catch (error) { onError(error); return; }
      send();
    },
    stop() {
      if (stopped) return;
      stopped = true;
      clearInterval(timer);
      signaler.offSignal(signalName, listener);
      container.off("connected", send);
      container.off("disconnected", disconnected);
      peers.clear();
    },
  };
}

watershed · Gleam

examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre/selection.gleam
/// Use the existing driver for the heartbeat, expiry, and connection state.
pub fn config() -> presence.Config(Metadata) {
  presence.config(encode, decoder()) |> presence.with_mode(presence.Ripple)
}
examples/shared_tree_project_brief_lustre/src/shared_tree_project_brief_lustre.gleam
GotDocument(document) -> {
  let next = Model(..model, document: Some(document))
  #(
    next,
    effect.batch([
      observe_presence(document),
      watershed_lustre.presence(
        document:,
        config: s.config(),
        initial: next.metadata,
        started: fn(handle) { PresenceStarted(next.generation, handle) },
        on_event: fn(event) { PresenceEvent(next.generation, event) },
      ),
      watershed_lustre.after(250, Poll),
    ]),
  )
}

Focus(field) -> {
  let inputs = case field {
    Some(field) ->
      case dict.get(model.inputs, s.field_key(field)) {
        Ok(local) ->
          dict.insert(
            model.inputs,
            s.field_key(field),
            input.interacted(local),
          )
        Error(_) -> model.inputs
      }
    None -> model.inputs
  }
  let model =
    Model(..model, metadata: s.focus(model.metadata, field), inputs:)
  #(model, announce(model))
}

fn announce(model: Model) -> Effect(Msg) {
  case model.presence {
    Some(handle) -> watershed_lustre.update_presence(handle, model.metadata)
    None -> effect.none()
  }
}

Try two fields at once.

From the repository root, start Floodgate on port 4000 with just integration-up. In examples/shared_tree_project_brief_lustre, run pnpm install, pnpm build, then pnpm serve. Open http://127.0.0.1:8080. Wait for the URL's document parameter, then copy the full URL into a second tab. Add &name=Sam to give it another name.

For Fluid, work in the example's fluid/ directory: run pnpm install and pnpm build, then keep pnpm service running on port 7070 in one terminal and pnpm serve in another. Open http://127.0.0.1:8081 and share that app's URL with another tab. The static host doesn't start the service. These are two separate runs, not two clients opening a cross-SDK document.

In either app, focus owner.name in one tab and schedule.target in the other. Type without blurring: each remote value should appear while the fields are still focused. There's no Apply or Save step. Now select the same field in both tabs. Both names can appear; both people can type. The final resolved string agrees across clients, but simultaneous whole-string assignments can overwrite one another. This isn't collaborative character-level text.

Keep a caret inside a milestone title and remove a preceding milestone from the other tab. The caret and selection badge belong to the UUID, not the old row number. Add and remove review remotely, too. A badge on a removed review or milestone must disappear rather than jump to a replacement.

The boundary is part of the example.

Dates and email stay ordinary strings so intermediate typing remains valid. Only an input in IME composition defers remote value writes; other fields keep updating. Completed composition publishes once, and native caret ranges are clamped when a remote string gets shorter. Disconnect disables edits and clears visible peers. Close stops awareness and disposes resources—it doesn't confirm delivery of pending edits. To check persistence, wait until the other tab receives an edit, then reload. Content returns; old selection doesn't.

These apps mint development tokens in the browser. Don't deploy that authentication flow or put production secrets in browser code; issue production tokens on a backend. An uncertain creation isn't retried automatically. See the README and complete sources for setup, error handling, cleanup, and the real two-client browser gate. This is a comparison of these APIs, not a claim of full Fluid SDK parity.