Rebasely Beta: data may be purged

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.
27f140d43d1f9715029b2e14b6c15372ad6bc2da
Parent e0ecd05 Browse filesHistory

Changed files

8 files changed353 additions4 deletionsagainst e0ecd05

  1. Added.rebasely/kb/concepts/conflict-strategy.md+60−0
  2. Added.rebasely/kb/concepts/note-room.md+51−0
  3. Added.rebasely/kb/concepts/wire-protocol.md+35−0
  4. Added.rebasely/kb/decisions/0001-single-sequencer-per-note.md+37−0
  5. Added.rebasely/kb/decisions/0002-drop-inserts-inside-concurrent-deletes.md+30−0
  6. Added.rebasely/kb/guides/local-dev.md+40−0
  7. Added.rebasely/kb/index.md+19−0
  8. 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.
View file
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.
View file
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.
View file
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.
View file
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]].
View file
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.
View file
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.
View file
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
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.