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.
App kinds: Page and ComponentRef
Section titled “App kinds: Page and ComponentRef”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.
DocId(tag): an id tied to its schema
Section titled “DocId(tag): an id tied to its schema”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.NotDocumentEventmeans that the event is notSnapshot,RemoteDelta, orDeleted.
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.
Render each document
Section titled “Render each document”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.
Add a child document
Section titled “Add a child 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.
Rename and reorder
Section titled “Rename and reorder”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.
Remove a child document
Section titled “Remove a child document”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.
Concurrent edits and repeat deletes
Section titled “Concurrent edits and repeat deletes”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 joins, leaving, and edge cases
Section titled “Lazy joins, leaving, and edge cases”- 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
nullsnapshot. Mergingnullinto a page fails.page_store.apply_document_eventreturnsMergeFailedand 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.Createdoes not hit this case: the server storeschild_stateunder the child's id in the same call that records the parent link, so by the time the client'sJoin(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,
Deletedcloses 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.
Limits
Section titled “Limits”- Ordinary edits stay per-document. Only
createanddeleteare atomic across two documents; there is no general multi-document transaction. createanddeleteare not ordered against concurrent deltas. A concurrent parent edit can re-add a deleted child's entry, and a repeatDeleteremoves 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
PageandComponentReftypes. 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.