Rebasely Beta: data may be purged

examples/live-notes Public

Save a snapshot of each note to R2 every 100 versions

The room keeps only the latest text and the last 200 ops, so nothing older than that could be
read back. Now every 100 versions it copies the note's text to the SNAPSHOTS bucket, after the
editors have their replies. The version of the last saved copy is kept in SQLite; a failed save
stays due and is tried again on the next edit.

The Worker lists and serves snapshots straight from R2 at /notes/:id/snapshots and
/notes/:id/snapshots/:version, without waking the room.
bd4f325f72f8a6bcf356ca1a78433a3804b4feb2
Parent d21dfdd Browse filesHistory

Changed files

10 files changed289 additions24 deletionsagainst d21dfdd

  1. Changedsrc/index.ts+23−4
  2. Changedsrc/note-room.ts+43−10
  3. Changedsrc/room.ts+21−4
  4. Changedsrc/routes.ts+11−3
  5. Addedsrc/snapshots.ts+43−0
  6. Addedtest/helpers/memory-bucket.ts+37−0
  7. Changedtest/helpers/memory-store.ts+9−0
  8. Changedtest/routes.test.ts+9−1
  9. Addedtest/snapshots.test.ts+88−0
  10. Changedwrangler.jsonc+5−2
Changedsrc/index.ts+23−4
@@ -1,25 +1,44 @@
// The live-notes Worker. It routes each note to its own NoteRoom by name, so every editor of
// "groceries" reaches the same object wherever they are.
//
-// GET /notes/:id the note as JSON: { id, text, version }
-// GET /notes/:id/ws upgrade to a WebSocket and edit the note (protocol.ts)
+// GET /notes/:id the note as JSON: { id, text, version }
+// GET /notes/:id/ws upgrade to a WebSocket and edit the note (protocol.ts)
+// GET /notes/:id/snapshots?after=n the note's saved snapshots, oldest first (snapshots.ts)
+// GET /notes/:id/snapshots/:version the note's text at that version, as text/plain
+//
+// Snapshots are read straight from R2, so looking at a note's history never wakes its room.
import type { NoteRoom } from "./note-room.ts";
import { route } from "./routes.ts";
+import { listSnapshots, readSnapshot } from "./snapshots.ts";
export { NoteRoom } from "./note-room.ts";
export interface Env {
NOTES: DurableObjectNamespace<NoteRoom>;
+ SNAPSHOTS: R2Bucket;
}
export default {
async fetch(request, env): Promise<Response> {
- const r = route(new URL(request.url).pathname);
+ const url = new URL(request.url);
+ const r = route(url.pathname);
if (!r) return new Response("not found", { status: 404 });
if (request.method !== "GET") return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
- const room = env.NOTES.get(env.NOTES.idFromName(r.id));
+ const objectId = env.NOTES.idFromName(r.id);
+ if (r.kind === "snapshots") {
+ const after = Number(url.searchParams.get("after") ?? 0);
+ if (!Number.isSafeInteger(after) || after < 0) return new Response("after is a version number", { status: 400 });
+ return Response.json({ id: r.id, ...(await listSnapshots(env.SNAPSHOTS, objectId.toString(), after)) });
+ }
+ if (r.kind === "snapshot") {
+ const text = await readSnapshot(env.SNAPSHOTS, objectId.toString(), r.version);
+ if (text === null) return new Response("no snapshot of that version", { status: 404 });
+ return new Response(text, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=31536000, immutable" } });
+ }
+
+ const room = env.NOTES.get(objectId);
if (r.kind === "socket") {
if (request.headers.get("Upgrade") !== "websocket") return new Response("expected a WebSocket upgrade", { status: 426 });
return room.fetch(request);
View file
Changedsrc/note-room.ts+43−10
@@ -5,15 +5,22 @@
// keeps the connections open while the object is evicted, and wakes it with webSocketMessage
// when a frame arrives. Everything the room needs lives in SQLite, so the constructor below is
// also the wake-up path.
+//
+// Every SNAPSHOT_EVERY versions the room also copies the note's text to the SNAPSHOTS bucket
+// (snapshots.ts), after the editors have their replies.
import { DurableObject } from "cloudflare:workers";
+import type { Env } from "./index.ts";
import type { Op } from "./ops.ts";
import { LOG_SIZE, Room, type Note, type NoteStore } from "./room.ts";
+import { saveSnapshot } from "./snapshots.ts";
-export class NoteRoom extends DurableObject {
+export class NoteRoom extends DurableObject<Env> {
#room: Room;
+ // A snapshot is being written. Edits that arrive meanwhile don't start another one.
+ #saving = false;
- constructor(ctx: DurableObjectState, env: Cloudflare.Env) {
+ constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.#room = new Room(new SqlNoteStore(ctx.storage));
}
@@ -39,18 +46,35 @@
}
const out = this.#room.receive(data);
ws.send(JSON.stringify(out.reply));
- if (!out.broadcast) return;
- const frame = JSON.stringify(out.broadcast);
- for (const other of this.ctx.getWebSockets()) {
- if (other === ws) continue;
- try {
- other.send(frame);
- } catch {
- // The socket is closing; webSocketClose will follow.
+ if (out.broadcast) {
+ const frame = JSON.stringify(out.broadcast);
+ for (const other of this.ctx.getWebSockets()) {
+ if (other === ws) continue;
+ try {
+ other.send(frame);
+ } catch {
+ // The socket is closing; webSocketClose will follow.
+ }
}
}
+ if (out.saveSnapshot) await this.#snapshot(out.saveSnapshot);
}
+ // Copies the note to R2. A failure is logged and the snapshot stays due, so the next edit
+ // tries again; editing never waits on R2.
+ async #snapshot(note: Note): Promise<void> {
+ if (this.#saving) return;
+ this.#saving = true;
+ try {
+ await saveSnapshot(this.env.SNAPSHOTS, this.ctx.id.toString(), note);
+ this.#room.snapshotSaved(note.version);
+ } catch (e) {
+ console.error(JSON.stringify({ msg: "saving a snapshot failed", version: note.version, error: String(e) }));
+ } finally {
+ this.#saving = false;
+ }
+ }
+
async webSocketClose(ws: WebSocket, code: number): Promise<void> {
// 1005 and 1006 report that no close code was sent; they can't be sent back.
ws.close(code === 1005 || code === 1006 ? 1000 : code);
@@ -66,7 +90,16 @@
storage.sql.exec(`
CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY CHECK (id = 1), text TEXT NOT NULL, version INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS ops (version INTEGER PRIMARY KEY, op TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS snapshot (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL);
`);
+ }
+
+ lastSnapshot(): number {
+ return this.#storage.sql.exec<{ version: number }>("SELECT version FROM snapshot WHERE id = 1").toArray()[0]?.version ?? 0;
+ }
+
+ snapshotSaved(version: number): void {
+ this.#storage.sql.exec("INSERT INTO snapshot (id, version) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET version = excluded.version", version);
}
load(): Note {
View file
Changedsrc/room.ts+21−4
@@ -16,6 +16,10 @@
// snapshot, so it stays well under what a socket frame and a row comfortably hold.
export const MAX_LENGTH = 100_000;
+// How often the room copies the note to R2 (snapshots.ts): whenever it is this many versions
+// past the last copy that was saved.
+export const SNAPSHOT_EVERY = 100;
+
export type Note = { text: string; version: number };
export interface NoteStore {
@@ -26,11 +30,15 @@
// Saves the note at its new version together with the op that produced it, and trims the
// log to the last LOG_SIZE ops. Must be atomic.
commit(note: Note, op: Op): void;
+ // The version of the last snapshot saved to R2, or 0 if there is none.
+ lastSnapshot(): number;
+ // Records that the snapshot of `version` is saved.
+ snapshotSaved(version: number): void;
}
-// What to send after a message: `reply` goes back to the sender, `broadcast` to every other
-// editor of the note.
-export type Outcome = { reply: ServerMessage; broadcast?: ServerMessage };
+// What to do after a message: `reply` goes back to the sender, `broadcast` to every other
+// editor of the note, and `saveSnapshot`, when set, is the note to copy to R2.
+export type Outcome = { reply: ServerMessage; broadcast?: ServerMessage; saveSnapshot?: Note };
export class Room {
#store: NoteStore;
@@ -73,6 +81,15 @@
const note = { text: next, version: version + 1 };
this.#store.commit(note, op);
this.#note = note;
- return { reply: { type: "ack", version: note.version }, broadcast: { type: "op", version: note.version, op } };
+ const out: Outcome = { reply: { type: "ack", version: note.version }, broadcast: { type: "op", version: note.version, op } };
+ // Due until a save is recorded, so a snapshot that failed to save is tried again on the next
+ // edit rather than skipped.
+ if (note.version - this.#store.lastSnapshot() >= SNAPSHOT_EVERY) out.saveSnapshot = note;
+ return out;
+ }
+
+ // NoteRoom calls this once the snapshot of `version` is in R2.
+ snapshotSaved(version: number): void {
+ if (version > this.#store.lastSnapshot()) this.#store.snapshotSaved(version);
}
}
View file
Changedsrc/routes.ts+11−3
@@ -1,12 +1,20 @@
// The Worker's routes, kept apart from index.ts so they can be tested without the runtime.
-export type Route = { kind: "note"; id: string } | { kind: "socket"; id: string } | null;
+export type Route =
+ | { kind: "note"; id: string }
+ | { kind: "socket"; id: string }
+ | { kind: "snapshots"; id: string }
+ | { kind: "snapshot"; id: string; version: number }
+ | null;
// Note ids are what people put in a link: letters, digits, dashes and underscores.
-const NOTE = /^\/notes\/([A-Za-z0-9_-]{1,64})(\/ws)?$/;
+const NOTE = /^\/notes\/([A-Za-z0-9_-]{1,64})(?:\/(ws|snapshots)(?:\/([1-9][0-9]{0,11}))?)?$/;
export function route(pathname: string): Route {
const m = NOTE.exec(pathname);
if (!m) return null;
- return m[2] ? { kind: "socket", id: m[1] } : { kind: "note", id: m[1] };
+ const [, id, part, version] = m;
+ if (!part) return { kind: "note", id };
+ if (part === "ws") return version ? null : { kind: "socket", id };
+ return version ? { kind: "snapshot", id, version: Number(version) } : { kind: "snapshots", id };
}
View file
Addedsrc/snapshots.ts+43−0
@@ -0,0 +1,43 @@
+// Copies of a note's text in R2, saved by its room every SNAPSHOT_EVERY versions (room.ts). The
+// room's own SQLite holds only the latest text and the last LOG_SIZE ops; these let anyone read
+// what the note said further back, without waking the room.
+//
+// Keys are snapshots/<object id>/<version>.txt. The object id is the room's
+// idFromName(note id), which the Worker computes the same way, so it can list and read a note's
+// snapshots straight from the bucket. Versions are zero-padded so keys list in version order.
+
+import type { Note } from "./room.ts";
+
+export type SnapshotInfo = { version: number; saved_at: string; size: number };
+export type SnapshotPage = { snapshots: SnapshotInfo[]; next: number | null };
+
+export const PAGE = 100;
+const DIGITS = 12;
+
+export function snapshotKey(objectId: string, version: number): string {
+ return `snapshots/${objectId}/${String(version).padStart(DIGITS, "0")}.txt`;
+}
+
+export async function saveSnapshot(bucket: R2Bucket, objectId: string, note: Note): Promise<void> {
+ await bucket.put(snapshotKey(objectId, note.version), note.text, {
+ httpMetadata: { contentType: "text/plain; charset=utf-8" },
+ });
+}
+
+// A note's snapshots in version order, PAGE at a time, starting after version `after`.
+export async function listSnapshots(bucket: R2Bucket, objectId: string, after = 0): Promise<SnapshotPage> {
+ const prefix = `snapshots/${objectId}/`;
+ const page = await bucket.list({ prefix, startAfter: snapshotKey(objectId, after), limit: PAGE });
+ const snapshots = page.objects.map((o) => ({
+ version: Number(o.key.slice(prefix.length, -".txt".length)),
+ saved_at: o.uploaded.toISOString(),
+ size: o.size,
+ }));
+ return { snapshots, next: page.truncated ? (snapshots.at(-1)?.version ?? null) : null };
+}
+
+// The note's text at `version`, or null when there is no snapshot of that version.
+export async function readSnapshot(bucket: R2Bucket, objectId: string, version: number): Promise<string | null> {
+ const object = await bucket.get(snapshotKey(objectId, version));
+ return object ? object.text() : null;
+}
View file
Addedtest/helpers/memory-bucket.ts+37−0
@@ -0,0 +1,37 @@
+// The part of an R2 bucket snapshots.ts uses (put, get and list with prefix, startAfter and
+// limit), in memory. Keys list in byte order, as they do in R2.
+
+type Stored = { text: string; uploaded: Date };
+
+export class MemoryBucket {
+ objects = new Map<string, Stored>();
+ failNext = false;
+ #clock: () => Date;
+
+ constructor(clock: () => Date = () => new Date()) {
+ this.#clock = clock;
+ }
+
+ async put(key: string, value: string): Promise<void> {
+ if (this.failNext) {
+ this.failNext = false;
+ throw new Error("R2 put failed: 503 Service Unavailable");
+ }
+ this.objects.set(key, { text: value, uploaded: this.#clock() });
+ }
+
+ async get(key: string) {
+ const o = this.objects.get(key);
+ return o ? { text: async () => o.text } : null;
+ }
+
+ async list({ prefix = "", startAfter = "", limit = 1000 }: { prefix?: string; startAfter?: string; limit?: number }) {
+ const keys = [...this.objects.keys()].filter((k) => k.startsWith(prefix) && k > startAfter).sort();
+ const objects = keys.slice(0, limit).map((key) => ({ key, size: new TextEncoder().encode(this.objects.get(key)!.text).length, uploaded: this.objects.get(key)!.uploaded }));
+ return { objects, truncated: keys.length > limit };
+ }
+}
+
+export function memoryBucket(clock?: () => Date): MemoryBucket & R2Bucket {
+ return new MemoryBucket(clock) as MemoryBucket & R2Bucket;
+}
View file
Changedtest/helpers/memory-store.ts+9−0
@@ -5,6 +5,7 @@
export class MemoryStore implements NoteStore {
note: Note;
log: { version: number; op: Op }[] = [];
+ snapshot = 0;
constructor(note: Note = { text: "", version: 0 }) {
this.note = note;
@@ -22,5 +23,13 @@
this.note = { ...note };
this.log.push({ version: note.version, op });
this.log = this.log.filter((e) => e.version > note.version - LOG_SIZE);
+ }
+
+ lastSnapshot(): number {
+ return this.snapshot;
+ }
+
+ snapshotSaved(version: number): void {
+ this.snapshot = version;
}
}
View file
Changedtest/routes.test.ts+9−1
@@ -7,8 +7,16 @@
assert.deepEqual(route("/notes/team_standup-42/ws"), { kind: "socket", id: "team_standup-42" });
});
+test("routes a note's snapshots and one snapshot by version", () => {
+ assert.deepEqual(route("/notes/groceries/snapshots"), { kind: "snapshots", id: "groceries" });
+ assert.deepEqual(route("/notes/groceries/snapshots/300"), { kind: "snapshot", id: "groceries", version: 300 });
+});
+
test("refuses other paths and odd ids", () => {
- for (const p of ["/", "/notes", "/notes/", "/notes/a/b", "/notes/a%20b", "/notes/../x", `/notes/${"a".repeat(65)}`, "/notes/a/ws/x"]) {
+ for (const p of [
+ "/", "/notes", "/notes/", "/notes/a/b", "/notes/a%20b", "/notes/../x", `/notes/${"a".repeat(65)}`, "/notes/a/ws/x",
+ "/notes/a/ws/100", "/notes/a/snapshots/0", "/notes/a/snapshots/007", "/notes/a/snapshots/1e3", "/notes/a/snapshots/", `/notes/a/snapshots/${"9".repeat(13)}`,
+ ]) {
assert.equal(route(p), null, p);
}
});
View file
Addedtest/snapshots.test.ts+88−0
@@ -0,0 +1,88 @@
+import assert from "node:assert/strict";
+import { describe, test } from "node:test";
+import { insert, type Op } from "../src/ops.ts";
+import { Room, SNAPSHOT_EVERY } from "../src/room.ts";
+import { PAGE, listSnapshots, readSnapshot, saveSnapshot, snapshotKey } from "../src/snapshots.ts";
+import { memoryBucket } from "./helpers/memory-bucket.ts";
+import { MemoryStore } from "./helpers/memory-store.ts";
+
+const send = (base: number, op: Op) => JSON.stringify({ type: "op", base, op });
+const ID = "a1b2c3";
+
+describe("when the room asks for a snapshot", () => {
+ test("every SNAPSHOT_EVERY versions, with the note as it is at that version", () => {
+ const room = new Room(new MemoryStore());
+ for (let v = 0; v < SNAPSHOT_EVERY - 1; v++) assert.equal(room.receive(send(v, insert(v, "x"))).saveSnapshot, undefined);
+ const due = room.receive(send(SNAPSHOT_EVERY - 1, insert(0, "y")));
+ assert.deepEqual(due.saveSnapshot, { text: "y" + "x".repeat(SNAPSHOT_EVERY - 1), version: SNAPSHOT_EVERY });
+ // The reply and the broadcast are the same as for any other edit.
+ assert.deepEqual(due.reply, { type: "ack", version: SNAPSHOT_EVERY });
+ assert.equal(due.broadcast?.type, "op");
+ });
+
+ test("not again until another SNAPSHOT_EVERY versions after a recorded save", () => {
+ const store = new MemoryStore({ text: "", version: SNAPSHOT_EVERY - 1 });
+ const room = new Room(store);
+ room.snapshotSaved(room.receive(send(SNAPSHOT_EVERY - 1, insert(0, "a"))).saveSnapshot!.version);
+ assert.equal(store.snapshot, SNAPSHOT_EVERY);
+ assert.equal(room.receive(send(SNAPSHOT_EVERY, insert(0, "b"))).saveSnapshot, undefined);
+ });
+
+ test("on the next edit again when a save was never recorded", () => {
+ const room = new Room(new MemoryStore({ text: "", version: SNAPSHOT_EVERY - 1 }));
+ assert.equal(room.receive(send(SNAPSHOT_EVERY - 1, insert(0, "a"))).saveSnapshot?.version, SNAPSHOT_EVERY);
+ assert.equal(room.receive(send(SNAPSHOT_EVERY, insert(0, "b"))).saveSnapshot?.version, SNAPSHOT_EVERY + 1);
+ });
+
+ test("never for an edit that changed nothing or was refused", () => {
+ const room = new Room(new MemoryStore({ text: "abc", version: 2 * SNAPSHOT_EVERY }));
+ assert.equal(room.receive(send(2 * SNAPSHOT_EVERY, insert(0, ""))).saveSnapshot, undefined);
+ assert.equal(room.receive(send(2 * SNAPSHOT_EVERY, insert(9, "x"))).saveSnapshot, undefined);
+ });
+
+ test("a recorded save survives the room waking from hibernation", () => {
+ const store = new MemoryStore({ text: "", version: SNAPSHOT_EVERY - 1 });
+ const room = new Room(store);
+ room.snapshotSaved(room.receive(send(SNAPSHOT_EVERY - 1, insert(0, "a"))).saveSnapshot!.version);
+ const woken = new Room(store);
+ assert.equal(woken.receive(send(SNAPSHOT_EVERY, insert(0, "b"))).saveSnapshot, undefined);
+ });
+
+ test("an older save never moves the recorded version back", () => {
+ const store = new MemoryStore();
+ const room = new Room(store);
+ room.snapshotSaved(300);
+ room.snapshotSaved(200);
+ assert.equal(store.snapshot, 300);
+ });
+});
+
+describe("snapshots in R2", () => {
+ test("keys sort in version order", () => {
+ assert.equal(snapshotKey(ID, 100), "snapshots/a1b2c3/000000000100.txt");
+ assert.ok(snapshotKey(ID, 900) < snapshotKey(ID, 1000));
+ });
+
+ test("saves a note and reads it back by version", async () => {
+ const bucket = memoryBucket();
+ await saveSnapshot(bucket, ID, { text: "milk\neggs\n", version: 100 });
+ assert.equal(await readSnapshot(bucket, ID, 100), "milk\neggs\n");
+ assert.equal(await readSnapshot(bucket, ID, 200), null);
+ assert.equal(await readSnapshot(bucket, "another-note", 100), null);
+ });
+
+ test("lists a note's snapshots oldest first, a page at a time", async () => {
+ const bucket = memoryBucket(() => new Date("2026-10-15T09:00:00Z"));
+ for (let v = 1; v <= PAGE + 2; v++) await saveSnapshot(bucket, ID, { text: "é".repeat(v), version: v * SNAPSHOT_EVERY });
+ await saveSnapshot(bucket, "another-note", { text: "", version: SNAPSHOT_EVERY });
+
+ const first = await listSnapshots(bucket, ID);
+ assert.equal(first.snapshots.length, PAGE);
+ assert.deepEqual(first.snapshots[0], { version: SNAPSHOT_EVERY, saved_at: "2026-10-15T09:00:00.000Z", size: 2 });
+ assert.equal(first.next, PAGE * SNAPSHOT_EVERY);
+
+ const second = await listSnapshots(bucket, ID, first.next!);
+ assert.deepEqual(second.snapshots.map((s) => s.version), [(PAGE + 1) * SNAPSHOT_EVERY, (PAGE + 2) * SNAPSHOT_EVERY]);
+ assert.equal(second.next, null);
+ });
+});
View file
Changedwrangler.jsonc+5−2
@@ -1,5 +1,5 @@
-// live-notes: one Worker, and one NoteRoom Durable Object per note. The editor page is a
-// static asset; `npm run build` compiles its script into public/js first.
+// live-notes: one Worker, one NoteRoom Durable Object per note, and an R2 bucket of snapshots.
+// The editor page is a static asset; `npm run build` compiles its script into public/js first.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "live-notes",
@@ -8,6 +8,9 @@
"observability": { "enabled": true },
"build": { "command": "npm run build", "watch_dir": ["client", "src"] },
"assets": { "directory": "public" },
+ // Copies of each note every 100 versions (src/snapshots.ts). Create it once with
+ // `npx wrangler r2 bucket create live-notes-snapshots`.
+ "r2_buckets": [{ "binding": "SNAPSHOTS", "bucket_name": "live-notes-snapshots" }],
"durable_objects": {
"bindings": [{ "name": "NOTES", "class_name": "NoteRoom" }]
},
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.