oficina / slides Public

feat: live-sync updates, stream, presence and snapshots

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 055f75e8602c60fbdb90579ddad4bed9b354a182 parent 3334652
15 files changed, +2077 −38
  • AGENTS.md +28 −15
  • internal/slides/app.go +16 −0
  • internal/slides/decks.go +26 −18
  • internal/slides/editor.go +96 −0
  • internal/slides/handlers.go +20 −5
  • internal/slides/live.go +561 −0
  • internal/slides/live/hub.go +319 −0
  • internal/slides/live/hub_test.go +168 −0
  • internal/slides/locales/en.toml +10 −0
  • internal/slides/migrations/0006_create_deck_snapshots.sql +4 −0
  • internal/slides/migrations/schema.sql +4 −0
  • internal/slides/models.go +37 −0
  • internal/slides/snapshot.go +152 −0
  • internal/slides/snapshot_test.go +125 −0
  • internal/slidestest/live_test.go +511 −0

Intent

Attributed to this commit: 8.69 g CO₂e (1033641 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-2-editor, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/AGENTS.md b/AGENTS.md
index fd18a60..f75a9b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,10 +74,11 @@ person and an agent both.
`internal/slides/` is the app in SKILL.md's five-file shape — `models.go`,
`migrations.go` plus `migrations/`, `app.go`, `handlers.go`, `render.go`
plus `templates/` — with one file per screen area beside them (`auth.go`,
-`decks.go`, `viewer.go`, `share.go`, `presentation.go`, `sample.go`) and
-one package per concern nested underneath: `core` (the wazero host),
-`deck` (Go types for the core's JSON view), `render` (slide HTML),
-`present` (the SSE hub). `core/` at the repo root is the Rust crate.
+`decks.go`, `viewer.go`, `share.go`, `presentation.go`, `sample.go`,
+`live.go`, `editor.go`, `snapshot.go`) and one package per concern nested
+underneath: `core` (the wazero host), `deck` (Go types for the core's JSON
+view), `render` (slide HTML), `present` (the presentation SSE hub), `live`
+(the deck's own hub: seqs and presence). `core/` at the repo root is the Rust crate.
`manifest/` and `gen/` are rastrillo's declarative path, unused here.
## The seams
@@ -100,17 +101,23 @@ operator precedence. `deckFor` is a deck the person may read; `ownedDeckFor`
is one they own. Both answer **404, never 403**, so a stranger holding a
sqid cannot learn whether it is real.
-`openDeck` instantiates the Y.Doc: a fresh wasm module, every stored update
-replayed in seq order, and the JSON view it produces. The handle is the
-caller's to close. There are no snapshots, so this replays the whole log
-every time — fine at today's sizes, and the thing to fix somewhere around a
-thousand updates on one deck.
-
-`applyOps` is the only write path into a document. It puts the core's
-update row, the `seq` bump and any deck-row columns in one transaction with
-the seq as an optimistic lock, so the row and the log commit or roll back
-together. `editDeck` wraps it with the handle's whole life and one retry on
-`errDeckBusy`. Nothing else may append to `deck_updates`.
+`openDeck` instantiates the Y.Doc: a fresh wasm module, the newest
+`deck_snapshots` row applied, every update after it replayed in seq order,
+and the JSON view that produces. The handle is the caller's to close. A
+snapshot is derived data and never the truth (`snapshot.go`) — the log
+alone still is the deck, so a missing or unreadable snapshot means a slower
+open and nothing else.
+
+`applyOps` and `appendUpdate` are the two write paths into a document, and
+the only two. Each puts the update row, the `seq` bump and any deck-row
+columns in one transaction with the seq as an optimistic lock, so the row
+and the log commit or roll back together, then publishes the new seq and
+takes a snapshot if it crossed the boundary. They differ in one line and in
+what a failure of it means: `applyOps` (`editDeck` wraps it with the
+handle's life and one retry on `errDeckBusy`) hands the core an op batch
+this app wrote, so a refusal is a bug; `appendUpdate` (`live.go`) hands it
+bytes off the wire, so a refusal is ordinary and comes back as a 422.
+Nothing else may append to `deck_updates`.
`render.Slide(d, index, viewport)` is the only place slide HTML is
produced, for every surface. It builds markup by hand rather than
@@ -123,6 +130,12 @@ are whole state, never deltas, so a subscriber that falls behind is dropped
and reconnects rather than having events skipped — a skipped event leaves a
projector showing slide 4 for the rest of a talk with nothing to notice it.
+`live.Hub` is the same shape for a deck being edited: a seq, which is
+advisory ("there is something at least this new" — the bytes come from
+`GET /d/{sqid}/updates?since=`), and the whole presence set, which is not.
+Presence lives there and nowhere else — it is not document state, so it
+never reaches the log, is never snapshotted, and is gone with the process.
+
## Traps
- **Never call `sessions.UserID`.** Under rastrillo/auth the Subject is
diff --git a/internal/slides/app.go b/internal/slides/app.go
index 426e519..a819f17 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -12,6 +12,7 @@ import (
"amadan.net/rastrillo/rastrillo/migrate"
"amadan.net/oficina/slides/internal/slides/core"
+ "amadan.net/oficina/slides/internal/slides/live"
"amadan.net/oficina/slides/internal/slides/present"
)
@@ -99,6 +100,20 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
// Every slide's notes on one page — the crib sheet somebody
// presents from with scripts off (spec §8).
r.Get("/d/{sqid}/notes", a.deckNotes)
+ // Live sync (live.go, spec §3.3). All four are the deck's
+ // readers-and-writers — deckFor's audience, not the owner's
+ // alone — because a team deck is edited by the team.
+ //
+ // Inside the group and therefore inside csrf.Protect, unlike
+ // the drive API: these are made by a browser, which sends the
+ // same-origin evidence csrf.Protect looks for.
+ r.Get("/d/{sqid}/updates", a.updatesGet)
+ r.Post("/d/{sqid}/updates", a.updatesPost)
+ r.Get("/d/{sqid}/stream", a.deckStream)
+ r.Post("/d/{sqid}/presence", a.deckPresence)
+ // The slide HTML the editor's stage and strip fetch — the
+ // session-cookie twin of the API's own (editor.go).
+ r.Get("/d/{sqid}/slides/{file}", a.deckSlide)
// Presenting starts here and nowhere else: anybody who can
// open the deck can read it to a room, so this is deckFor's
// audience rather than the owner's alone.
@@ -213,5 +228,6 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) {
core: rt,
auth: authPlugin,
hub: present.New(),
+ live: live.New(),
}, authPlugin, nil
}
diff --git a/internal/slides/decks.go b/internal/slides/decks.go
index 71dd2b7..851f092 100644
--- a/internal/slides/decks.go
+++ b/internal/slides/decks.go
@@ -178,28 +178,33 @@ func (a *app) visibleDecks(ctx context.Context, viewer User) *gorm.DB {
// --- the document ---
-// openDeck instantiates d's Y.Doc: a fresh wasm module, every stored
-// update replayed into it in seq order, and the view it produces.
+// openDeck instantiates d's Y.Doc: a fresh wasm module, the newest
+// snapshot applied, every update after it replayed in seq order, and
+// the view that produces.
//
// The handle is the CALLER's to close. One module per request is
-// deliberate — an instance is not safe for concurrent use, and task 6's
-// hub is where a long-lived one will live.
+// deliberate — an instance is not safe for concurrent use, and a
+// long-lived one is a change to make when there is evidence for it.
//
-// There are no snapshots yet: this replays the whole log every time.
-// That is fine at the sizes this app has today and stops being fine
-// somewhere around a thousand updates on one deck, which is the moment
-// to persist Handle.State as a snapshot row and replay from it.
+// The snapshot is an accelerator and never the truth (snapshot.go): a
+// deck with none, or one whose snapshot row could not be read, opens
+// from the plain log and is exactly the same deck, only slower.
func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, error) {
h, err := a.core.NewDoc(ctx, d.Sqid, serverClientID)
if err != nil {
return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err)
}
- var updates []DeckUpdate
- if err := a.db.G.WithContext(ctx).
- Where("deck_id = ?", d.ID).Order("seq").Find(&updates).Error; err != nil {
+ snap, updates, err := a.replayPlan(ctx, d)
+ if err != nil {
h.Close(ctx)
- return nil, nil, fmt.Errorf("slides: read deck %s log: %w", d.Sqid, err)
+ return nil, nil, err
+ }
+ if snap != nil {
+ if err := h.Apply(ctx, snap.Bytes); err != nil {
+ h.Close(ctx)
+ return nil, nil, fmt.Errorf("slides: replay deck %s snapshot at seq %d: %w", d.Sqid, snap.Seq, err)
+ }
}
for _, u := range updates {
if err := h.Apply(ctx, u.Bytes); err != nil {
@@ -323,12 +328,15 @@ func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor strin
}
d.Seq++
- // After the commit, never inside it: a subscriber told about a seq
- // a rolled-back transaction never wrote would broadcast an update
- // nobody can read back. Nil until task 6 mounts the hub.
- if a.onUpdate != nil {
- a.onUpdate(d.ID, d.Seq)
- }
+ // After the commit, never inside it: a browser told about a seq a
+ // rolled-back transaction never wrote would fetch it and find
+ // nothing, and its cursor would sit one ahead of the deck for the
+ // rest of the session.
+ a.live.PublishSeq(d.Sqid, d.Seq)
+ // Also after the commit, and best effort: h now holds exactly the
+ // document through d.Seq, which is the one thing maybeSnapshot
+ // cannot check for itself.
+ a.maybeSnapshot(ctx, *d, h, d.Seq)
return view, nil
}
diff --git a/internal/slides/editor.go b/internal/slides/editor.go
new file mode 100644
index 0000000..e3442e9
--- /dev/null
+++ b/internal/slides/editor.go
@@ -0,0 +1,96 @@
+// editor.go is the browser editor's own surface. Today it holds one
+// handler: the slide HTML the stage and the strip fetch. The editor
+// page itself lands beside it.
+package slides
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ slidehtml "amadan.net/oficina/slides/internal/slides/render"
+)
+
+// --- GET /d/{sqid}/slides/{id}.html ---
+
+// deckSlide renders one slide as the section the viewer would show it
+// in — the session-cookie twin of the API's apiSlide, for the editor's
+// stage and its thumbnail strip.
+//
+// Two surfaces rather than one shared route because the two are
+// authenticated differently and nothing else about them differs: the
+// API's caller carries a bearer token and the API's own 404 shape, this
+// one carries a session cookie and this app's. The rendering is
+// literally the same call into the same package, which is the invariant
+// that matters (AGENTS.md: render.Slide is the only place slide HTML is
+// produced).
+//
+// It is the SECTION, never a page: the stage swaps this into the DOM,
+// so a layout around it would be a page nested inside a page.
+//
+// vp names the viewport: `phone` reflows (spec §2.5), `desktop` and
+// `thumb` both render desktop. A thumbnail is a desktop slide scaled by
+// CSS rather than a third rendering — the one rule this app has is that
+// a slide is one block tree rendered many ways, and "many ways" is two
+// viewports, not one per place a slide is drawn.
+func (a *app) deckSlide(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.deckFor(w, r, u)
+ if !ok {
+ return
+ }
+ slideID, ok := strings.CutSuffix(chi.URLParam(r, "file"), ".html")
+ if !ok || slideID == "" {
+ a.notFound(w, r)
+ return
+ }
+
+ h, view, err := a.openDeck(r.Context(), d)
+ if err != nil {
+ a.fail(w, r, "open a deck to render a slide", err)
+ return
+ }
+ defer h.Close(r.Context())
+
+ index := -1
+ for i, s := range view.Slides {
+ if s.ID == slideID {
+ index = i
+ break
+ }
+ }
+ if index < 0 {
+ a.notFound(w, r)
+ return
+ }
+
+ vp := editorViewport(r.URL.Query().Get("vp"))
+ section, err := slidehtml.Slide(view, index, vp)
+ if err != nil {
+ a.fail(w, r, "render a slide for the editor", err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ // Seq, slide and viewport: the three things that change what these
+ // bytes are. The stage re-fetches after every structural change, so
+ // this is what makes the fetch that changed nothing cheap.
+ w.Header().Set("ETag", strconv.Quote(strconv.FormatInt(d.Seq, 10)+"-"+slideID+"-"+vp.String()))
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprint(w, section)
+}
+
+// editorViewport maps the ?vp= value onto a rendering. Anything
+// unrecognised is desktop rather than a 400: this is a rendering hint
+// on a read, and a stage that got a typo should show the slide.
+func editorViewport(vp string) slidehtml.Viewport {
+ if vp == "phone" {
+ return slidehtml.Phone
+ }
+ return slidehtml.Desktop
+}
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go
index fb34a49..f9662d5 100644
--- a/internal/slides/handlers.go
+++ b/internal/slides/handlers.go
@@ -8,6 +8,7 @@ import (
"amadan.net/rastrillo/rastrillo/db"
"amadan.net/oficina/slides/internal/slides/core"
+ "amadan.net/oficina/slides/internal/slides/live"
"amadan.net/oficina/slides/internal/slides/present"
)
@@ -44,10 +45,14 @@ type app struct {
// One per app, because a subscriber list nobody else can reach is
// not a fan-out.
hub *present.Hub
- // onUpdate is told, after the transaction commits, that a deck
- // moved to a new seq. Nil until task 6 mounts the hub that
- // broadcasts to open browsers; every caller is nil-safe.
- onUpdate func(deckID, seq int64)
+ // live is the in-process fan-out behind every deck being EDITED:
+ // one accepted update announced as a seq, plus who is currently in
+ // the deck (internal/slides/live). Separate from hub above because
+ // they are separate subjects with separate lifetimes — a talk being
+ // given and a document being written — and folding them into one
+ // map keyed by two kinds of sqid would make every publish a
+ // question about which kind it was.
+ live *live.Hub
}
// Close releases the compiled deck core's wazero runtime — the one
@@ -62,7 +67,17 @@ type app struct {
// cmd/slides/main.go's shutdown path and every test harness call it so
// a compiled module is not leaked per app built.
func (a *app) Close() error {
- if a == nil || a.core == nil {
+ if a == nil {
+ return nil
+ }
+ // The live hub's sweeper is a goroutine, so it is stopped here for
+ // the same reason the runtime is closed here: a test binary builds
+ // one app per test, and a resource nobody releases is a leak per
+ // app rather than per process.
+ if a.live != nil {
+ a.live.Close()
+ }
+ if a.core == nil {
return nil
}
return a.core.Close(context.Background())
diff --git a/internal/slides/live.go b/internal/slides/live.go
new file mode 100644
index 0000000..1bd40a2
--- /dev/null
+++ b/internal/slides/live.go
@@ -0,0 +1,561 @@
+// live.go is the transport a browser edits a deck over: the update log
+// by cursor, one new update in, an advisory stream out, and a heartbeat
+// saying who is in the deck.
+//
+// It is Docs' live-sync design adopted whole (its internal/docs/{
+// handlers.go, stream.go}), with one addition and one difference.
+//
+// The addition is presence, which is NOT document state (spec §3.3): it
+// lives in the hub's memory, never in the log, and is gone when this
+// process is.
+//
+// The difference is framing. Docs concatenates updates, because a Yjs
+// update is self-delimiting and its client feeds the whole blob to one
+// decoder. Slides frames each one as a 4-byte big-endian length then
+// the bytes, so the client can hand them to the core one at a time and
+// so a fixture can compare a frame against the update that produced it
+// (the plan's live-sync wire). Concatenation would make both of those
+// "decode and hope".
+//
+// What never travels on the stream is bytes. The stream says "there is
+// content through seq N" and the client fetches it through the endpoint
+// it already used at mount — one code path from stored update to a
+// browser's Y.Doc rather than two that can drift, and no base64 tax on
+// every keystroke batch.
+package slides
+
+import (
+ "context"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/rastrillo"
+
+ "amadan.net/oficina/slides/internal/slides/core"
+ "amadan.net/oficina/slides/internal/slides/live"
+)
+
+// maxUpdateBytes is the cap on one posted update (the plan's 1 MiB).
+// Generous for a keystroke batch and for a pasted slide; small enough
+// that a client cannot make this instance hold an arbitrary amount of
+// memory by opening one connection.
+const maxUpdateBytes = 1 << 20
+
+// maxPresenceBytes is the cap on one heartbeat body. A heartbeat is
+// four short strings; anything approaching this is not one.
+const maxPresenceBytes = 4 << 10
+
+// maxPresenceField bounds each string the client supplies. Ids in this
+// app are twelve characters (decks.go's newID) and a client id is a
+// browser's own random string, so 128 is roomy — and a cap of some kind
+// is required, because these strings are held in memory for the life of
+// a heartbeat by a request that only had to be signed in.
+const maxPresenceField = 128
+
+// errUpdateRefused is the core looking at the bytes a client posted and
+// saying no: malformed wire bytes, or bytes that trapped the module and
+// were contained. It is the client's problem, so it is a 422 and an
+// Info-level log — accepting arbitrary input over HTTP and occasionally
+// being sent nonsense is the ordinary case, not an incident.
+//
+// Everything else out of appendUpdate — a database failure, a replay of
+// this deck's own history going wrong, a rebuild that itself failed —
+// is a 500, because the client did not cause it and an operator needs
+// to see it.
+var errUpdateRefused = errors.New("slides: the core refused this update")
+
+// --- GET /d/{sqid}/updates ---
+
+// updatesGet serves the deck's log from a cursor: `application/octet-
+// stream`, each update framed as a 4-byte big-endian length then the
+// bytes, in seq order.
+//
+// `Slides-Seq` is the highest seq in the BODY, or the cursor itself
+// when the body is empty — derived from the bytes in hand and never
+// from a second MAX(seq) query. The difference matters: a separate
+// query can see an update this response does not carry, and the client
+// would then record a cursor for bytes it never received and never ask
+// for them again.
+//
+// `Slides-Snapshot: <seq>` says the first frame is a snapshot rather
+// than a log row. A client that does not care can ignore it — a
+// snapshot is a Yjs update like any other and applying it is correct
+// either way — and one that does can tell "here is the deck" from "here
+// is what changed".
+func (a *app) updatesGet(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.deckFor(w, r, u)
+ if !ok {
+ return
+ }
+ since, ok := parseSince(w, r)
+ if !ok {
+ return
+ }
+
+ updates, highest, snapshotSeq, err := a.readUpdates(r.Context(), d, since)
+ if err != nil {
+ a.fail(w, r, "read a deck's log", err)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Slides-Seq", strconv.FormatInt(highest, 10))
+ if snapshotSeq > 0 {
+ w.Header().Set("Slides-Snapshot", strconv.FormatInt(snapshotSeq, 10))
+ }
+
+ var length [4]byte
+ for _, u := range updates {
+ binary.BigEndian.PutUint32(length[:], uint32(len(u)))
+ if _, err := w.Write(length[:]); err != nil {
+ // The client went away mid-response. Logged rather than
+ // surfaced: the status line is already on the wire.
+ a.logger.Error("slides: write the update log", "deck", d.Sqid, "err", err)
+ return
+ }
+ if _, err := w.Write(u); err != nil {
+ a.logger.Error("slides: write the update log", "deck", d.Sqid, "err", err)
+ return
+ }
+ }
+}
+
+// readUpdates is what a cursor is owed: the frames to send, the highest
+// seq they carry, and the seq of the snapshot when one of them is one.
+//
+// The snapshot is only ever offered at since=0. A client mid-session
+// holds a prefix of the log and needs the tail after it; handing it a
+// whole-document update instead would be correct (Yjs would merge it)
+// and wasteful on every single fetch. At since=0 it is the opposite: a
+// deck with two thousand updates behind it would otherwise send all two
+// thousand to every tab that opens it.
+func (a *app) readUpdates(ctx context.Context, d Deck, since int64) (frames [][]byte, highest, snapshotSeq int64, err error) {
+ highest = since
+ tail := since
+
+ if since == 0 {
+ // An accelerator, never the source of truth (snapshot.go): a
+ // lookup that fails serves the plain log rather than failing a
+ // request the log alone can still answer.
+ snap, err := a.latestSnapshot(ctx, d.ID)
+ if err != nil {
+ a.logger.Error("slides: load a deck's newest snapshot, serving the plain log instead",
+ "deck", d.Sqid, "err", err)
+ } else if snap != nil && snap.Seq > since {
+ frames = append(frames, snap.Bytes)
+ snapshotSeq = snap.Seq
+ tail = snap.Seq
+ highest = snap.Seq
+ }
+ }
+
+ var updates []DeckUpdate
+ if err := a.db.G.WithContext(ctx).
+ Where("deck_id = ? AND seq > ?", d.ID, tail).
+ Order("seq").Find(&updates).Error; err != nil {
+ return nil, 0, 0, fmt.Errorf("slides: read deck %s log from %d: %w", d.Sqid, tail, err)
+ }
+ for _, u := range updates {
+ frames = append(frames, u.Bytes)
+ if u.Seq > highest {
+ highest = u.Seq
+ }
+ }
+ return frames, highest, snapshotSeq, nil
+}
+
+// parseSince reads the ?since= cursor: the highest seq the client
+// already holds, so the answer carries only what came after it. Absent
+// means 0, which asks for the whole deck — what a tab wants at mount.
+//
+// A malformed or negative value is a 400 rather than a silent 0.
+// Treating "banana" as "send everything" would answer an expensive
+// question nobody asked and hide a client bug behind a response that
+// looks fine.
+func parseSince(w http.ResponseWriter, r *http.Request) (int64, bool) {
+ raw := r.URL.Query().Get("since")
+ if raw == "" {
+ return 0, true
+ }
+ since, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || since < 0 {
+ http.Error(w, rastrillo.T(r, "slides.live.error.badsince"), http.StatusBadRequest)
+ return 0, false
+ }
+ return since, true
+}
+
+// --- POST /d/{sqid}/updates ---
+
+// updatesPost takes one Yjs update from a browser, applies it through
+// the core, stores it and answers 204 with the seq it landed at.
+//
+// The body is wrapped in MaxBytesReader before anything reads it, so an
+// oversized update is refused before it is buffered into memory at all,
+// never after.
+func (a *app) updatesPost(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.deckFor(w, r, u)
+ if !ok {
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, maxUpdateBytes)
+ update, err := io.ReadAll(r.Body)
+ if err != nil {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ http.Error(w, rastrillo.T(r, "slides.live.error.toolarge"), http.StatusRequestEntityTooLarge)
+ return
+ }
+ a.fail(w, r, "read a posted update", err)
+ return
+ }
+
+ seq, err := a.appendUpdate(r.Context(), &d, actorFor(u), update)
+ if err != nil {
+ if errors.Is(err, errUpdateRefused) {
+ a.logger.Info("slides: refuse a posted update", "deck", d.Sqid, "err", err)
+ http.Error(w, rastrillo.T(r, "slides.live.error.invalid"), http.StatusUnprocessableEntity)
+ return
+ }
+ if errors.Is(err, errDeckBusy) {
+ // Two retries both lost: real contention rather than a
+ // race worth spinning on. The client's own queue reposts,
+ // and Yjs makes a duplicate harmless.
+ http.Error(w, rastrillo.T(r, "slides.live.error.busy"), http.StatusConflict)
+ return
+ }
+ a.fail(w, r, "apply a posted update", err)
+ return
+ }
+
+ // The header before the broadcast, both only on success: an
+ // announced seq must be one a reader can actually fetch back.
+ w.Header().Set("Slides-Seq", strconv.FormatInt(seq, 10))
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// appendUpdate is the second write path into a document, beside
+// applyOps, and it takes the same lock the same way: open the deck,
+// apply the update to the handle, insert the log row at Seq+1 and bump
+// the row's seq in ONE transaction whose `WHERE id = ? AND seq = ?` is
+// the optimistic lock. One retry on errDeckBusy from a freshly opened
+// document, for editDeck's reason — the batch applied nothing, so
+// replaying it against the state that beat us is correct.
+//
+// It exists rather than being folded into applyOps because the two
+// differ in exactly one line and in what a failure of that line MEANS.
+// applyOps hands the core a batch this app wrote, so a refusal is a bug
+// here; appendUpdate hands it bytes off the wire, so a refusal is the
+// ordinary case and has to come back distinguishable (errUpdateRefused)
+// rather than as a 500.
+//
+// d is a pointer because its Seq is updated in place on success and
+// re-read on a conflict, exactly as editDeck does.
+func (a *app) appendUpdate(ctx context.Context, d *Deck, actor string, update []byte) (int64, error) {
+ var lastErr error
+ for attempt := 0; attempt < 2; attempt++ {
+ h, _, err := a.openDeck(ctx, *d)
+ if err != nil {
+ return 0, err
+ }
+ seq, err := a.commitUpdate(ctx, h, d, actor, update)
+ if err == nil {
+ // After the commit, in this order and for the same reasons
+ // applyOps has: nobody is told about a seq a rolled-back
+ // transaction never wrote, and the snapshot is taken from
+ // a handle that holds exactly what the row now claims.
+ a.live.PublishSeq(d.Sqid, seq)
+ a.maybeSnapshot(ctx, *d, h, seq)
+ h.Close(ctx)
+ return seq, nil
+ }
+ h.Close(ctx)
+
+ lastErr = err
+ if !errors.Is(err, errDeckBusy) {
+ return 0, err
+ }
+ // Re-read the row: our copy's Seq is what just lost.
+ if err := a.db.G.WithContext(ctx).First(d, d.ID).Error; err != nil {
+ return 0, fmt.Errorf("slides: reload deck %s: %w", d.Sqid, err)
+ }
+ }
+ return 0, lastErr
+}
+
+// commitUpdate is appendUpdate's one attempt: applyOps' transaction
+// with h.Apply where h.Ops would be.
+//
+// The same honest wrinkle applies as in applyOps: Apply runs INSIDE the
+// transaction, so a rollback leaves the handle holding a change the
+// database does not. That is harmless because the handle is
+// per-attempt and discarded on failure, and it is why appendUpdate
+// reopens rather than retrying on the same handle.
+func (a *app) commitUpdate(ctx context.Context, h *core.Handle, d *Deck, actor string, update []byte) (int64, error) {
+ next := d.Seq + 1
+ err := a.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := h.Apply(ctx, update); err != nil {
+ if core.Rejected(err) || core.Recovered(err) {
+ return fmt.Errorf("%w: %w", errUpdateRefused, err)
+ }
+ // A rebuild that itself failed, or a full retained log:
+ // neither is the client's doing, and the second means this
+ // deck needs a snapshot before it can take anything else.
+ return fmt.Errorf("slides: apply a posted update to deck %s: %w", d.Sqid, err)
+ }
+
+ if err := tx.Create(&DeckUpdate{
+ DeckID: d.ID,
+ Seq: next,
+ Actor: actor,
+ Bytes: update,
+ CreatedAt: time.Now().UTC(),
+ }).Error; err != nil {
+ if errors.Is(err, gorm.ErrDuplicatedKey) {
+ // Somebody else's update is already at this seq: the
+ // lost race, reported as the lost race.
+ return errDeckBusy
+ }
+ return fmt.Errorf("slides: record deck %s update %d: %w", d.Sqid, next, err)
+ }
+
+ res := tx.Model(&Deck{}).Where("id = ? AND seq = ?", d.ID, d.Seq).
+ Updates(map[string]any{"seq": next})
+ if res.Error != nil {
+ return fmt.Errorf("slides: bump deck %s seq: %w", d.Sqid, res.Error)
+ }
+ if res.RowsAffected != 1 {
+ return errDeckBusy
+ }
+ return nil
+ })
+ if err != nil {
+ return 0, err
+ }
+ d.Seq = next
+ return next, nil
+}
+
+// --- GET /d/{sqid}/stream ---
+
+// deckStream is the deck's SSE feed: `event: update` carrying a seq and
+// no bytes, `event: presence` carrying the whole set as JSON, a comment
+// ping in between, and one of each on connect.
+//
+// The timings are presentation.go's, shared rather than restated
+// (streamHeartbeat, streamWriteDeadline, streamTTL): the reasons are
+// the same reasons, and two apps' worth of streams in one binary
+// behaving differently would be a surprise nobody asked for.
+//
+// The connect events are load-bearing rather than an optimisation. The
+// update one heals a tab whose cursor legitimately failed to advance
+// and covers the gap between the page rendering and this stream
+// opening; the presence one tells a tab who is already here, rather
+// than leaving it to find out as each of them happens to move.
+func (a *app) deckStream(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.deckFor(w, r, u)
+ if !ok {
+ return
+ }
+ fl, ok := w.(http.Flusher)
+ if !ok {
+ a.fail(w, r, "stream: the ResponseWriter does not flush", nil)
+ return
+ }
+
+ // Subscribe BEFORE re-reading the seq, never after (Docs' note): an
+ // update committing between the two must produce a redundant
+ // notification, which costs one empty fetch, where the other order
+ // would let it fall into the gap and leave the tab looking current
+ // and being stale until somebody typed.
+ ch, stop := a.live.Subscribe(d.Sqid)
+ defer stop()
+
+ var current Deck
+ if err := a.db.G.WithContext(r.Context()).Take(&current, d.ID).Error; err != nil {
+ a.fail(w, r, "read a deck for the stream", err)
+ return
+ }
+
+ rc := http.NewResponseController(w)
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusOK)
+ fl.Flush()
+
+ // write is every byte's one exit: deadline armed, bytes, flush. The
+ // deadline error is ignored deliberately — a ResponseWriter that
+ // cannot set deadlines still streams, unbounded — while a write
+ // error ends the handler, because it means the client is gone.
+ write := func(text string) bool {
+ _ = rc.SetWriteDeadline(time.Now().Add(streamWriteDeadline))
+ if _, err := io.WriteString(w, text); err != nil {
+ return false
+ }
+ fl.Flush()
+ return true
+ }
+ seqEvent := func(seq int64) bool {
+ return write("event: update\ndata: " + strconv.FormatInt(seq, 10) + "\n\n")
+ }
+ presenceEvent := func(set live.PresenceSet) bool {
+ // A nil set must go out as `[]`, not `null`: the client reads
+ // this as "everybody here", and null is a different sentence.
+ if set == nil {
+ set = live.PresenceSet{}
+ }
+ body, err := json.Marshal(set)
+ if err != nil {
+ a.logger.Error("slides: encode presence", "deck", d.Sqid, "err", err)
+ return false
+ }
+ return write("event: presence\ndata: " + string(body) + "\n\n")
+ }
+
+ if !seqEvent(current.Seq) {
+ return
+ }
+ if !presenceEvent(a.live.Snapshot(d.Sqid)) {
+ return
+ }
+
+ heartbeat := time.NewTimer(streamHeartbeat)
+ defer heartbeat.Stop()
+ ttl := time.NewTimer(streamTTL)
+ defer ttl.Stop()
+
+ for {
+ select {
+ case <-r.Context().Done():
+ // The client went away, or the server is draining. Either
+ // way this returns, so a graceful shutdown's budget is not
+ // held open by a parked tab.
+ return
+ case <-ttl.C:
+ // A clean close: the browser reopens if it still wants one,
+ // and a tab nobody is looking at does not.
+ return
+ case e, ok := <-ch:
+ if !ok {
+ // Dropped for falling behind. Ending the response is
+ // the right answer: EventSource reconnects, and the
+ // connect events make it right again immediately.
+ return
+ }
+ sent := false
+ if e.Presence != nil {
+ sent = presenceEvent(*e.Presence)
+ } else {
+ sent = seqEvent(e.Seq)
+ }
+ if !sent {
+ return
+ }
+ heartbeat.Reset(streamHeartbeat)
+ case <-heartbeat.C:
+ if !write(": ping\n\n") {
+ return
+ }
+ heartbeat.Reset(streamHeartbeat)
+ }
+ }
+}
+
+// --- POST /d/{sqid}/presence ---
+
+// deckPresence is one tab saying "I am here, on this slide, in this
+// block". The client posts every twenty seconds and on every change
+// (spec §3.3); two missed beats and the hub's sweeper drops it.
+//
+// Name and Colour are stamped from the users row and are NOT read from
+// the body. Anything else would let anybody signed in appear in a deck
+// wearing a colleague's name, which is the whole value of presence
+// inverted.
+//
+// Nothing here is persisted. Presence is not document state: it never
+// reaches the update log, it is never snapshotted, and it is gone when
+// this process is.
+func (a *app) deckPresence(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.deckFor(w, r, u)
+ if !ok {
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, maxPresenceBytes)
+ var body struct {
+ Client string `json:"client"`
+ Slide string `json:"slide"`
+ Block string `json:"block"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ http.Error(w, rastrillo.T(r, "slides.live.error.toolarge"), http.StatusRequestEntityTooLarge)
+ return
+ }
+ liveInvalid(w, r)
+ return
+ }
+ // A heartbeat with no client to key on would be filed under the
+ // empty string, where every tab in the deck would overwrite every
+ // other. Refused rather than guessed at.
+ if body.Client == "" || len(body.Client) > maxPresenceField ||
+ len(body.Slide) > maxPresenceField || len(body.Block) > maxPresenceField {
+ liveInvalid(w, r)
+ return
+ }
+
+ name := u.Name
+ if name == "" {
+ name = u.Email
+ }
+ a.live.PublishPresence(d.Sqid, live.Presence{
+ Client: body.Client,
+ Name: name,
+ Colour: u.Colour,
+ Slide: body.Slide,
+ Block: body.Block,
+ At: time.Now().UTC(),
+ })
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// liveInvalid is the 422 a JSON caller on this surface gets: a body
+// shaped like every other JSON error this app writes, so a client has
+// one shape to read rather than two. The binary update endpoint answers
+// the same status as plain text, because its caller is posting bytes
+// and has no JSON decoder in the path at all.
+func liveInvalid(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "error": "invalid",
+ "message": rastrillo.T(r, "slides.live.error.invalid"),
+ })
+}
diff --git a/internal/slides/live/hub.go b/internal/slides/live/hub.go
new file mode 100644
index 0000000..4b4382f
--- /dev/null
+++ b/internal/slides/live/hub.go
@@ -0,0 +1,319 @@
+// Package live is the fan-out behind a deck being edited: one accepted
+// update announced to every browser that has the deck open, and the set
+// of people currently in it.
+//
+// It is Docs' hub (its internal/docs/hub.go) with presence bolted on,
+// and the two things it carries are carried differently on purpose.
+//
+// A SEQ is advisory — "there is something at least this new". It never
+// says what changed, and a subscriber must not treat it as a cursor
+// assignment: the bytes come from GET /d/{sqid}/updates?since=<its own
+// cursor>, through the one replay path, so a subscriber that missed ten
+// notifications is still exactly right after one fetch.
+//
+// A PRESENCE event is the whole set for that deck, never a delta, for
+// present.Hub's reason: a subscriber that applies one is right rather
+// than approximately right, and there is no "fetch the presence I
+// missed" endpoint to make a skipped one safe.
+//
+// The two together decide the channel's shape. It is buffered eight —
+// a burst of keystroke batches is a real thing — and a subscriber that
+// falls behind that is DROPPED, channel closed, rather than having
+// events skipped. The handler holding it ends the response, the browser
+// reconnects, and its connect events re-read both the current seq and
+// the current set. Skipping would leave a stale cursor look correct.
+//
+// Presence lives here and nowhere else: it is not document state (spec
+// §3.3), so it is never written to the update log, never snapshotted,
+// and is gone when this process is.
+//
+// Scope, like Docs' and like present.Hub's: one process. Slides is one
+// instance per team, so there is no second process to reach; this type
+// is the seam that changes on the day there is.
+package live
+
+import (
+ "sort"
+ "sync"
+ "time"
+)
+
+// TTL is how long one person's presence survives without a heartbeat.
+// The client posts every 20 s and on every change (spec §3.3), so this
+// is two missed beats plus a little slack — long enough that a phone
+// switching networks does not flicker out of the deck, short enough
+// that somebody who closed their laptop stops being shown as present
+// while their colleague waits for a reply.
+const TTL = 45 * time.Second
+
+// sweepInterval is how often the sweeper looks for expired presences.
+// A third of TTL, so somebody who has gone is shown as gone within
+// about fifteen seconds of the deadline rather than at the next event
+// somebody else happens to cause.
+const sweepInterval = 15 * time.Second
+
+// subscriberBuffer is how many events a subscriber may fall behind by
+// before it is dropped. See the package comment for why it is dropped
+// rather than skipped.
+const subscriberBuffer = 8
+
+// Presence is one browser tab in one deck at one moment.
+//
+// Client is the tab's own random string, minted per tab rather than
+// per person: one person with the deck open twice is two cursors, and
+// keying on the user id would make the second tab overwrite the first.
+//
+// Name and Colour are stamped by the SERVER from the users row, never
+// taken from the request body — otherwise anybody signed in could
+// appear in a deck under somebody else's name.
+//
+// At is when the last heartbeat landed, and it is what the sweeper
+// reads. It carries no JSON tag because it is bookkeeping: the browser
+// is told who is here, not when each of them last said so.
+type Presence struct {
+ Client string `json:"client"`
+ Name string `json:"name"`
+ Colour string `json:"colour"`
+ // Slide is the id of the slide this tab is looking at, and Block
+ // the id of the block its caret is in. Both may be empty — a tab
+ // that has just opened the deck is present without being anywhere
+ // in particular yet.
+ Slide string `json:"slide"`
+ Block string `json:"block"`
+ At time.Time `json:"-"`
+}
+
+// PresenceSet is everybody currently in one deck, ordered by Client so
+// two reads of an unchanged set produce byte-identical JSON — which is
+// what lets a client diff cheaply and what makes the tests here able to
+// say "the set did not change" at all.
+type PresenceSet []Presence
+
+// Event is what a subscriber receives. Exactly one of the two halves is
+// meaningful: a nil Presence makes it a seq announcement, a non-nil one
+// makes it a presence announcement whose Seq is not to be read.
+//
+// A pointer rather than a length check, because an EMPTY set is a real
+// event — it is what "the last person left" looks like — and a
+// nil-versus-empty slice distinction is exactly the kind that gets
+// flattened by an innocent refactor.
+type Event struct {
+ Seq int64
+ Presence *PresenceSet
+}
+
+// Hub is the per-process fan-out, keyed by deck sqid.
+//
+// Keyed by sqid rather than by deck id because that is what a URL, a
+// handler and a browser all already hold; nothing here ever needs to
+// touch the database, so there is nothing to look an id up against.
+type Hub struct {
+ mu sync.Mutex
+ subs map[string]map[chan Event]struct{}
+ // people is deck sqid -> client -> that client's last heartbeat.
+ people map[string]map[string]Presence
+ // now is the clock, swappable by this package's own tests so
+ // expiry is asserted by moving time rather than by waiting for it.
+ now func() time.Time
+
+ stop chan struct{}
+ stopOnce sync.Once
+}
+
+// New builds an empty hub and starts its sweeper. One per app (app.go).
+//
+// The sweeper is a goroutine rather than work done lazily on the next
+// event, because the event that matters most is the one nobody causes:
+// the last person in a deck closing their laptop. Lazily, their cursor
+// would sit on somebody else's screen until that person typed.
+//
+// Close stops it. Every caller that builds a Hub must, or a test binary
+// leaks one goroutine per app it builds.
+func New() *Hub {
+ h := &Hub{
+ subs: make(map[string]map[chan Event]struct{}),
+ people: make(map[string]map[string]Presence),
+ now: time.Now,
+ stop: make(chan struct{}),
+ }
+ go h.sweeper(sweepInterval)
+ return h
+}
+
+// Close stops the sweeper. Idempotent, because the app that owns a hub
+// may be closed by both a shutdown path and a test cleanup.
+func (h *Hub) Close() {
+ h.stopOnce.Do(func() { close(h.stop) })
+}
+
+// Subscribe registers interest in one deck and returns the channel to
+// wait on plus the function that unregisters it.
+//
+// The returned cancel is idempotent — the stream handler defers it and
+// may also have reason to stop early — and it never closes the channel.
+// Only the publish path closes one, and only when it is dropping the
+// subscriber, so "the channel is closed" carries exactly one meaning
+// for a reader: you fell behind, reconnect. A cancel that closed too
+// would make that signal ambiguous at the one moment it matters.
+func (h *Hub) Subscribe(deck string) (<-chan Event, func()) {
+ ch := make(chan Event, subscriberBuffer)
+
+ h.mu.Lock()
+ if h.subs[deck] == nil {
+ h.subs[deck] = make(map[chan Event]struct{})
+ }
+ h.subs[deck][ch] = struct{}{}
+ h.mu.Unlock()
+
+ var once sync.Once
+ return ch, func() {
+ once.Do(func() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.removeLocked(deck, ch)
+ })
+ }
+}
+
+// PublishSeq tells everyone watching deck that there is content through
+// seq. It runs on whichever request goroutine just committed an update,
+// so it never blocks.
+func (h *Hub) PublishSeq(deck string, seq int64) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.publishLocked(deck, Event{Seq: seq})
+}
+
+// PublishPresence records one heartbeat and announces the deck's whole
+// set. A zero At is stamped here rather than refused, so a caller that
+// only has {client, slide, block} to hand cannot accidentally publish a
+// presence that the sweeper reads as forty-five seconds stale.
+func (h *Hub) PublishPresence(deck string, p Presence) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if p.At.IsZero() {
+ p.At = h.now()
+ }
+ if h.people[deck] == nil {
+ h.people[deck] = make(map[string]Presence)
+ }
+ h.people[deck][p.Client] = p
+
+ set := h.setLocked(deck)
+ h.publishLocked(deck, Event{Presence: &set})
+}
+
+// Snapshot is the deck's current set, for the stream's connect event —
+// so a tab that opens after everybody else already arrived sees them,
+// rather than waiting for each of them to move.
+//
+// Expired entries are filtered here as well as swept, because a read
+// must never be able to report somebody as present past their deadline
+// just because the sweeper's next tick has not come round yet.
+func (h *Hub) Snapshot(deck string) PresenceSet {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.setLocked(deck)
+}
+
+// sweeper is the goroutine New starts.
+func (h *Hub) sweeper(every time.Duration) {
+ t := time.NewTicker(every)
+ defer t.Stop()
+ for {
+ select {
+ case <-h.stop:
+ return
+ case <-t.C:
+ h.sweep()
+ }
+ }
+}
+
+// sweep drops every presence past TTL and republishes the set for each
+// deck that actually changed — and only those, because an unchanged
+// deck republished every fifteen seconds would be a heartbeat pretending
+// to be an event, waking every open tab for nothing.
+func (h *Hub) sweep() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ cut := h.now().Add(-TTL)
+ for deck, set := range h.people {
+ changed := false
+ for client, p := range set {
+ if p.At.Before(cut) {
+ delete(set, client)
+ changed = true
+ }
+ }
+ if len(set) == 0 {
+ // Drop the inner map with its last member, or this map
+ // keeps one empty entry per deck ever opened for the life
+ // of the process.
+ delete(h.people, deck)
+ }
+ if !changed {
+ continue
+ }
+ announced := h.setLocked(deck)
+ h.publishLocked(deck, Event{Presence: &announced})
+ }
+}
+
+// setLocked builds deck's set: a sorted copy with expired entries left
+// out. Callers hold h.mu.
+func (h *Hub) setLocked(deck string) PresenceSet {
+ cut := h.now().Add(-TTL)
+ set := make(PresenceSet, 0, len(h.people[deck]))
+ for _, p := range h.people[deck] {
+ if p.At.Before(cut) {
+ continue
+ }
+ set = append(set, p)
+ }
+ sort.Slice(set, func(i, j int) bool { return set[i].Client < set[j].Client })
+ return set
+}
+
+// publishLocked sends e to everyone watching deck. Callers hold h.mu,
+// which is what makes the close below safe: no send can race it.
+func (h *Hub) publishLocked(deck string, e Event) {
+ for ch := range h.subs[deck] {
+ select {
+ case ch <- e:
+ default:
+ h.removeLocked(deck, ch)
+ close(ch)
+ }
+ }
+}
+
+// removeLocked takes ch out of deck's set and drops the set with its
+// last member. Callers hold h.mu.
+func (h *Hub) removeLocked(deck string, ch chan Event) {
+ subs, ok := h.subs[deck]
+ if !ok {
+ return
+ }
+ delete(subs, ch)
+ if len(subs) == 0 {
+ delete(h.subs, deck)
+ }
+}
+
+// deckCount and presenceDecks report how many decks the two maps hold.
+// They exist for the tests that assert neither grows forever; nothing
+// in the app reads them.
+func (h *Hub) deckCount() int {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return len(h.subs)
+}
+
+func (h *Hub) presenceDecks() int {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return len(h.people)
+}
diff --git a/internal/slides/live/hub_test.go b/internal/slides/live/hub_test.go
new file mode 100644
index 0000000..4d6d9de
--- /dev/null
+++ b/internal/slides/live/hub_test.go
@@ -0,0 +1,168 @@
+package live
+
+import (
+ "testing"
+ "time"
+)
+
+// drain reads every event already queued on ch without blocking.
+func drain(ch <-chan Event) []Event {
+ var out []Event
+ for {
+ select {
+ case e, ok := <-ch:
+ if !ok {
+ return out
+ }
+ out = append(out, e)
+ default:
+ return out
+ }
+ }
+}
+
+// next takes the one event a test just caused, failing rather than
+// hanging when the hub said nothing.
+func next(t *testing.T, ch <-chan Event) Event {
+ t.Helper()
+ select {
+ case e, ok := <-ch:
+ if !ok {
+ t.Fatal("the subscriber was dropped")
+ }
+ return e
+ default:
+ t.Fatal("no event")
+ }
+ return Event{}
+}
+
+// TestSeqIsAdvisoryAndOnlyReachesItsOwnDeck: a published seq goes to
+// everyone watching that deck and to nobody watching another one.
+func TestSeqIsAdvisoryAndOnlyReachesItsOwnDeck(t *testing.T) {
+ h := New()
+ defer h.Close()
+
+ mine, stop := h.Subscribe("deck-a")
+ defer stop()
+ theirs, stopTheirs := h.Subscribe("deck-b")
+ defer stopTheirs()
+
+ h.PublishSeq("deck-a", 7)
+
+ e := next(t, mine)
+ if e.Seq != 7 || e.Presence != nil {
+ t.Errorf("the subscriber heard %+v, want seq 7 and no presence", e)
+ }
+ if got := drain(theirs); len(got) != 0 {
+ t.Errorf("a subscriber on another deck heard %+v", got)
+ }
+}
+
+// TestPresenceIsAnnouncedAndExpires: posting presence publishes the
+// WHOLE set for that deck, and a person who stops heartbeating is
+// swept out and the remaining set republished.
+func TestPresenceIsAnnouncedAndExpires(t *testing.T) {
+ h := New()
+ defer h.Close()
+
+ now := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC)
+ h.now = func() time.Time { return now }
+
+ ch, stop := h.Subscribe("deck-a")
+ defer stop()
+
+ h.PublishPresence("deck-a", Presence{Client: "tab-1", Name: "Paul", Colour: "#111", Slide: "s1"})
+ e := next(t, ch)
+ if e.Presence == nil {
+ t.Fatalf("presence published a seq event: %+v", e)
+ }
+ if set := *e.Presence; len(set) != 1 || set[0].Client != "tab-1" || set[0].Name != "Paul" {
+ t.Fatalf("the announced set is %+v, want one entry for tab-1", set)
+ }
+
+ // A second tab: the set, never a delta.
+ h.PublishPresence("deck-a", Presence{Client: "tab-2", Name: "Sam", Colour: "#222"})
+ if set := *next(t, ch).Presence; len(set) != 2 {
+ t.Fatalf("the announced set is %+v, want both tabs", set)
+ }
+
+ // tab-1 keeps heartbeating; tab-2 goes quiet.
+ now = now.Add(30 * time.Second)
+ h.PublishPresence("deck-a", Presence{Client: "tab-1", Name: "Paul", Colour: "#111", Slide: "s2"})
+ drain(ch)
+
+ // Past two missed heartbeats the sweeper drops tab-2 and says so.
+ now = now.Add(20 * time.Second)
+ h.sweep()
+ e = next(t, ch)
+ if e.Presence == nil {
+ t.Fatalf("the sweep published a seq event: %+v", e)
+ }
+ set := *e.Presence
+ if len(set) != 1 || set[0].Client != "tab-1" {
+ t.Fatalf("after the sweep the set is %+v, want tab-1 alone", set)
+ }
+ if got := h.Snapshot("deck-a"); len(got) != 1 || got[0].Slide != "s2" {
+ t.Errorf("Snapshot is %+v, want tab-1 on s2", got)
+ }
+
+ // A sweep that changes nothing says nothing.
+ h.sweep()
+ if got := drain(ch); len(got) != 0 {
+ t.Errorf("an idle sweep published %+v", got)
+ }
+
+ // Everyone gone: the deck is forgotten rather than kept as an
+ // empty entry for the life of the process.
+ now = now.Add(time.Minute)
+ h.sweep()
+ if set := *next(t, ch).Presence; len(set) != 0 {
+ t.Errorf("the final sweep announced %+v, want an empty set", set)
+ }
+ if n := h.presenceDecks(); n != 0 {
+ t.Errorf("the hub still holds presence for %d decks, want 0", n)
+ }
+}
+
+// TestSubscriberThatFallsBehindIsDropped: presence is whole state, so
+// a reader that cannot keep up is closed and left to reconnect rather
+// than fed a set with holes in it.
+func TestSubscriberThatFallsBehindIsDropped(t *testing.T) {
+ h := New()
+ defer h.Close()
+
+ ch, stop := h.Subscribe("deck-a")
+ defer stop()
+ for i := 0; i < subscriberBuffer+2; i++ {
+ h.PublishSeq("deck-a", int64(i+1))
+ }
+ for i := 0; i < subscriberBuffer; i++ {
+ if _, ok := <-ch; !ok {
+ t.Fatalf("the subscriber closed after %d events, want %d buffered first", i, subscriberBuffer)
+ }
+ }
+ if _, ok := <-ch; ok {
+ t.Fatal("a subscriber that fell behind is still open")
+ }
+ if n := h.deckCount(); n != 0 {
+ t.Errorf("the dropped subscriber left %d decks behind", n)
+ }
+}
+
+// TestUnsubscribeForgetsTheDeck: the subscriber map must not grow one
+// empty entry per deck ever opened.
+func TestUnsubscribeForgetsTheDeck(t *testing.T) {
+ h := New()
+ defer h.Close()
+
+ _, stop := h.Subscribe("deck-a")
+ if n := h.deckCount(); n != 1 {
+ t.Fatalf("deckCount = %d after one subscribe, want 1", n)
+ }
+ stop()
+ stop() // idempotent
+ if n := h.deckCount(); n != 0 {
+ t.Errorf("deckCount = %d after unsubscribing, want 0", n)
+ }
+}
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml
index 81ecb42..429e08c 100644
--- a/internal/slides/locales/en.toml
+++ b/internal/slides/locales/en.toml
@@ -273,3 +273,13 @@ slides.remote.pair.error = "That code and key do not go together. Check both, or
# moves them.
slides.follow.offer = "Follow the presenter"
slides.follow.browse = "Browse on your own"
+
+# slides.live.* — the live-sync transport (live.go). These four are
+# read by a script rather than by a person, so each is written for the
+# person who WILL read it: whoever has the network tab open when
+# something has gone wrong. Plain sentences, no codes, and never a
+# reason the client cannot act on.
+slides.live.error.badsince = "That cursor isn't a number. Reload the page."
+slides.live.error.toolarge = "That change is too large to save in one piece."
+slides.live.error.invalid = "This change couldn't be applied. Reload the page and try again."
+slides.live.error.busy = "Somebody else saved at the same moment. Try again."
diff --git a/internal/slides/migrations/0006_create_deck_snapshots.sql b/internal/slides/migrations/0006_create_deck_snapshots.sql
new file mode 100644
index 0000000..521f382
--- /dev/null
+++ b/internal/slides/migrations/0006_create_deck_snapshots.sql
@@ -0,0 +1,4 @@
+CREATE TABLE `deck_snapshots` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`seq` integer,`bytes` blob,`created_at` datetime);
+
+CREATE INDEX `idx_deck_snapshots_deck_id` ON `deck_snapshots`(`deck_id`);
+
diff --git a/internal/slides/migrations/schema.sql b/internal/slides/migrations/schema.sql
index 5a1e998..a65a53b 100644
--- a/internal/slides/migrations/schema.sql
+++ b/internal/slides/migrations/schema.sql
@@ -5,6 +5,8 @@ CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text,`opera
CREATE TABLE `deck_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`user_id` integer,`actor` text,`created_at` datetime);
+CREATE TABLE `deck_snapshots` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`seq` integer,`bytes` blob,`created_at` datetime);
+
CREATE TABLE `deck_updates` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`seq` integer,`actor` text,`bytes` blob,`created_at` datetime);
CREATE TABLE `decks` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text,`title` text,`owner_id` integer,`rung` text,`theme` text,`seq` integer,`created_at` datetime,`updated_at` datetime);
@@ -23,6 +25,8 @@ CREATE INDEX `idx_deck_grants_deck_id` ON `deck_grants`(`deck_id`);
CREATE INDEX `idx_deck_grants_user_id` ON `deck_grants`(`user_id`);
+CREATE INDEX `idx_deck_snapshots_deck_id` ON `deck_snapshots`(`deck_id`);
+
CREATE UNIQUE INDEX `idx_deck_updates_deck_seq` ON `deck_updates`(`deck_id`,`seq`);
CREATE INDEX `idx_decks_owner_id` ON `decks`(`owner_id`);
diff --git a/internal/slides/models.go b/internal/slides/models.go
index ace5687..8f5ae40 100644
--- a/internal/slides/models.go
+++ b/internal/slides/models.go
@@ -11,6 +11,7 @@ var Models = []any{
&User{},
&Deck{},
&DeckUpdate{},
+ &DeckSnapshot{},
&DeckGrant{},
&ShareLink{},
&Presentation{},
@@ -113,6 +114,42 @@ type DeckUpdate struct {
CreatedAt time.Time
}
+// DeckSnapshot is one deck's whole document at one seq, stored as a
+// single Yjs update (core.Handle.State) so a later open can start from
+// it instead of from the first row of the log.
+//
+// It is DERIVED DATA, and every decision here follows from that (Docs'
+// snapshots ruling, adopted). The log is what the deck is: delete every
+// row of this table and nothing is lost but time. So a snapshot is
+// written best-effort AFTER the update that crossed the boundary has
+// committed (snapshot.go's maybeSnapshot), a failure to write one is
+// logged and never fails the request, and a snapshot that cannot be
+// read is skipped in favour of replaying the plain log.
+//
+// Seq is the highest seq the bytes contain, and openDeck trusts it: it
+// replays the rows at seq > Seq and no others. Nothing here can check
+// that claim, so the ONE caller allowed to write a row is the one
+// holding a handle it knows the seq of.
+//
+// Bytes rather than a path: a deck's state is kilobytes, this is one
+// row per two hundred updates, and superseded rows are deleted as each
+// new one lands, so the table holds one row per deck in the steady
+// state.
+//
+// (deck_id, seq) is deliberately NOT unique. Two writers crossing the
+// boundary together produce equivalent rows at the same seq, and that
+// is an ordinary outcome rather than a collision to resolve.
+type DeckSnapshot struct {
+ ID int64
+ DeckID int64 `gorm:"index"`
+ Seq int64
+ Bytes []byte
+ // CreatedAt is when the snapshot was taken, not when the deck last
+ // moved: a snapshot is bookkeeping, and the deck's own UpdatedAt is
+ // the column anything user-facing reads.
+ CreatedAt time.Time
+}
+
// DeckGrant names one person who may open one deck — spec §5's
// `granted` rung.
//
diff --git a/internal/slides/snapshot.go b/internal/slides/snapshot.go
new file mode 100644
index 0000000..7ecda2b
--- /dev/null
+++ b/internal/slides/snapshot.go
@@ -0,0 +1,152 @@
+// snapshot.go is the accelerator under openDeck: a deck's whole
+// document written down every so often, so opening it replays a short
+// tail instead of every update since it was made.
+//
+// The rule the whole file is built on, and the one to keep if anything
+// here is ever rewritten: THE LOG IS THE DECK. A snapshot is derived
+// data (models.go's DeckSnapshot, Docs' snapshots ruling). Every path
+// in this file is therefore allowed to fail without failing the request
+// that reached it, and openDeck falls back to the plain log when it
+// does.
+package slides
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/slides/internal/slides/core"
+)
+
+// snapshotCadence is how many updates may accumulate before the next
+// snapshot is taken: the request whose seq lands on a multiple of this
+// writes one (maybeSnapshot).
+//
+// 200 is the plan's number, and the tail it leaves — at most 199
+// updates to replay — sits about twenty times clear of
+// core.MaxRetainedUpdates (4096), which is the hard wall a handle hits
+// when it has replayed too much to rebuild itself. That headroom is
+// conditional on snapshot writes actually succeeding; each failure is
+// logged at Error for exactly that reason.
+const snapshotCadence = 200
+
+// latestSnapshot returns the newest usable snapshot for deckID, or nil
+// when there is none.
+//
+// Ordering is seq DESC, id DESC: two writers crossing the boundary
+// together may legitimately write equivalent rows at the same seq, so
+// the tie resolves to the last row written rather than being treated as
+// an error worth reporting.
+func (a *app) latestSnapshot(ctx context.Context, deckID int64) (*DeckSnapshot, error) {
+ var row DeckSnapshot
+ err := a.db.G.WithContext(ctx).
+ Where("deck_id = ?", deckID).
+ Order("seq DESC, id DESC").
+ Take(&row).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("slides: newest snapshot for deck %d: %w", deckID, err)
+ }
+ return &row, nil
+}
+
+// replayPlan is what openDeck has to apply to reconstitute d: the
+// newest snapshot (or nil) and every update after it, in seq order.
+//
+// It is one function rather than two calls at the call site so that the
+// invariant tying them together — the tail starts exactly where the
+// snapshot ends — is written once and can be tested on its own
+// (snapshot_test.go).
+//
+// A snapshot that cannot be read is not an error here. The log alone
+// still answers the question, so the failure is returned to the caller
+// as "no snapshot" plus a log line, and the deck opens slowly rather
+// than not at all.
+func (a *app) replayPlan(ctx context.Context, d Deck) (*DeckSnapshot, []DeckUpdate, error) {
+ snap, err := a.latestSnapshot(ctx, d.ID)
+ if err != nil {
+ a.logger.Error("slides: read a deck's newest snapshot, replaying the plain log instead",
+ "deck", d.Sqid, "err", err)
+ snap = nil
+ }
+
+ var since int64
+ if snap != nil {
+ since = snap.Seq
+ }
+ var updates []DeckUpdate
+ if err := a.db.G.WithContext(ctx).
+ Where("deck_id = ? AND seq > ?", d.ID, since).
+ Order("seq").Find(&updates).Error; err != nil {
+ return nil, nil, fmt.Errorf("slides: read deck %s log: %w", d.Sqid, err)
+ }
+ return snap, updates, nil
+}
+
+// maybeSnapshot writes a snapshot when seq lands on the cadence
+// boundary, and does nothing at all otherwise.
+//
+// Two things it is the caller's job to have true, because nothing here
+// can check either:
+//
+// - h holds the document THROUGH seq and no further. Every caller
+// applies its update to h and commits it before calling this, so
+// the number on the row is the number in the bytes.
+// - The transaction that wrote seq has COMMITTED. A snapshot taken
+// from a rolled-back state would be a copy of a deck that never
+// existed, and openDeck would then skip the real rows it claims to
+// cover.
+//
+// Every failure is logged and swallowed. This runs after the update the
+// person actually made is safely on disk; failing their request because
+// the accelerator could not be written would be trading the thing that
+// matters for the thing that does not.
+func (a *app) maybeSnapshot(ctx context.Context, d Deck, h *core.Handle, seq int64) {
+ if seq <= 0 || seq%snapshotCadence != 0 {
+ return
+ }
+
+ state, err := h.State(ctx)
+ if err != nil {
+ a.logger.Error("slides: encode a deck snapshot", "deck", d.Sqid, "seq", seq, "err", err)
+ return
+ }
+
+ row := &DeckSnapshot{
+ DeckID: d.ID,
+ Seq: seq,
+ Bytes: state,
+ CreatedAt: time.Now().UTC(),
+ }
+ err = a.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(row).Error; err != nil {
+ return fmt.Errorf("slides: persist a snapshot for deck %s at seq %d: %w", d.Sqid, seq, err)
+ }
+ // Superseded snapshots are deleted rather than kept: they are
+ // reproducible from a log that keeps every row, so this deletes
+ // no history, and retaining them would cost one whole copy of
+ // the deck per two hundred updates — the difference between an
+ // accelerator and a second, fatter archive.
+ //
+ // "Superseded" is (seq, id) ordered exactly as latestSnapshot
+ // reads, so what survives is always what latestSnapshot would
+ // return, and a row at a HIGHER seq — written by another
+ // request that got further — is never touched. "Delete
+ // everything that is not mine" would take that one out and
+ // quietly put the deck back to replaying updates it had
+ // already been spared.
+ if err := tx.Where("deck_id = ? AND (seq < ? OR (seq = ? AND id < ?))",
+ d.ID, row.Seq, row.Seq, row.ID).Delete(&DeckSnapshot{}).Error; err != nil {
+ return fmt.Errorf("slides: delete superseded snapshots for deck %s at seq %d: %w", d.Sqid, seq, err)
+ }
+ return nil
+ })
+ if err != nil {
+ a.logger.Error("slides: write a deck snapshot", "deck", d.Sqid, "seq", seq, "err", err)
+ }
+}
diff --git a/internal/slides/snapshot_test.go b/internal/slides/snapshot_test.go
new file mode 100644
index 0000000..c25f4c0
--- /dev/null
+++ b/internal/slides/snapshot_test.go
@@ -0,0 +1,125 @@
+package slides
+
+import (
+ "context"
+ "fmt"
+ "testing"
+)
+
+// TestSnapshotWrittenAtBoundaryAndUsedOnOpen: a deck that crosses the
+// cadence boundary gets exactly one snapshot, openDeck replays only the
+// tail after it, and the document it produces is the same document the
+// whole log produces.
+//
+// The last clause is the one that matters. A snapshot is an accelerator
+// and derived data (models.go's DeckSnapshot); the moment it is not
+// byte-for-byte the same deck, it is a second, wrong copy of the truth.
+func TestSnapshotWrittenAtBoundaryAndUsedOnOpen(t *testing.T) {
+ d := testDB(t)
+ a, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()})
+ if err != nil {
+ t.Fatalf("newApp: %v", err)
+ }
+ t.Cleanup(func() { a.Close() })
+ ctx := context.Background()
+
+ owner, _, err := ensureUser(ctx, d.G, "paul@example.test")
+ if err != nil {
+ t.Fatalf("ensureUser: %v", err)
+ }
+ actor := actorFor(owner)
+
+ row, err := a.createDeckRow(ctx, owner, "Long deck", defaultTheme)
+ if err != nil {
+ t.Fatalf("createDeckRow: %v", err)
+ }
+ opening, err := openingOps("Long deck", defaultTheme)
+ if err != nil {
+ t.Fatalf("openingOps: %v", err)
+ }
+ if err := a.editDeck(ctx, &row, actor, opening, nil); err != nil {
+ t.Fatalf("editDeck: %v", err)
+ }
+
+ // One handle for the rest, so this is 200 applies rather than 200
+ // full replays — the test is about the boundary, not about how
+ // slowly a deck can be built.
+ h, _, err := a.openDeck(ctx, row)
+ if err != nil {
+ t.Fatalf("openDeck: %v", err)
+ }
+ defer h.Close(ctx)
+ for row.Seq < snapshotCadence+1 {
+ batch, err := opsJSON([]op{{"op": "set_meta", "title": fmt.Sprintf("Long deck %d", row.Seq)}})
+ if err != nil {
+ t.Fatalf("opsJSON: %v", err)
+ }
+ if _, err := a.applyOps(ctx, h, &row, actor, batch, nil); err != nil {
+ t.Fatalf("applyOps at seq %d: %v", row.Seq, err)
+ }
+ }
+ if row.Seq != snapshotCadence+1 {
+ t.Fatalf("the deck is at seq %d, want %d", row.Seq, snapshotCadence+1)
+ }
+
+ var snapshots []DeckSnapshot
+ if err := d.G.WithContext(ctx).Where("deck_id = ?", row.ID).Order("seq").Find(&snapshots).Error; err != nil {
+ t.Fatalf("read snapshots: %v", err)
+ }
+ if len(snapshots) != 1 {
+ t.Fatalf("%d snapshots after %d updates, want exactly 1", len(snapshots), row.Seq)
+ }
+ if snapshots[0].Seq != snapshotCadence {
+ t.Errorf("the snapshot is at seq %d, want %d", snapshots[0].Seq, snapshotCadence)
+ }
+ if len(snapshots[0].Bytes) == 0 {
+ t.Error("the snapshot carries no bytes")
+ }
+
+ // openDeck replays the tail and nothing before it.
+ snap, tail, err := a.replayPlan(ctx, row)
+ if err != nil {
+ t.Fatalf("replayPlan: %v", err)
+ }
+ if snap == nil || snap.Seq != snapshotCadence {
+ t.Fatalf("replayPlan chose %+v, want the snapshot at seq %d", snap, snapshotCadence)
+ }
+ if len(tail) != 1 || tail[0].Seq != row.Seq {
+ t.Fatalf("replayPlan replays %d updates, want the one at seq %d", len(tail), row.Seq)
+ }
+
+ // And it is the same deck either way.
+ want, err := h.JSON(ctx)
+ if err != nil {
+ t.Fatalf("read the live view: %v", err)
+ }
+ fromSnapshot := viewJSON(t, a, row)
+ if string(fromSnapshot) != string(want) {
+ t.Fatalf("opening from the snapshot gives a different deck:\n got %s\nwant %s", fromSnapshot, want)
+ }
+
+ // Deleting the snapshot loses nothing: the log alone still is the
+ // deck, which is what makes a snapshot safe to throw away.
+ if err := d.G.WithContext(ctx).Where("deck_id = ?", row.ID).Delete(&DeckSnapshot{}).Error; err != nil {
+ t.Fatalf("delete snapshots: %v", err)
+ }
+ if fromLog := viewJSON(t, a, row); string(fromLog) != string(want) {
+ t.Fatalf("opening from the plain log gives a different deck:\n got %s\nwant %s", fromLog, want)
+ }
+}
+
+// viewJSON opens d the way a request does and returns the core's view.
+func viewJSON(t *testing.T, a *app, d Deck) []byte {
+ t.Helper()
+ ctx := context.Background()
+ h, _, err := a.openDeck(ctx, d)
+ if err != nil {
+ t.Fatalf("openDeck: %v", err)
+ }
+ defer h.Close(ctx)
+ raw, err := h.JSON(ctx)
+ if err != nil {
+ t.Fatalf("read the view: %v", err)
+ }
+ return raw
+}
diff --git a/internal/slidestest/live_test.go b/internal/slidestest/live_test.go
new file mode 100644
index 0000000..67800f7
--- /dev/null
+++ b/internal/slidestest/live_test.go
@@ -0,0 +1,511 @@
+package slidestest
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/binary"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/slides/internal/slides/core"
+)
+
+// --- helpers ---
+
+// postBody makes a POST with a body of this test's choosing — the
+// binary update endpoint and the JSON presence endpoint both take one,
+// and neither is a form.
+//
+// It sends the same same-origin evidence `post` does: these are
+// browser-made requests, inside csrf.Protect like every other write.
+func postBody(t *testing.T, a *testApp, target, contentType string, body []byte, cookies []*http.Cookie) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
+ req.Header.Set("Content-Type", contentType)
+ req.Header.Set("Origin", testOrigin)
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ a.ServeHTTP(rec, req)
+ return rec
+}
+
+// frames splits an updates response into the updates it carries: a
+// 4-byte big-endian length then that many bytes, repeated. A body that
+// does not divide cleanly is a failure rather than a short read — the
+// framing is the protocol, and a client that guessed would be a client
+// that silently loses the last update.
+func frames(t *testing.T, body []byte) [][]byte {
+ t.Helper()
+ var out [][]byte
+ for len(body) > 0 {
+ if len(body) < 4 {
+ t.Fatalf("%d trailing bytes with no length prefix", len(body))
+ }
+ n := binary.BigEndian.Uint32(body[:4])
+ body = body[4:]
+ if uint32(len(body)) < n {
+ t.Fatalf("a frame claims %d bytes and %d remain", n, len(body))
+ }
+ out = append(out, body[:n])
+ body = body[n:]
+ }
+ return out
+}
+
+// getUpdates fetches one cursor's worth of the log and returns the
+// frames plus the advisory seq the response carried.
+func getUpdates(t *testing.T, a *testApp, path string, since int64, cookies []*http.Cookie) ([][]byte, int64) {
+ t.Helper()
+ target := path + "/updates?since=" + strconv.FormatInt(since, 10)
+ rec := mustGet(t, a, target, http.StatusOK, cookies)
+ if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
+ t.Fatalf("GET %s: Content-Type = %q, want application/octet-stream", target, ct)
+ }
+ seq, err := strconv.ParseInt(rec.Header().Get("Slides-Seq"), 10, 64)
+ if err != nil {
+ t.Fatalf("GET %s: Slides-Seq = %q, not a number", target, rec.Header().Get("Slides-Seq"))
+ }
+ return frames(t, rec.Body.Bytes()), seq
+}
+
+// mirror is a browser's copy of a deck: a core document of its own,
+// caught up to the server's log, that produces the update bytes a real
+// editor would post. It is the only honest way to test this wire —
+// bytes made up by hand are not Yjs updates and prove nothing about the
+// path they travel.
+type mirror struct {
+ rt *core.Runtime
+ h *core.Handle
+}
+
+func newMirror(t *testing.T, sqid string, log [][]byte) *mirror {
+ t.Helper()
+ ctx := context.Background()
+ rt, err := core.New(ctx)
+ if err != nil {
+ t.Fatalf("core.New: %v", err)
+ }
+ // Client id 2: the server writes as 1 (decks.go's serverClientID),
+ // and two writers sharing one client id is the one thing a CRDT
+ // cannot forgive.
+ h, err := rt.NewDoc(ctx, sqid, 2)
+ if err != nil {
+ rt.Close(ctx)
+ t.Fatalf("core.NewDoc: %v", err)
+ }
+ m := &mirror{rt: rt, h: h}
+ t.Cleanup(func() {
+ h.Close(ctx)
+ rt.Close(ctx)
+ })
+ m.apply(t, log)
+ return m
+}
+
+func (m *mirror) apply(t *testing.T, log [][]byte) {
+ t.Helper()
+ for i, u := range log {
+ if err := m.h.Apply(context.Background(), u); err != nil {
+ t.Fatalf("applying frame %d to the mirror: %v", i, err)
+ }
+ }
+}
+
+// ops runs a batch against the mirror and returns the update it
+// produced — exactly what the browser's own ops module will POST.
+func (m *mirror) ops(t *testing.T, batch string) []byte {
+ t.Helper()
+ update, err := m.h.Ops(context.Background(), []byte(batch))
+ if err != nil {
+ t.Fatalf("mirror ops %s: %v", batch, err)
+ }
+ return update
+}
+
+// postUpdate posts one update and insists on the 204 and the seq.
+func postUpdate(t *testing.T, a *testApp, path string, update []byte, cookies []*http.Cookie) int64 {
+ t.Helper()
+ rec := postBody(t, a, path+"/updates", "application/octet-stream", update, cookies)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("POST %s/updates: status %d, want 204\n%s", path, rec.Code, rec.Body)
+ }
+ seq, err := strconv.ParseInt(rec.Header().Get("Slides-Seq"), 10, 64)
+ if err != nil {
+ t.Fatalf("POST %s/updates: Slides-Seq = %q, not a number", path, rec.Header().Get("Slides-Seq"))
+ }
+ return seq
+}
+
+// scan opens the deck's SSE stream against a real server and returns a
+// reader over it. The request's context is the timeout, so a stream
+// that never speaks fails the test rather than hanging it.
+func scan(t *testing.T, a *testApp, path string, cookies []*http.Cookie) *bufio.Reader {
+ t.Helper()
+ srv := httptest.NewServer(a)
+ t.Cleanup(srv.Close)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ t.Cleanup(cancel)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+path+"/stream", nil)
+ if err != nil {
+ t.Fatalf("build the stream request: %v", err)
+ }
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("open the stream: %v", err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GET %s/stream: status %d, want 200", path, resp.StatusCode)
+ }
+ if got := resp.Header.Get("Content-Type"); got != "text/event-stream" {
+ t.Fatalf("the stream's Content-Type is %q, want text/event-stream", got)
+ }
+ return bufio.NewReader(resp.Body)
+}
+
+// nextEvent reads events until one of the named kind arrives,
+// returning its data line. Pings and the other kind are skipped.
+func nextEvent(t *testing.T, r *bufio.Reader, want string) string {
+ t.Helper()
+ var kind string
+ for {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ t.Fatalf("reading the stream for a %q event: %v", want, err)
+ }
+ line = strings.TrimRight(line, "\r\n")
+ switch {
+ case strings.HasPrefix(line, "event: "):
+ kind = strings.TrimPrefix(line, "event: ")
+ case strings.HasPrefix(line, "data: "):
+ if kind == want {
+ return strings.TrimPrefix(line, "data: ")
+ }
+ }
+ }
+}
+
+// scalar reads one value out of the app's own database, for the facts
+// that have no screen: which actor a row recorded, which id a person
+// got.
+func scalar(t *testing.T, a *testApp, query string, args ...any) string {
+ t.Helper()
+ var out string
+ if err := a.db.G.Raw(query, args...).Scan(&out).Error; err != nil {
+ t.Fatalf("query %q: %v", query, err)
+ }
+ return out
+}
+
+// sqidOf turns the /d/{sqid} path createDeck returns into the sqid.
+func sqidOf(path string) string { return strings.TrimPrefix(path, "/d/") }
+
+// --- the wire ---
+
+// TestUpdatesRoundTripThroughTheWire: a browser's own update, made by
+// the same core the server runs, posted, read back framed, and visible
+// in the deck the server renders.
+//
+// The last step is the point. A test that posted bytes and read the
+// same bytes back would pass over a table that stored them and a
+// document that never saw them.
+func TestUpdatesRoundTripThroughTheWire(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Wire")
+
+ // A new deck is one update: its opening batch (decks.go).
+ log, seq := getUpdates(t, a, path, 0, cookies)
+ if len(log) != 1 {
+ t.Fatalf("a new deck's log has %d frames, want 1", len(log))
+ }
+ if seq != 1 {
+ t.Fatalf("Slides-Seq on a new deck is %d, want 1", seq)
+ }
+
+ m := newMirror(t, sqidOf(path), log)
+ update := m.ops(t, `[{"op":"add_slide","id":"wiretestslide","layout":"flow","template":"title"}]`)
+
+ if got := postUpdate(t, a, path, update, cookies); got != 2 {
+ t.Fatalf("POST answered Slides-Seq %d, want 2", got)
+ }
+
+ // Read back from the cursor the client held: exactly the one
+ // update it did not already have, byte for byte.
+ tail, seq := getUpdates(t, a, path, 1, cookies)
+ if len(tail) != 1 {
+ t.Fatalf("since=1 returned %d frames, want 1", len(tail))
+ }
+ if !bytes.Equal(tail[0], update) {
+ t.Errorf("the frame read back is not the update posted:\n got %x\nwant %x", tail[0], update)
+ }
+ if seq != 2 {
+ t.Errorf("Slides-Seq is %d, want 2", seq)
+ }
+
+ // And the server's own document really has the slide.
+ rec := mustGet(t, a, path+"/slides/wiretestslide.html", http.StatusOK, cookies)
+ if !strings.Contains(rec.Body.String(), "<section slides-slide") {
+ t.Errorf("the slide the update added does not render:\n%s", rec.Body)
+ }
+}
+
+// TestUpdatesSinceCursorIsExclusive: ?since=N means "everything after
+// N", never "everything from N". Off by one here and every client
+// re-applies the update it already holds on every reconnect.
+func TestUpdatesSinceCursorIsExclusive(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Cursors")
+
+ log, _ := getUpdates(t, a, path, 0, cookies)
+ m := newMirror(t, sqidOf(path), log)
+ postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"Second"}]`), cookies)
+ postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"Third"}]`), cookies)
+
+ for _, tc := range []struct {
+ since int64
+ frames int
+ seq int64
+ }{
+ {since: 0, frames: 3, seq: 3},
+ {since: 1, frames: 2, seq: 3},
+ {since: 2, frames: 1, seq: 3},
+ // Nothing after the last one, and the header says where the
+ // client already is rather than dropping to zero.
+ {since: 3, frames: 0, seq: 3},
+ // A cursor past the end is a client that is ahead of this
+ // instance, not an error: it gets nothing and keeps its place.
+ {since: 99, frames: 0, seq: 99},
+ } {
+ got, seq := getUpdates(t, a, path, tc.since, cookies)
+ if len(got) != tc.frames {
+ t.Errorf("since=%d returned %d frames, want %d", tc.since, len(got), tc.frames)
+ }
+ if seq != tc.seq {
+ t.Errorf("since=%d answered Slides-Seq %d, want %d", tc.since, seq, tc.seq)
+ }
+ }
+
+ // A cursor that is not a number is a client bug, and it is told so
+ // rather than being handed the whole log as if it had asked.
+ mustGet(t, a, path+"/updates?since=banana", http.StatusBadRequest, cookies)
+ mustGet(t, a, path+"/updates?since=-1", http.StatusBadRequest, cookies)
+}
+
+// TestStreamAnnouncesSeqOnConnectAndAfterPost: the stream says where
+// the deck is the moment it opens — which is what heals a tab whose
+// cursor fell behind — and says so again every time an update lands.
+func TestStreamAnnouncesSeqOnConnectAndAfterPost(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Stream")
+
+ log, _ := getUpdates(t, a, path, 0, cookies)
+ m := newMirror(t, sqidOf(path), log)
+
+ events := scan(t, a, path, cookies)
+ if data := nextEvent(t, events, "update"); data != "1" {
+ t.Errorf("the connect event is %q, want the deck's current seq 1", data)
+ }
+ // Nobody has posted presence, so the connect set is empty — an
+ // event all the same, so a tab knows it is alone rather than
+ // waiting to find out.
+ if data := nextEvent(t, events, "presence"); data != "[]" {
+ t.Errorf("the connect presence is %q, want an empty set", data)
+ }
+
+ postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"Moved"}]`), cookies)
+ if data := nextEvent(t, events, "update"); data != "2" {
+ t.Errorf("after a POST the stream said %q, want 2", data)
+ }
+
+ // A write through any other path says so too: the stream is the
+ // deck's, not the update endpoint's.
+ rec := post(t, a, path+"/settings", url.Values{
+ "title": {"Renamed"}, "theme": {"ink"}, "rung": {"private"},
+ }, cookies...)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST %s/settings: status %d, want 303", path, rec.Code)
+ }
+ if data := nextEvent(t, events, "update"); data != "3" {
+ t.Errorf("after a settings save the stream said %q, want 3", data)
+ }
+}
+
+// TestPresencePostReachesTheStream: a heartbeat becomes the whole set,
+// stamped with the name and colour of the person whose SESSION it came
+// from rather than whatever the body claimed.
+func TestPresencePostReachesTheStream(t *testing.T) {
+ a := newSharedApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Presence")
+
+ events := scan(t, a, path, cookies)
+ nextEvent(t, events, "presence")
+
+ body := `{"client":"tab-1","slide":"s1","block":"b1","name":"Somebody Else","colour":"#ff0000"}`
+ rec := postBody(t, a, path+"/presence", "application/json", []byte(body), cookies)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("POST %s/presence: status %d, want 204\n%s", path, rec.Code, rec.Body)
+ }
+
+ data := nextEvent(t, events, "presence")
+ for _, want := range []string{`"client":"tab-1"`, `"slide":"s1"`, `"block":"b1"`, allowedAddress} {
+ if !strings.Contains(data, want) {
+ t.Errorf("the presence event %q does not carry %q", data, want)
+ }
+ }
+ colour := scalar(t, a, "SELECT colour FROM users WHERE email = ?", allowedAddress)
+ if colour == "" || !strings.Contains(data, `"colour":"`+colour+`"`) {
+ t.Errorf("the presence event %q does not carry the person's own colour %q", data, colour)
+ }
+ if strings.Contains(data, "Somebody Else") || strings.Contains(data, "#ff0000") {
+ t.Errorf("the body's own name or colour was trusted:\n%s", data)
+ }
+
+ // A heartbeat with no client to key on is a client bug, not a
+ // person: refused rather than filed under the empty string.
+ rec = postBody(t, a, path+"/presence", "application/json", []byte(`{"slide":"s1"}`), cookies)
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Errorf("POST presence with no client: status %d, want 422", rec.Code)
+ }
+}
+
+// TestUpdatesRequireMembershipAndRecordActor: every live-sync route is
+// behind the same visibility seam every other deck route is, answering
+// 404 rather than 403 — and every write it accepts says who made it.
+func TestUpdatesRequireMembershipAndRecordActor(t *testing.T) {
+ a := newSharedApp(t)
+ mine := signedIn(t, a, allowedAddress)
+ theirs := signedIn(t, a, otherAddress)
+ path := createDeck(t, a, mine, "Members only")
+
+ log, _ := getUpdates(t, a, path, 0, mine)
+ m := newMirror(t, sqidOf(path), log)
+ update := m.ops(t, `[{"op":"set_meta","title":"Mine"}]`)
+
+ // A stranger cannot read the log, write to it, watch it, or say
+ // they are in the deck.
+ mustGet(t, a, path+"/updates?since=0", http.StatusNotFound, theirs)
+ mustGet(t, a, path+"/stream", http.StatusNotFound, theirs)
+ mustGet(t, a, path+"/slides/whatever.html", http.StatusNotFound, theirs)
+ if rec := postBody(t, a, path+"/updates", "application/octet-stream", update, theirs); rec.Code != http.StatusNotFound {
+ t.Errorf("a stranger's POST /updates: status %d, want 404", rec.Code)
+ }
+ if rec := postBody(t, a, path+"/presence", "application/json", []byte(`{"client":"x"}`), theirs); rec.Code != http.StatusNotFound {
+ t.Errorf("a stranger's POST /presence: status %d, want 404", rec.Code)
+ }
+ // Signed out is a redirect to sign in, never the log.
+ mustGet(t, a, path+"/updates?since=0", http.StatusSeeOther, nil)
+
+ // The owner's own write records them.
+ postUpdate(t, a, path, update, mine)
+ id := scalar(t, a, "SELECT id FROM users WHERE email = ?", allowedAddress)
+ actor := scalar(t, a,
+ "SELECT actor FROM deck_updates WHERE deck_id = (SELECT id FROM decks WHERE sqid = ?) ORDER BY seq DESC LIMIT 1",
+ sqidOf(path))
+ if want := "person:" + id; actor != want {
+ t.Errorf("the update recorded actor %q, want %q", actor, want)
+ }
+
+ // A team member can edit: the live routes are visibleDecks' whole
+ // audience, not the owner alone.
+ if rec := post(t, a, path+"/settings", url.Values{
+ "title": {"Members only"}, "theme": {"ink"}, "rung": {"team"},
+ }, mine...); rec.Code != http.StatusSeeOther {
+ t.Fatalf("raising the rung: status %d, want 303", rec.Code)
+ }
+ shared, _ := getUpdates(t, a, path, 0, theirs)
+ if len(shared) == 0 {
+ t.Fatal("a team member cannot read the deck's log")
+ }
+ m.apply(t, shared[len(shared)-1:])
+ postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"Ours"}]`), theirs)
+ theirID := scalar(t, a, "SELECT id FROM users WHERE email = ?", otherAddress)
+ actor = scalar(t, a,
+ "SELECT actor FROM deck_updates WHERE deck_id = (SELECT id FROM decks WHERE sqid = ?) ORDER BY seq DESC LIMIT 1",
+ sqidOf(path))
+ if want := "person:" + theirID; actor != want {
+ t.Errorf("the team member's update recorded actor %q, want %q", actor, want)
+ }
+}
+
+// TestRefusedUpdateIs422: bytes the core will not have are the client's
+// problem, and the answer says so without pretending anything was
+// stored.
+func TestRefusedUpdateIs422(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Refusals")
+
+ rec := postBody(t, a, path+"/updates", "application/octet-stream",
+ []byte("this is not a Yjs update"), cookies)
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("POST garbage: status %d, want 422\n%s", rec.Code, rec.Body)
+ }
+
+ // Nothing was written: the log is still the one opening update.
+ log, seq := getUpdates(t, a, path, 0, cookies)
+ if len(log) != 1 || seq != 1 {
+ t.Errorf("after a refusal the log has %d frames at seq %d, want 1 at 1", len(log), seq)
+ }
+
+ // And an update larger than the cap is refused before it is read
+ // into memory at all.
+ rec = postBody(t, a, path+"/updates", "application/octet-stream",
+ make([]byte, maxUpdateBytesForTest+1), cookies)
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Errorf("POST an oversized update: status %d, want 413", rec.Code)
+ }
+}
+
+// maxUpdateBytesForTest is the app's own cap, restated here rather than
+// exported: the number is part of the wire contract (the plan's "limit
+// 1 MiB"), so a test that read it from the app could never notice it
+// changing.
+const maxUpdateBytesForTest = 1 << 20
+
+// TestSlideHTMLTwinRendersOneSection: the editor's stage and strip
+// fetch slide HTML with a session cookie rather than a bearer token,
+// and get the same section the API serves — the section alone, never a
+// page.
+func TestSlideHTMLTwinRendersOneSection(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Stage")
+
+ log, _ := getUpdates(t, a, path, 0, cookies)
+ m := newMirror(t, sqidOf(path), log)
+ postUpdate(t, a, path, m.ops(t, `[{"op":"add_slide","id":"stageslide","layout":"flow","template":"title"}]`), cookies)
+
+ for _, vp := range []string{"", "desktop", "phone", "thumb"} {
+ target := path + "/slides/stageslide.html"
+ if vp != "" {
+ target += "?vp=" + vp
+ }
+ body := mustGet(t, a, target, http.StatusOK, cookies).Body.String()
+ if !strings.HasPrefix(strings.TrimSpace(body), "<section slides-slide") {
+ t.Errorf("vp=%q is not a bare slide section:\n%s", vp, body)
+ }
+ if strings.Contains(body, "<html") {
+ t.Errorf("vp=%q carries a whole page:\n%s", vp, body)
+ }
+ }
+
+ mustGet(t, a, path+"/slides/nosuchslide.html", http.StatusNotFound, cookies)
+ mustGet(t, a, path+"/slides/stageslide", http.StatusNotFound, cookies)
+}