Rebasely Beta: data may be purged

examples/live-notes Public

TypeScript · 79 lines · 3.2 KiB · at 99dd857

// 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;  }  // Handles a frame from the editor with id `by`.  receive(raw: string, by: 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, by } };  }}