examples/live-notes Public
Write down how editing works and why
Concepts for the conflict strategy, the room and the wire protocol, the two decisions behind the transform, and a guide to running two editors locally. The README now covers the API and the layout.
Changed files
8 files changed353 additions4 deletionsagainst e0ecd05
- Added.rebasely/kb/concepts/conflict-strategy.md+60−0
- Added.rebasely/kb/concepts/note-room.md+51−0
- Added.rebasely/kb/concepts/wire-protocol.md+35−0
- Added.rebasely/kb/decisions/0001-single-sequencer-per-note.md+37−0
- Added.rebasely/kb/decisions/0002-drop-inserts-inside-concurrent-deletes.md+30−0
- Added.rebasely/kb/guides/local-dev.md+40−0
- Added.rebasely/kb/index.md+19−0
- ChangedREADME.md+81−4
Added.rebasely/kb/concepts/conflict-strategy.md+60−0
| @@ -0,0 +1,60 @@ | ||
| +--- | ||
| +id: conflict-strategy | ||
| +type: concept | ||
| +owner: justin | ||
| +reviewed: 2026-10-13 | ||
| +component: ops | ||
| +describes: | ||
| + - src/ops.ts | ||
| + - src/room.ts | ||
| + - client/session.ts | ||
| +--- | ||
| +# Conflict strategy | ||
| + | ||
| +Edits are operations, not new copies of the text. There are two kinds: insert some text at an | ||
| +offset, and delete a number of characters at an offset. Offsets are UTF-16 code units, the unit | ||
| +a textarea's `selectionStart` uses. Replacing a selection is a delete followed by an insert. | ||
| + | ||
| +## The room decides the order | ||
| + | ||
| +Every op carries the version of the note it was written against. The note's room | ||
| +([[note-room]]) is the only place ops are ordered ([[0001-single-sequencer-per-note]]). When an | ||
| +op arrives written against version 41 and the room is at 43, the room takes ops 42 and 43 from | ||
| +its log and moves the new op past each of them with `transform()`. It applies the result, saves | ||
| +it as version 44, acks the sender and sends the transformed op to everyone else. | ||
| + | ||
| +Because the room handles one message at a time, there is never a question of which op came | ||
| +first: the one with the lower version. | ||
| + | ||
| +## Transform | ||
| + | ||
| +`transform(op, other, otherFirst)` rewrites `op` so it applies after `other`, when both were | ||
| +written against the same text. The cases are few: | ||
| + | ||
| +- Inserts before an op shift it right by the inserted length; deletes before it shift it left. | ||
| +- Two inserts at the same offset: the one the room sequenced first ends up first. The room | ||
| + passes `otherFirst = true` for logged ops; an editor passes `false` when it moves a remote op | ||
| + past its own pending edits. Both sides then put the same text first. | ||
| +- Overlapping deletes only delete what is still there. | ||
| +- An insert strictly inside a range someone else deleted at the same time is dropped, and the | ||
| + delete grows to cover it ([[0002-drop-inserts-inside-concurrent-deletes]]). | ||
| + | ||
| +The tests in `test/ops.test.ts` check every pair of random concurrent ops converges in either | ||
| +order, and `test/session.test.ts` runs three editors against a room with messages delivered in | ||
| +random interleavings. | ||
| + | ||
| +## The editor's side | ||
| + | ||
| +`client/session.ts` keeps at most one op in flight. Edits made while it waits for the ack go in | ||
| +a queue, folded together with `compose()` when they touch the same spot, so a fast typist sends | ||
| +a word per round trip. When a remote op arrives, the session moves it past the in-flight op and | ||
| +the queue, and moves those past it, the same way the room will. When the ack arrives, the next | ||
| +queued op goes out written against the version in the ack. | ||
| + | ||
| +## When an editor is too far behind | ||
| + | ||
| +The room keeps the last 200 ops. An op written against an older version, one from a version | ||
| +the room has not reached, or one that does not fit the text after transforming, gets a | ||
| +snapshot back instead of an ack. The editor shows the snapshot and drops its pending edits. | ||
| +In practice this happens after a long disconnect, and the page disables the textarea while it | ||
| +is offline so nothing is typed into the void. | ||
Added.rebasely/kb/concepts/note-room.md+51−0
| @@ -0,0 +1,51 @@ | ||
| +--- | ||
| +id: note-room | ||
| +type: concept | ||
| +owner: justin | ||
| +reviewed: 2026-10-13 | ||
| +component: note-room | ||
| +describes: | ||
| + - src/note-room.ts | ||
| + - src/room.ts | ||
| + - src/index.ts | ||
| +--- | ||
| +# The note room | ||
| + | ||
| +Each note is one Durable Object of class `NoteRoom`, found with `idFromName(noteId)` through the | ||
| +`NOTES` binding. Everyone who opens the same note id reaches the same object, wherever they | ||
| +are, and that object is the single sequencer described in [[conflict-strategy]]. | ||
| + | ||
| +## Two layers | ||
| + | ||
| +`NoteRoom` (`src/note-room.ts`) is the part that knows about the runtime: it accepts sockets, | ||
| +reads frames, sends replies and broadcasts, and owns the SQLite storage. Everything it decides | ||
| +is delegated to `Room` (`src/room.ts`), which takes a `NoteStore` and returns, for each | ||
| +message, a reply for the sender and an optional broadcast for the others. The tests drive | ||
| +`Room` with an in-memory store, so they run in plain Node without workerd. | ||
| + | ||
| +## Storage | ||
| + | ||
| +Two tables in the object's SQLite database: | ||
| + | ||
| +- `note`: one row with the text and its version. | ||
| +- `ops`: the op log, keyed by the version each op produced, trimmed to the last 200. | ||
| + | ||
| +`commit()` writes the new text, appends the op and trims the log inside one `transactionSync`, | ||
| +so a note and its log never disagree. The room only updates its in-memory copy after the | ||
| +write. | ||
| + | ||
| +## Hibernation | ||
| + | ||
| +Sockets are accepted with `ctx.acceptWebSocket`, the Hibernation API. When nobody has typed for | ||
| +a while the runtime evicts the object but keeps the sockets open; the next frame wakes it, | ||
| +runs the constructor, and calls `webSocketMessage`. The constructor creates the tables if | ||
| +needed and loads the note, so waking up is the same as starting fresh. Nothing the room needs | ||
| +lives only in memory. | ||
| + | ||
| +Broadcasting uses `ctx.getWebSockets()`, which lists every accepted socket even after a wake, | ||
| +so there is no connection list to rebuild. | ||
| + | ||
| +## Reads without a socket | ||
| + | ||
| +`GET /notes/:id` calls the `read()` RPC method and returns `{ id, text, version }`. It reads the | ||
| +room's in-memory copy, which is always the last committed state. | ||
Added.rebasely/kb/concepts/wire-protocol.md+35−0
| @@ -0,0 +1,35 @@ | ||
| +--- | ||
| +id: wire-protocol | ||
| +type: concept | ||
| +owner: justin | ||
| +reviewed: 2026-10-13 | ||
| +component: ops | ||
| +describes: | ||
| + - src/protocol.ts | ||
| +--- | ||
| +# Wire protocol | ||
| + | ||
| +Editors and a room exchange JSON text frames over the WebSocket at `/notes/:id/ws`. Versions | ||
| +are the room's: the number of ops it has applied to the note. | ||
| + | ||
| +From an editor: | ||
| + | ||
| +```json | ||
| +{ "type": "op", "base": 41, "op": { "type": "insert", "pos": 4, "text": "oat " } } | ||
| +{ "type": "op", "base": 41, "op": { "type": "delete", "pos": 0, "count": 3 } } | ||
| +``` | ||
| + | ||
| +From the room: | ||
| + | ||
| +| type | fields | when | | ||
| +| ---------- | ----------------- | --------------------------------------------------------- | | ||
| +| `snapshot` | `text`, `version` | on connect, and when an editor's op can't be used | | ||
| +| `ack` | `version` | your op was applied, or turned out to change nothing | | ||
| +| `op` | `version`, `op` | someone else's op, already transformed, at that version | | ||
| +| `error` | `message` | the frame was not an op message | | ||
| + | ||
| +`parseClientMessage()` checks every field and copies only the ones it knows, so a frame with | ||
| +extra fields still works and a malformed one is answered with `error` and changes nothing. | ||
| + | ||
| +An editor sends its next op only after the ack for the previous one; see [[conflict-strategy]] | ||
| +for why that keeps the room's job simple. | ||
Added.rebasely/kb/decisions/0001-single-sequencer-per-note.md+37−0
| @@ -0,0 +1,37 @@ | ||
| +--- | ||
| +id: 0001-single-sequencer-per-note | ||
| +type: decision | ||
| +owner: justin | ||
| +reviewed: 2026-10-13 | ||
| +status: accepted | ||
| +--- | ||
| +# 0001 One sequencer per note | ||
| + | ||
| +## Context | ||
| + | ||
| +Two people editing the same note will type at the same moment. Something has to decide how | ||
| +their edits combine, and every editor has to reach the same text. | ||
| + | ||
| +The options we looked at: | ||
| + | ||
| +1. A CRDT in every browser, with the server only relaying and storing. | ||
| +2. Peer-to-peer operational transform, where every client can order ops. | ||
| +3. Operational transform with one sequencer that orders all ops for a note. | ||
| + | ||
| +## Decision | ||
| + | ||
| +Each note's Durable Object is the only place its edits are ordered. Editors send ops written | ||
| +against a version; the room transforms them past what they missed, applies them and broadcasts | ||
| +the result. Editors keep one op in flight and never order ops themselves. | ||
| + | ||
| +## Consequences | ||
| + | ||
| +- The transform only has to make pairs of ops converge. It never has to handle three-way | ||
| + puzzles between clients, so it fits in one small file ([[conflict-strategy]]). | ||
| +- A Durable Object already gives us one instance per name with single-threaded handling, so | ||
| + the sequencer costs nothing extra to build. | ||
| +- Every edit round-trips through the room before the next one is sent. Queued keystrokes are | ||
| + composed into one op, which keeps this unnoticeable at normal latencies. | ||
| +- No offline editing: an editor that loses the connection stops accepting input until it is | ||
| + back. A CRDT would allow it, at the cost of shipping one to the browser and storing its | ||
| + metadata with every note. | ||
Added.rebasely/kb/decisions/0002-drop-inserts-inside-concurrent-deletes.md+30−0
| @@ -0,0 +1,30 @@ | ||
| +--- | ||
| +id: 0002-drop-inserts-inside-concurrent-deletes | ||
| +type: decision | ||
| +owner: justin | ||
| +reviewed: 2026-10-13 | ||
| +status: accepted | ||
| +--- | ||
| +# 0002 Drop inserts inside a concurrent delete | ||
| + | ||
| +## Context | ||
| + | ||
| +Alice deletes a sentence while Bob types a word in the middle of it. To keep Bob's word, the | ||
| +delete has to be split into two deletes around it, which means an op can turn into several | ||
| +ops, and every transform rule then has to handle lists. | ||
| + | ||
| +## Decision | ||
| + | ||
| +An insert that lands strictly inside a range someone else deleted at the same time is dropped, | ||
| +and the delete grows to cover the inserted text. Both orders of applying the two ops give the | ||
| +same result: the sentence and Bob's word are gone. | ||
| + | ||
| +An insert exactly at either end of the deleted range is kept. | ||
| + | ||
| +## Consequences | ||
| + | ||
| +- Every transform returns exactly one op, and `transform()` stays a few lines per case. | ||
| +- Bob loses a word he typed into text that was being deleted. He sees it disappear right away, | ||
| + and in practice he was typing into a sentence somebody else had decided to remove. | ||
| +- If this ever matters, splitting deletes is a contained change to `src/ops.ts`; the room, | ||
| + the protocol and the session would not change. See [[conflict-strategy]]. | ||
Added.rebasely/kb/guides/local-dev.md+40−0
| @@ -0,0 +1,40 @@ | ||
| +--- | ||
| +id: local-dev | ||
| +type: guide | ||
| +owner: justin | ||
| +reviewed: 2026-10-13 | ||
| +--- | ||
| +# Running it locally | ||
| + | ||
| +You need Node 24. | ||
| + | ||
| +```bash | ||
| +npm install | ||
| +npm test # the transform, the room and simulated editors | ||
| +npm run typecheck # the Worker and the page's script | ||
| +npm run dev # builds the page script, then runs wrangler dev | ||
| +``` | ||
| + | ||
| +`npm run dev` fetches wrangler through npx the first time. It serves the page and the Worker | ||
| +on http://localhost:8787 with a local Durable Object and SQLite under `.wrangler/`. | ||
| + | ||
| +## Two editors | ||
| + | ||
| +Open `http://localhost:8787/#groceries` in two windows side by side and type in both. The | ||
| +status line shows the room's version, or "Saving" while an edit waits for its ack. | ||
| + | ||
| +To see the note without the page: | ||
| + | ||
| +```bash | ||
| +curl http://localhost:8787/notes/groceries | ||
| +``` | ||
| + | ||
| +## Watching the protocol | ||
| + | ||
| +The browser's developer tools show the frames on the socket under the Network tab, on the | ||
| +`ws` request. You will see one `op` frame per send, an `ack` for each, and the other window's | ||
| +ops arriving as `op` frames with a version. [[wire-protocol]] lists them. | ||
| + | ||
| +## Starting over | ||
| + | ||
| +Delete `.wrangler/state` to forget every note. | ||
Added.rebasely/kb/index.md+19−0
| @@ -0,0 +1,19 @@ | ||
| +# live-notes | ||
| + | ||
| +Plain-text notes several people edit at once. Each note is one Durable Object, which orders | ||
| +every edit and passes it on to the other editors over WebSockets. | ||
| + | ||
| +## How it works | ||
| + | ||
| +- [[conflict-strategy]]: how two people typing at once end up with the same text. | ||
| +- [[note-room]]: the Durable Object behind a note, its storage, and hibernation. | ||
| +- [[wire-protocol]]: the messages on the socket. | ||
| + | ||
| +## Decisions | ||
| + | ||
| +- [[0001-single-sequencer-per-note]]: the room is the only place edits are ordered. | ||
| +- [[0002-drop-inserts-inside-concurrent-deletes]]: the one rule that keeps the transform small. | ||
| + | ||
| +## Guides | ||
| + | ||
| +- [[local-dev]]: run it on your machine, open two tabs, and watch the edits cross. | ||
ChangedREADME.md+81−4
| @@ -1,11 +1,88 @@ | ||
| # live-notes | ||
| -Plain-text notes that several people can edit at once, on Cloudflare Durable Objects. | ||
| +Plain-text notes that several people can edit at the same time, on Cloudflare Workers and | ||
| +Durable Objects. Open `/#groceries` in two browsers and type in both: each sees the other's | ||
| +edits as they happen, and both end up with the same text. | ||
| -This first part is the edit model: insert and delete operations and the transform that makes | ||
| -concurrent edits converge (`src/ops.ts`). | ||
| +## How it works | ||
| +Every note is one Durable Object, `NoteRoom`, found by name. Everyone editing "groceries" | ||
| +connects to the same object over a WebSocket, and that object is the only place where edits to | ||
| +the note are put in order. | ||
| + | ||
| +Editors do not send the whole text. They send operations (insert some text at an offset, or | ||
| +delete some characters at an offset) together with the version of the note they were looking | ||
| +at. If other edits landed in the meantime, the room moves the operation past them with a small | ||
| +transform function, applies it, saves it, and sends it on to the other editors. Each editor | ||
| +does the same transform on its side for edits it has not had acknowledged yet, so its caret | ||
| +stays where it was while others type. | ||
| + | ||
| +The room keeps the note and its last 200 operations in the Durable Object's SQLite database | ||
| +and accepts sockets with the Hibernation API. A note nobody is typing in is evicted from | ||
| +memory without closing anyone's connection, and wakes up on the next keystroke. | ||
| + | ||
| +## Running it locally | ||
| + | ||
| +You need Node 24. | ||
| + | ||
| ```bash | ||
| npm install | ||
| -npm test | ||
| +npm test # the transform, the room and simulated editors | ||
| +npm run typecheck # the Worker and the page's script | ||
| +npm run dev # builds the page script, then runs wrangler dev on :8787 | ||
| ``` | ||
| + | ||
| +Then open http://localhost:8787/#groceries in two windows. | ||
| + | ||
| +## API | ||
| + | ||
| +| Request | Response | | ||
| +| ---------------------- | --------------------------------------------------------------- | | ||
| +| `GET /` | the editor page; the part after `#` is the note id | | ||
| +| `GET /notes/:id` | the note as JSON: `{ "id", "text", "version" }` | | ||
| +| `GET /notes/:id/ws` | a WebSocket to the note's room | | ||
| + | ||
| +Note ids are 1 to 64 letters, digits, dashes or underscores. On the socket, the editor sends | ||
| + | ||
| +```json | ||
| +{ "type": "op", "base": 41, "op": { "type": "insert", "pos": 4, "text": "oat " } } | ||
| +``` | ||
| + | ||
| +and the room answers with `snapshot` (on connect, or when it can't use an op), `ack` (your op | ||
| +is in, at this version) and `op` (someone else's edit, at this version). | ||
| + | ||
| +## Conflicts in brief | ||
| + | ||
| +- The room is the single sequencer for its note. It handles one message at a time, so there is | ||
| + always an answer to "which edit came first". | ||
| +- An op written against an older version is transformed past the ops it missed, in order. | ||
| +- Two inserts at the same offset: the one the room saw first goes first. | ||
| +- Overlapping deletes remove the text once. | ||
| +- Typing inside text that someone else deleted at the same moment is deleted along with it. | ||
| + This keeps every transform to a single op. | ||
| +- An editor more than 200 versions behind gets a fresh snapshot and drops its unsent edits. | ||
| + | ||
| +The knowledge base in `.rebasely/kb` explains each of these and why we chose them. | ||
| + | ||
| +## Layout | ||
| + | ||
| +``` | ||
| +src/ | ||
| + index.ts the Worker: routes /notes/:id to its NoteRoom | ||
| + routes.ts path matching for the Worker | ||
| + note-room.ts the Durable Object: sockets, hibernation, SQLite | ||
| + room.ts the sequencer, with storage behind an interface | ||
| + ops.ts insert, delete, transform, compose, diff | ||
| + protocol.ts the socket messages and their validation | ||
| +client/ | ||
| + session.ts one editor's state: pending ops, remote ops, caret | ||
| + editor.ts the page script, compiled to public/js | ||
| +public/ | ||
| + index.html the editor page | ||
| +test/ node --test, no Workers runtime needed | ||
| +.rebasely/ architecture, flows, knowledge base and CI | ||
| +``` | ||
| + | ||
| +## License | ||
| + | ||
| +MIT | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.