Collaboration
How many people change one thing at once, and why it is a sequencer rather than a CRDT.
Optimistic concurrency is right for a record: two people setting a colour is a genuine either/or, and the loser deserves to be told. It is wrong for a field where the answer is almost always "both" — prose, a tally, a shared board.
Those fields are declared, never inferred:
notes: defineTable("notes", noteRow, [{ name: "byUpdatedAt", fields: ["updatedAt"] }], {
collaborative: { body: { algebra: "text", maxBytes: 1_000_000 } },
})Per field, not per table, because one row usually wants both answers: a note's body merges character by character while its title and colour keep optimistic concurrency.
Why a sequencer, not a CRDT
A CRDT solves merging without a central authority. HighX has one — the tenant Durable Object is single-threaded and already serializes every mutation, which makes it a sequencer.
Building on that authority is a smaller problem than building without it. A CRDT would cost per-element identity metadata, tombstones that cannot be collected without knowing every peer's state, state that only ever grows, and the interleaving anomalies that took RGA, YATA, and Fugue to work through — in exchange for commutativity a total order already provides.
The decisive consequence: only transform property 1 must hold.
apply(apply(d, a), transform(a, b, true)) === apply(apply(d, b), transform(b, a, false))TP2 — the property that makes peer-to-peer OT impractical — is not required, because no two clients ever transform against each other. Every client transforms only against the order the server already fixed.
There is no universal merge rule
A framework claiming one has picked a domain and renamed it "universal".
- Two people editing a sentence want both edits.
- Two people moving a piece want one of them; the piece is in one place.
- Two people scoring want the score to go up by two.
- Two people making a move under rules want the server to decide.
Each is right in its domain and destroys the others. So merge policy is chosen per document from three strategies:
| Strategy | How concurrency resolves | Fits |
|---|---|---|
transform | Concurrent ops are rebased against one another | Text, ordered lists |
commutative | Ops already commute; nothing to rebase | Counters, tallies |
authoritative | The server computes; clients discard and reconcile | Games, simulations, anything with rules |
The third is what makes this general rather than a document framework wearing a games label. For most game state the correct merge is no merge: clients send intents, the server computes truth, and each client replays what it had not yet sent. That is ordinary netcode, and the client loop already is it.
Algebras
An algebra is what a domain implements. The substrate knows nothing else about it — a note, a board, a whiteboard, and a tally are the same object here.
| Algebra | Package | Strategy |
|---|---|---|
text | @highx/text | transform |
record | @highx/sync | transform |
list | @highx/sync | transform |
counter | @highx/sync | commutative |
command | @highx/sync | authoritative |
The schema names an algebra with a string; the application says what that name
means. Keeping them apart is what lets @highx/core describe a declaration
without importing merge logic:
documentTypes: documentTypes({
schema: databaseSchema,
algebras: {
text: { build: ({ maxBytes }) => textAlgebra({ maxInsert: maxBytes }) },
},
})An algebra the schema names but nobody registered is refused at startup, not when someone finally opens that document.
Writing your own algebra means satisfying Algebra<Doc, Op> and passing
checkAlgebra — a property suite asserting TP1, associativity of compose,
the invert round trip, and that a diff reaches its target. Do not ship a
hand-written transform without it.
Composite documents
A real application is never one algebra. A game is entity state
(authoritative), chat (a list), scores (counters), and perhaps a shared note
(text) — under one revision counter. compositeAlgebra distributes over named
children, so an op touching one child never conflicts with an op touching
another. A document with any authoritative child is authoritative as a whole,
because a client cannot predict the revision.
What the server does
submit runs inside the tenant object, and there is no lock: single-threaded
execution is the critical section.
- Rebase the incoming op over every op accepted since its base revision —
under
transformonly. - Validate it against the document it will actually meet. For an authoritative algebra this is the rules engine.
- Apply, assign the next revision, append to the log, broadcast.
Ops pass the same auth and capability gates as any mutation, checked per op rather than at open time, so a capability revoked mid-session takes effect on the next keystroke rather than the next reconnection.
Storage and retention
An append-only op log plus one snapshot per document, in Durable Object SQLite. The log is folded into the snapshot on a threshold and pruned below it, so a cold start reads one row and a bounded tail rather than replaying everything.
Compaction folds by applying in order, never by composing: each op carries the context the server stamped it with, and an algebra that reads that context would otherwise rebuild a state nobody ever saw.
Op logs fall under the ordinary retention policy via
documentOpRetentionDays, defaulting to the idempotency window. Reclaiming an
op loses no work — the snapshot holds it — it only means a returning client
resyncs rather than replays.
This is Durable Object SQLite, not D1. D1 cannot participate in a Durable Object transaction and offers no total order across concurrent writers, so it cannot be the sequencer. D1 remains right for the global read model — listing documents, search, excerpts — and anything that is not the ordering decision.
Divergence
The failure mode of merge logic is not a crash. It is two clients quietly holding different state while both believe they are synchronized.
Every acknowledgement carries the server's hash of the document at that revision. The client hashes its own and compares. On a mismatch it reopens from a snapshot, re-derives its unsent work, and submits it — and the mismatch is reported rather than silently swallowed.
The same path answers a client returning from offline past the compaction horizon: the ops needed to walk it forward are gone, so it takes a snapshot and re-derives. One recovery mechanism, not two.
What is preserved on resync is the client's own delta — the difference between what it had acknowledged and what it is showing — applied on top of the snapshot. Not the difference between the snapshot and the screen: that would say "make the document look like mine" and discard everything everyone else did while this client was wrong.
Awareness
Carets, selections, held pieces. Ephemeral by construction and never written to storage — a caret moves tens of times a second and is worth nothing a moment later.
The payload is opaque. There is no universal position: a text cursor is a character offset, a game's is an entity, a whiteboard's is a viewport, and a substrate that named any of those would be a substrate for that domain.
Awareness is broadcast to everyone except its sender. A commit is the opposite — it goes to everyone including the sender, because that echo is how the sender learns where its op landed after rebasing.
What this does not cover
Tick-based rollback netcode for 60Hz action simulation. The prediction loop generalizes to it; an op log per frame does not. Turn-based games and real-time shared-space tools fit well; twitch action games want a different system.