Rebasely Beta: data may be purged

examples/live-notes Public

Cap notes at 100,000 characters

1 check passed
A note is one SQLite row and goes out whole in every snapshot, so an unbounded paste could make every reconnect slow. The room now refuses an op that would pass the cap and answers with a snapshot carrying the reason, which takes the edit back out of the sender's page.
d21dfdd0f0216b0b756ac4ac2d012492419ab1cd
Parent 27f140d Browse filesHistory

Changed files

7 files changed40 additions18 deletionsagainst 27f140d

  1. Changed.rebasely/kb/concepts/conflict-strategy.md+3−3
  2. Changed.rebasely/kb/concepts/note-room.md+3−2
  3. Changed.rebasely/kb/concepts/wire-protocol.md+10−7
  4. Changedclient/editor.ts+1−1
  5. Changedsrc/protocol.ts+3−2
  6. Changedsrc/room.ts+9−2
  7. Changedtest/room.test.ts+11−1
Changed.rebasely/kb/concepts/conflict-strategy.md+3−3
@@ -2,7 +2,7 @@
id: conflict-strategy
type: concept
owner: justin
-reviewed: 2026-10-13
+reviewed: 2026-10-14
component: ops
describes:
- src/ops.ts
@@ -54,7 +54,7 @@
## 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.
+the room has not reached, one that does not fit the text after transforming, or one that would
+take the note past 100,000 characters 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
Changed.rebasely/kb/concepts/note-room.md+3−2
@@ -2,7 +2,7 @@
id: note-room
type: concept
owner: justin
-reviewed: 2026-10-13
+reviewed: 2026-10-14
component: note-room
describes:
- src/note-room.ts
@@ -27,7 +27,8 @@
Two tables in the object's SQLite database:
-- `note`: one row with the text and its version.
+- `note`: one row with the text and its version. `Room` caps the text at 100,000 UTF-16 code
+ units, so the row and every snapshot frame stay small.
- `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`,
View file
Changed.rebasely/kb/concepts/wire-protocol.md+10−7
@@ -2,7 +2,7 @@
id: wire-protocol
type: concept
owner: justin
-reviewed: 2026-10-13
+reviewed: 2026-10-14
component: ops
describes:
- src/protocol.ts
@@ -21,12 +21,15 @@
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 |
+| type | fields | when |
+| ---------- | --------------------------- | --------------------------------------------------- |
+| `snapshot` | `text`, `version`, `reason` | 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 |
+| `error` | `message` | the frame was not an op message |
+
+`reason` is only set when the room refused an op the editor had already applied, for example
+one that would take the note past 100,000 characters. The page shows it in the status line.
`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.
View file
Changedclient/editor.ts+1−1
@@ -39,7 +39,7 @@
area.disabled = false;
area.focus();
retry = 0;
- status.textContent = `Version ${session.version}`;
+ status.textContent = msg.type === "snapshot" && msg.reason ? `Edit undone: ${msg.reason}` : `Version ${session.version}`;
} else if (applied) {
area.value = session.text;
area.setSelectionRange(moveCaret(selectionStart, applied), moveCaret(selectionEnd, applied));
View file
Changedsrc/protocol.ts+3−2
@@ -7,8 +7,9 @@
export type ClientMessage = { type: "op"; base: number; op: Op };
export type ServerMessage =
- // The whole note: on connect, and whenever an editor's view can't be repaired.
- | { type: "snapshot"; text: string; version: number }
+ // The whole note: on connect, and whenever an editor's view can't be repaired. `reason`
+ // says why, when the editor did something the room refused.
+ | { type: "snapshot"; text: string; version: number; reason?: string }
// Your op was applied (or turned out to change nothing) and the note is now at `version`.
| { type: "ack"; version: number }
// Someone else's op, already transformed, which took the note to `version`.
View file
Changedsrc/room.ts+9−2
@@ -12,6 +12,10 @@
// caught up op by op and gets a fresh snapshot instead.
export const LOG_SIZE = 200;
+// The longest note, in UTF-16 code units. A note is one SQLite row and goes out whole in every
+// snapshot, so it stays well under what a socket frame and a row comfortably hold.
+export const MAX_LENGTH = 100_000;
+
export type Note = { text: string; version: number };
export interface NoteStore {
@@ -41,8 +45,9 @@
return this.#note;
}
- snapshot(): ServerMessage {
- return { type: "snapshot", text: this.#note.text, version: this.#note.version };
+ snapshot(reason?: string): ServerMessage {
+ const msg: ServerMessage = { type: "snapshot", text: this.#note.text, version: this.#note.version };
+ return reason ? { ...msg, reason } : msg;
}
receive(raw: string): Outcome {
@@ -62,6 +67,8 @@
if (e instanceof OpError) return { reply: this.snapshot() };
throw e;
}
+ // The sender has already applied the op locally; the snapshot takes it back out.
+ if (next.length > MAX_LENGTH) return { reply: this.snapshot(`notes are limited to ${MAX_LENGTH.toLocaleString("en-US")} characters`) };
const note = { text: next, version: version + 1 };
this.#store.commit(note, op);
View file
Changedtest/room.test.ts+11−1
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import { del, insert, type Op } from "../src/ops.ts";
-import { LOG_SIZE, Room } from "../src/room.ts";
+import { LOG_SIZE, MAX_LENGTH, Room } from "../src/room.ts";
import { MemoryStore } from "./helpers/memory-store.ts";
const send = (base: number, op: Op) => JSON.stringify({ type: "op", base, op });
@@ -58,6 +58,16 @@
const room = new Room(store);
assert.equal(room.receive(send(4, del(2, 5))).reply.type, "snapshot");
assert.deepEqual(store.note, { text: "abc", version: 4 });
+ });
+
+ test("refuses an op that would make the note too long", () => {
+ const store = new MemoryStore({ text: "x".repeat(MAX_LENGTH - 2), version: 7 });
+ const room = new Room(store);
+ assert.equal(room.receive(send(7, insert(0, "ab"))).reply.type, "ack");
+ const out = room.receive(send(8, insert(0, "c")));
+ assert.deepEqual(out.reply, { type: "snapshot", text: "ab" + "x".repeat(MAX_LENGTH - 2), version: 8, reason: "notes are limited to 100,000 characters" });
+ assert.equal(out.broadcast, undefined);
+ assert.equal(store.note.version, 8);
});
test("answers a malformed frame with an error", () => {
View file

Checks

1 check passed
StatusCheckReported byDescriptionDetails
Successduragit/ci/testRebasely ActionssucceededRun

Runs

StatusWorkflowEventStarted
SuccessciPush