examples/live-notes Public
// The sequencer for one note. Every edit to a note passes through its room, one at a time, so// the room decides the order and every editor applies the same ops in that order.//// Room knows nothing about Durable Objects or sockets: it reads and writes through a NoteStore// and answers each message with an Outcome. NoteRoom (note-room.ts) supplies SQLite storage and// does the sending, and the tests supply an in-memory store.import { OpError, apply, isNoop, transformPast, type Op } from "./ops.ts";import { parseClientMessage, type ServerMessage } from "./protocol.ts";// How many recent ops the room keeps. An editor whose base version is older than that can't be// 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 { // The saved note, or { text: "", version: 0 } for one nobody has written yet. load(): Note; // The logged ops after `version`, oldest first. The room only asks for the last LOG_SIZE. opsSince(version: number): Op[]; // 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;}// 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 };export class Room { #store: NoteStore; #note: Note; constructor(store: NoteStore) { this.#store = store; this.#note = store.load(); } get note(): Note { return this.#note; } 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 { const msg = parseClientMessage(raw); if (!msg) return { reply: { type: "error", message: "expected an op message" } }; const { text, version } = this.#note; // A base from the future, or one older than the log reaches, can't be transformed. if (msg.base > version || version - msg.base > LOG_SIZE) return { reply: this.snapshot() }; const op = transformPast(msg.op, this.#store.opsSince(msg.base)); if (isNoop(op)) return { reply: { type: "ack", version } }; let next: string; try { next = apply(text, op); } catch (e) { 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); this.#note = note; return { reply: { type: "ack", version: note.version }, broadcast: { type: "op", version: note.version, op } }; }}