Rebasely Beta: data may be purged

examples/live-notes Public

TypeScript · 95 lines · 4.0 KiB · at 70b1506

// 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;// 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 {  // 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;  // 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 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;  #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;    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);  }}