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

Composing Documents

A Strata app can hold more than one document. This guide shows how to define several document types, open and close them as a person moves through the app, and create or delete owned children.

Use these terms consistently:

  • A reference is an application field containing a document ID.
  • A deletion owner, also called the parent, is the single parent the server records when it creates an owned child.
  • An event-routing owner is the component instance a host chooses to receive events for an ID. This does not grant deletion authority.

The architecture diagram shows all three structures. The host-author contract defines routing and duplicate-claim rules.

Define every document kind your app understands as one closed sum type. Add a constructor per kind:

pub type Page {
Composite(Document(CompositeDocument))
Intake(Document(RequestDocument))
Board(Document(CardDocument))
Checklist(Document(TodoDocument))
}

Alongside it, define a typed reference sum for cases that need an id but not the open document:

pub type ComponentRef {
CompositeRef(DocId(CompositeDocument))
IntakeRef(DocId(RequestDocument))
BoardRef(DocId(CardDocument))
ChecklistRef(DocId(TodoDocument))
}

Both types are closed. Adding a kind means adding a constructor to each one, and the compiler then lists every case in the app that must grow to match. Only the composite's JSON conversion reads a document type from a string.

The current workspace can host IntakeRef as an independent root component. Its nested add menu creates composites, boards, and checklists. The closed types still include Intake so the root composite codec and host can handle it.

strata_document.DocId(tag) pairs a document id with its schema type. Create one with strata_document.doc_id(id) and read the plain string back with strata_document.doc_id_to_string(id).

A DocId(CardDocument) and a DocId(TodoDocument) are different types. A function that expects a board id rejects a checklist id at compile time. Both types wrap a String when the program runs.

The phantom tag does not survive serialization. doc_id wraps a string without checking the target's runtime schema, existence, or deletion parent. Validate incoming references with application codecs. Typed IDs do not grant server-side deletion authority.

PageStore(page): a typed store for open documents

Section titled “PageStore(page): a typed store for open documents”

strata_client/page_store keys every open document by id over the app's Page sum. Build one with the app's own exhaustive merge and to_json functions:

pub fn merge(page: Page, incoming: Json) -> Result(Page, DocumentError) {
case page {
Composite(doc) -> strata_document.merge_json(doc, incoming) |> result.map(Composite)
Intake(doc) -> strata_document.merge_json(doc, incoming) |> result.map(Intake)
Board(doc) -> strata_document.merge_json(doc, incoming) |> result.map(Board)
Checklist(doc) -> strata_document.merge_json(doc, incoming) |> result.map(Checklist)
}
}
pub fn to_json(page: Page) -> Json {
case page {
Composite(doc) -> strata_document.to_json(doc)
Intake(doc) -> strata_document.to_json(doc)
Board(doc) -> strata_document.to_json(doc)
Checklist(doc) -> strata_document.to_json(doc)
}
}
let store = page_store.new(merge, to_json)

page_store.open(store, id, page) records a page under id. It does not send a join frame; open a page and send strata_client.Join(id) together:

let store = page_store.open(store, id, empty_page)
let #(conn, frame) = strata_client.outbound(conn, strata_client.Join(id))

PageStore state survives a reconnect. The socket does not own this in-memory dictionary. Connection also retains its joined ids. Only the welcome status resets.

After a reconnect, strata_client.frames_for_resume creates one new join message for each retained id. The app rejoins the documents that PageStore already holds.

Feed each document event to page_store.apply_document_event. It merges a Snapshot or RemoteDelta into the stored page for that id. It closes the page on Deleted.

The function returns Result(PageStore(page), ApplyError). Handle each error:

  • NotOpen(document_id) means that the id is not open.
  • MergeFailed(document_id, error) preserves the document merge error.
  • NotDocumentEvent means that the event is not Snapshot, RemoteDelta, or Deleted.

page_store.apply_event is a compatibility wrapper. It intentionally returns the unchanged store for every error. Use it only when the application needs that no-op behavior.

Keep each document schema independent from Lustre. Put the default Lustre surface in a companion doc_view module. The board example uses this signature:

pub fn surface(
cards: Document(CardDocument),
actions: Actions(msg),
) -> Element(msg)

Actions(msg) defines functions that return host messages:

pub type Actions(msg) {
Actions(
add: fn(Dynamic, List(#(String, String))) -> msg,
move: fn(String) -> msg,
remove: fn(String) -> msg,
)
}

The host supplies these functions. The standalone kanban app maps them to its own Msg constructors:

board_view.surface(
document,
board_view.Actions(
add: AddSubmitted,
move: MoveClicked,
remove: DeleteClicked,
),
)

The workspace maps the same actions into messages that include the typed document id:

board_view.Actions(
add: fn(form, fields) {
BoardMsg(id, strata_kanban.AddSubmitted(form, fields))
},
move: fn(card) {
BoardMsg(id, strata_kanban.MoveClicked(card))
},
remove: fn(card) {
BoardMsg(id, strata_kanban.DeleteClicked(card))
},
)

Pass the renderer into the parent view when a use site must replace the default surface:

fn board_pane_with(
model: Model,
id: DocId(CardDocument),
render: fn(
Document(CardDocument),
board_view.Actions(Msg),
) -> Element(Msg),
) -> Element(Msg)

The parent pane can show context data, such as the document id and the number of open documents. The parent calls the renderer inside that pane. Another use site can pass a compact or read-only renderer without a change to the document type.

The composite: one document, typed child references

Section titled “The composite: one document, typed child references”

Model a composite as a single document whose entries are typed references to other documents, one entry per child, keyed by the child's document id:

pub type Child {
Child(
ref: ComponentRef,
title: String,
order: String,
created: Int,
version: Int,
config: Json,
)
}

The composite's JSON conversion is the only place that converts a document kind to and from a string. The current entry codec persists kind, version, config, title, order, and ts. The ts field stores the creation sequence. Version 1 uses an empty JSON object for config.

Decoding matches the stored kind to a ComponentRef constructor. It rejects an unknown kind or component version. A composite read first checks the stamped schema version. It returns a decode error for an incompatible or future document.

Adding a child is one atomic server operation, not a local edit followed by a separate save. Build the new child's empty page, add its entry to the parent document locally, then send both states in a single Create command:

strata_client.Create(
parent: parent_id,
child: child_id,
child_state: page.to_json(child_page),
parent_state: page.to_json(updated_parent_page),
)

The server performs three actions in one actor call. It stores child_state under child_id. It records the parent link. It merges parent_state into the parent.

Do not send a separate SendDelta for the parent. The Create command contains the new parent state.

Renaming or reordering a child changes only the parent's own entry for it. Send it as a normal delta: edit the parent document locally and strata_client.SendDelta(parent_id, ...), the same path as any other field write.

Removing a child is also one atomic operation:

strata_client.Delete(
parent: parent_id,
child: child_id,
parent_state: page.to_json(updated_parent_page),
)

The server verifies that the parent link maps child to parent. It then removes the child and each descendant. It follows the parent links during this cascade. It also merges parent_state into the parent.

The server pushes a deleted frame to each removed document's topic. A socket that joined a descendant receives its removal without access to the parent. strata_client.inbound converts the frame to Deleted(document_id: id). It also removes id from joined_documents. page_store.apply_document_event closes the page for id.

The app still owns the topic leave. Send strata_client.Leave(id) when Deleted arrives. The server can then release that socket's live subscription.

The store serializes create, delete, and ordinary deltas. The parent document is an add-wins CRDT, so an edit made concurrently with a delete, such as renaming the child a moment before the delete arrives, can put the child's entry back into the parent list. The child document stays deleted: the server keeps a tombstone for it, rejects later writes to that id, and returns a null snapshot to anyone who rejoins it.

Send Delete again for the same child to converge. A repeat delete is accepted rather than answered with not_a_child: the server merges the new parent_state, which removes the re-added entry, broadcasts the parent delta, and pushes deleted for the child once more. Two people can delete the same child at the same time, and neither sees an error.

Each tombstone retains the former deletion parent. A repeat delete from that parent succeeds; a different parent receives not_a_child. The checkpoint persists tombstones with document removal and parent changes. After a successful flush, these rules survive restart. An unflushed delete can be lost. See Durability and Recovery.

Tombstones have no expiry or garbage collection. Create cannot reuse a deleted ID. Remove a stale reference, or create a replacement with a new ID. An app can still show a re-added reference until a client repeats the delete. The server rejects edits to its deleted target.

A graph of references, a tree of deletion owners

Section titled “A graph of references, a tree of deletion owners”

Composition itself is a graph: any document can hold a typed reference to any other, and nothing stops the same id from appearing in two different composites. The server's parent relation, in contrast, is a tree: each child document records exactly one parent, and delete cascades along that single recorded link. Referencing one child from two parents does not create a second parent link; the child still has exactly one recorded parent. Deleting through that one recorded parent removes the child. The second composite then refers to an id that no longer exists. Create a new child id for each parent when you need independent deletion lifetimes.

Use Create to record deletion ownership for a new child. To add a non-owning reference to an existing target, edit an application field with that target's ID and send a normal delta. The reference can cross deletion subtrees, and it can dangle if the target's deletion owner removes it. The server neither infers parent links from reference fields nor removes all incoming references.

The checkpointed tombstone policy preserves deleted identity across restart. It does not turn references into shared ownership or make dangling targets reusable.

  • Lazy child joins. Opening a composite does not join its children. A peer sees a new child appear in the list as soon as the parent's delta arrives; the child's own state only arrives once something actually joins that child's id, typically because a person navigates into it.
  • Leaving closes without deleting. Navigating away from a document closes its page locally and sends Leave, but its state survives on the server. Navigating back reopens and rejoins it, and the previous state comes back in the snapshot. A leave is not a delete.
  • Unknown snapshots. Joining an id the server has never stored, such as the first join for the app's root document before anything exists under that id, or a rejoin of an id that was already deleted, returns a null snapshot. Merging null into a page fails. page_store.apply_document_event returns MergeFailed and leaves the store unchanged. Treat a known deleted id as terminal; the server rejects writes to it after its deletion has been checkpointed, including after restart. Create does not hit this case: the server stores child_state under the child's id in the same call that records the parent link, so by the time the client's Join(child_id) reaches the server and returns, the server already has a snapshot to send.
  • Fall back after deletion. If another person deletes the current document, Deleted closes it and each removed open ancestor. Walk the breadcrumb path. Drop each entry that is no longer open. Show the nearest surviving ancestor. This page is normally the app's root document. The root has no parent, so a cascade cannot delete it.
  • Ordinary edits stay per-document. Only create and delete are atomic across two documents; there is no general multi-document transaction.
  • create and delete are not ordered against concurrent deltas. A concurrent parent edit can re-add a deleted child's entry, and a repeat Delete removes it again.
  • Order ties (two children added with the same sort key) fall back to comparing document ids until a sequence CRDT exists.
  • You cannot define schemas while the program runs. Every document kind is a constructor in the app's closed Page and ComponentRef types. Define these constructors before you compile the app.

See examples/workspace for a complete Lustre app built on these pieces. Its root composite can refer to Intake, Board, Checklist, and Composite component roots. Nested composites can hold boards, checklists, or more composites.

Plain composition needs only typed references, PageStore, and the Create and Delete commands. strata_component adds a headless lifecycle, local messages, outputs, ownership queries, ports, and bindings. The workspace uses both models. See Components and Bindings for the component host.