watershed Collaborative data structures for Gleam

Compare two implementations
of one shared array.

Insert an entry. Remove the first entry. See the count change in another browser tab. This page compares the Fluid website's simple SharedTree example with a watershed implementation. Read the source for each implementation below.

Compare the native SharedTree APIs.

Both implementations store an array of strings and display its length. Both insert an empty string at the start. Remove has no effect on an empty array. The Fluid application uses React. The watershed application uses Lustre. Both applications use the same shared data and actions.

This page compares watershed's native SharedTree API. The typed-map comparison follows a different design. It uses a graph of channels and decodes values when it reads them. To try a native tree in your browser, open the live checklist. Or go beyond one array with the project-brief comparison: nested objects, optional review, milestones, and transient field selection.

In Gleam, one Root(List(String)) declaration configures creation and opening of a Tree(List(String)). Reads return immutable Gleam lists containing the array's values at the time of the read. List operations produce new local lists. To change the shared array, call the tree's edit API with a Ref(List(String)) that identifies the array in the tree.

The Fluid source uses the MIT license. This page uses the source from commit 396454d69295. The upstream package specifies Fluid ^3.1.0 as a dependency. This comparison does not imply that watershed implements the full Fluid SDK. This page extracts both columns from source files in the repository.

Define the array schema.

Fluid derives a typed StringArray class from SchemaFactory and passes it to TreeViewConfiguration. In watershed, configuration() declares the array schema as Root(List(String)). Pass this declaration to the create or open API. These APIs compile the stored schema, view schema, and snapshot codecs.

Fluid · TypeScript

examples/shared_tree_item_counter_lustre/upstream/src/schema.ts
// Define a schema factory that is used to generate classes for the schema
const sf = new SchemaFactory("d302b84c-75f6-4ecd-9663-524f467013e3");

// Define a class that is an array of strings
// This class is used to create an array in the SharedTree
export class StringArray extends sf.array("StringArray", sf.string) {
/**
 * Remove the first item in the list if the list is not empty
 */
public removeFirst = () => {
	if (this.length > 0) this.removeAt(0);
};

/**
 * Add an item to the beginning of the list
 */
public insertNew = () => {
	this.insertAtStart("");
};
}

// This object is passed into the SharedTree via the schematize method.
export const treeConfiguration = new TreeViewConfiguration(
	// Specify the root type - StringArray.
	{ schema: StringArray },
);

watershed · Gleam

examples/shared_tree_item_counter_lustre/src/shared_tree_item_counter_lustre/schema.gleam
// This identifier names the array type, as SchemaFactory names StringArray.
pub const array_type = "shared_tree_item_counter.StringArray"

// Declare a root array of strings, as sf.array("StringArray", sf.string) does.
pub fn configuration() -> builder.Root(List(String)) {
  builder.array(
    factory: builder.factory(scope: "shared_tree_item_counter"),
    name: "StringArray",
    element: builder.string(),
  )
  |> builder.root
}

// Start with an empty array, as appData.initialize([]) does.
pub fn initial() -> List(String) {
  []
}

Create or open a container.

Fluid creates or loads a container and gets its tree view. It initializes the tree if allowed and attaches a new detached container. watershed passes configuration() and an empty list to create_dev, then saves the returned ID. It connects and opens a Tree(List(String)) with the same declaration. Opening does not initialize content or upgrade the stored schema. The complete applications include configuration and UI setup that these excerpts omit.

Fluid · TypeScript

examples/shared_tree_item_counter_lustre/upstream/src/infra/fluid.ts
export const containerSchema = {
	initialObjects: {
		appData: SharedTree,
	},
} satisfies ContainerSchema;

// Get or create the document depending if we are running through the create new flow
if (containerId.length === 0) {
	// The client will create a new detached container using the schema
	// A detached container will enable the app to modify the container before attaching it to the client
	({ container, services } = await client.createContainer(
		containerSchema,
		minVersionForCollab,
	));
} else {
	// Use the unique container ID to fetch the container created earlier. It will already be connected to the
	// collaboration session.
	({ container, services } = await client.getContainer(
		containerId,
		containerSchema,
		minVersionForCollab,
	));
}
examples/shared_tree_item_counter_lustre/upstream/src/index.tsx
// Initialize the SharedTree Data Structure
const appData = container.initialObjects.appData.viewWith(
	treeConfiguration, // This is defined in schema.ts
);
if (appData.compatibility.canInitialize) {
	appData.initialize([]);
}

// If the app is in a `createNew` state - no containerId, and the container is detached, we attach the container.
// This uploads the container to the service and connects to the collaboration session.
if (container.attachState === AttachState.Detached) {
	containerId = await container.attach();

	// The newly attached container is given a unique ID that can be used to access the container in another session.
	// This adds that id to the url.
	history.replaceState(undefined, "", `#${containerId}`);
}

watershed · Gleam

examples/shared_tree_item_counter_lustre/src/shared_tree_item_counter_lustre.gleam
// Select create or load from the URL document ID, as loadFluidData does.
case document_id {
  // Create with the schema and empty root. No separate attach is needed.
  "" -> #(
    model,
    tree.create_dev(
      config.base_url,
      config.tenant,
      config.secret,
      document_schema.configuration(),
      [],
      Created,
    ),
  )
  id -> #(model, connect(config, id))
}
examples/shared_tree_item_counter_lustre/src/shared_tree_item_counter_lustre.gleam
case message {
  Created(Ok(document_id)) -> {
    // Keep the new ID in the URL, as the container.attach result is kept.
    replace_document(document_id)
    #(
      Model(..model, phase: Connecting, document_id: document_id),
      connect(model.config, document_id),
    )
  }
  Created(Error(detail)) ->
    fail(
      model,
      "Creation failed. The service might have created a document whose ID was not received. Do not retry automatically: "
        <> tree_error.to_string(detail),
    )
  GotDocument(document) -> #(
    Model(..model, document: Some(document)),
    effect.none(),
  )
  Connected(Error(detail)) -> fail(model, detail)
  Connected(Ok(Nil)) ->
    case model.document {
      Some(document) -> #(
        model,
        // Open the view, as viewWith does. Creation initialized the root.
        tree.open(document, document_schema.configuration(), Opened),
      )
      None -> fail(model, "Connection completed without a document handle")
    }
}

Read the array and subscribe to changes.

Both applications calculate the count from the array length. The React application subscribes to nodeChanged and reads length. The Lustre application uses observe to subscribe and read_root to request the initial values. Both Gleam operations deliver Result(List(String), error.Error); observe checks that the root is readable but sends no initial value. Subscription registration can also fail. Both applications unsubscribe when their view closes; the complete Gleam application also handles a subscription that arrives after Close.

Fluid · TypeScript

examples/shared_tree_item_counter_lustre/upstream/src/react_app.tsx
const [count, setCount] = useState(props.list.length);

// Register for change events on the list when the component mounts.
// Any time the list changes, the app will update
useEffect(() => {
	const unsubscribe = Tree.on(props.list, "nodeChanged", () => {
		setCount(props.list.length);
	});
	return unsubscribe;
}, []);

watershed · Gleam

examples/shared_tree_item_counter_lustre/src/shared_tree_item_counter_lustre.gleam
Opened(Ok(shared_tree)) -> #(
  Model(..model, tree: Some(shared_tree)),
  effect.batch([
    // Register for changes, as Tree.on does, and read the initial count.
    tree.observe(shared_tree, Subscribed, Read),
    tree.read_root(shared_tree, Read),
  ]),
)
Subscribed(Error(detail)) -> fail(model, tree_error.to_string(detail))
Subscribed(Ok(subscription)) -> #(
  // Keep the token for unsubscribe when the view closes.
  Model(..model, subscription: Some(subscription)),
  effect.none(),
)
Read(Error(detail)) -> fail(model, tree_error.to_string(detail))
Read(Ok(values)) -> #(
  Model(..model, phase: Ready, count: list.length(values)),
  effect.none(),
)

Insert or remove the first entry.

Fluid methods edit the array. watershed calls required_root to get a Ref(List(String)), then passes it to array_insert or array_remove. The tree uses the item codec from its declaration, so the caller does not prepare a separate edit value. Both applications insert an empty string at index zero; Remove has no effect on an empty array. watershed checks for the first entry with array_get. To remove the first entry, pass start index 0 and end index 1. The end index is excluded.

Fluid · TypeScript

examples/shared_tree_item_counter_lustre/upstream/src/schema.ts
/**
 * Remove the first item in the list if the list is not empty
 */
public removeFirst = () => {
	if (this.length > 0) this.removeAt(0);
};

/**
 * Add an item to the beginning of the list
 */
public insertNew = () => {
	this.insertAtStart("");
};

watershed · Gleam

examples/shared_tree_item_counter_lustre/src/shared_tree_item_counter_lustre/counter.gleam
pub fn apply(
  handle: tree.Tree(List(String)),
  action: Action,
) -> Result(Nil, error.Error) {
  use array <- result.try(tree.required_root(handle))
  case action {
    Insert -> tree.array_insert(array, 0, [""])
    Remove -> {
      use first <- result.try(tree.array_get(array, 0))
      case first {
        None -> Ok(Nil)
        Some(_) -> tree.array_remove(array, 0, 1)
      }
    }
  }
}
examples/shared_tree_item_counter_lustre/src/shared_tree_item_counter_lustre.gleam
Edit(action) ->
  case model.phase, model.busy, model.tree {
    Ready, False, Some(shared_tree) -> #(
      Model(..model, busy: True),
      tree.perform(fn() { counter.apply(shared_tree, action) }, Edited),
    )
    _, _, _ -> #(model, effect.none())
  }

Account for differences between the implementations.

Fluid creates a detached container, initializes its tree, and attaches the container. watershed creates a container with a schema, connects, and opens the tree. The Gleam example uses its own schema namespace and the native Floodgate container layout. This comparison does not show either application opening documents from the other.

When another client edits the array, an existing Gleam list remains unchanged. The example subscribes to changes and receives a new list for each successful update. Reads and edits return a Result. A read of Ok([]) means the array is empty; Error(detail) means the operation failed. The example displays errors from watershed/tree/error.Error instead of displaying a count of zero after a failed read.

This example does not use a numeric counter DDS. The displayed number is the array's length. Two concurrent removals can target the same entry. Thus, two Remove clicks do not guarantee a decrease of two in the shared count. The array's merge rules determine which entries remain.

The complete example also includes setup, typed reads, error handling, and subscription cleanup. The example creates authentication tokens in the browser for development. In production, create tokens on a server. Do not include a tenant secret in browser code.

Run the example in two browser tabs.

Start Floodgate with just integration-up. In examples/shared_tree_item_counter_lustre, run pnpm install, pnpm run build, then pnpm run serve. Open http://127.0.0.1:8080, wait for a document parameter, and copy the full URL into another tab.

Click Insert in either tab. Click Remove when the count is zero. Reload after an edit appears in the other tab. The README and complete Gleam source explain the development configuration and browser tests. To run the Fluid version, follow the original project's instructions.