oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

Live Updates Across Tabs Implementation Plan

> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Open a document in two tabs, type in one, and the other updates without a reload — and a tab nobody is touching stops holding the instance awake.

Architecture: doc_updates.seq is promoted from an internal ordering column to a client-facing cursor. GET /d/{sqid}/updates learns ?since=N; both update routes answer with a Docs-Seq header derived from the bytes in hand. A new in-process hub (internal/docs/stream.go) fans a committed seq out to whoever is subscribed to that document, and GET /d/{sqid}/stream is a Server-Sent Events feed that blocks on it. The stream carries no document bytes: it says "there is something through seq N", and the browser re-fetches through the same binary endpoint it already uses at mount, through the same applyServerLog. A new client module owns the cursor, the connection lifecycle and the idle policy; editor-entry.js only wires it into the lifecycle attemptResync already has.

Tech Stack: Go (no new dependencies), the existing chi router and rastrillo middleware, browser EventSource, JS yjs 13.6.27 unchanged.

Spec: docs/superpowers/specs/2026-09-01-live-sync-design.md. It in turn extends the docs design spec's §4.1, §4.3, §7.1 and §7.3.

---

Sequencing decisions made when writing this plan

S1 — No new dependency, in either language. The Go side uses net/http and the standard library only; the browser side uses native EventSource. If a task appears to need a library, the task is wrong.

S2 — Docs-Seq is derived, never queried. Spec D2. Any implementation that runs a second MAX(seq) query to fill this header reintroduces the exact TOCTOU the review caught. max(since, highest seq in the returned bytes) and nothing else.

S3 — The client module owns every open and every close. Spec §6.1. Native EventSource reconnection is never relied on. An implementer who deletes the explicit close()/reopen policy "because EventSource already reconnects" has recreated §7.3's parked-tab bug.

S4 — Nothing in save-queue.mjs or resync-controller.mjs changes. The header is read inside the post closure editor-entry.js already supplies. If either module needs editing, stop and re-read spec §7.2.

S5 — No user-visible strings. A dead stream is logged to the console and nothing else (spec §7.4), so this plan adds no catalog keys and touches no template. views/show.html is not in the file list, deliberately — a sibling session is editing it.

Global Constraints

Every task's requirements implicitly include this section.

  • CGO_ENABLED=0. No new module requirements in go.mod.
  • Never call sessions.UserID; never use scope.Owned. The AST guard in internal/lint fails the build.
  • Every route added here is owner-scoped through getDocumentForUpdates, and a stranger gets 404, never 403.
  • Store.ApplyUpdate's owner re-derivation and its MAX+INSERT transaction are not to be weakened, reordered, or moved.
  • The fixtures in fixtures/ are the contract, not an output.
  • Run .amadan/ci.d/10-gate before reporting any task complete.
  • Environment, under an agent sandbox: GOCACHE must point somewhere writable ($TMPDIR/go-build); the default ~/.cache/go-build is read-only here and fails as "setup failed", which reads like a build break and is not one. A fresh worktree needs npm ci in fixtures/ first. The Rust toolchain lives at ~/.cargo/bin and is not on a default PATH.

File Structure

| File | Responsibility | |---|---| | internal/docs/stream.go (new) | The subscriber hub, and the SSE handler. The only file that knows a transport exists. | | internal/docs/store.go | ApplyUpdate returns the assigned seq. Nothing else changes. | | internal/docs/handlers.go | ?since= and Docs-Seq on the update routes; the broadcast call after a successful POST. | | internal/docs/app.go | One route registration, inside the existing RequireSession group. | | internal/docs/seed.go | Call-site update for the new ApplyUpdate signature. | | internal/docs/stream_test.go (new) | The SSE handler under a real httptest.NewServer. | | internal/docs/updates_test.go | ?since=, Docs-Seq, and D2's derivation. | | fixtures/live-updates.mjs (new) | The cursor, the connection lifecycle, the idle policy, fetch serialization. | | fixtures/live-updates.test.mjs (new) | All of the above under plain Node with injected fakes. | | fixtures/editor-entry.js | Wiring only, inside attemptResync and its sibling state. | | internal/docs/static/editor.<hash>.js | Rebuilt bundle, committed. | | .amadan/ci.d/10-gate | One line: the new Node test joins the gate. |

---

Task 1: seq becomes a wire value

Without this the client has no cursor, and every later task has nothing to talk about.

Files: internal/docs/store.go, internal/docs/handlers.go, internal/docs/seed.go, internal/docs/updates_test.go

Interfaces:

  • func (s *Store) ApplyUpdate(ctx, doc, update) (int64, error) — the assigned seq, meaningful only when the error is nil.
  • GET /d/{sqid}/updates?since=N — rows with seq > N.
  • Docs-Seq response header on both update routes.
  • [ ] Step 1: Tests first. ?since= returns only later rows; Docs-Seq on a full GET is the highest row returned; Docs-Seq on an empty slice echoes since and never reports a higher number (D2's regression test — write it so it fails against a MAX(seq) implementation); Docs-Seq on a POST is the row just written; a malformed since is 400; maxLogBytes still applies to a slice.
  • [ ] Step 2: ApplyUpdate returns the seq. Hoist maxSeq + 1 out of the transaction closure into a captured variable and return it after commit. The owner re-derivation runs before and is untouched.
  • [ ] Step 3: The two call sites. seed.go:94 and handlers.go:378. Seeding ignores the seq.
  • [ ] Step 4: updatesGet learns since, and both handlers set Docs-Seq per S2.

Task 2: The hub

Files: internal/docs/stream.go (new), internal/docs/handlers.go, internal/docs/app.go

Interfaces:

  • newHub() *hub, (*hub).subscribe(docID int64) (<-chan int64, func()), (*hub).broadcast(docID, seq int64).
  • [ ] Step 1: Tests first. A subscriber receives a broadcast for its own document and not for another's; unsubscribe stops delivery and is safe to call twice; a broadcast with no subscribers is a no-op; a slow subscriber never blocks a broadcaster (fill the slot, broadcast again, assert broadcast returns and the newest state is still reachable); concurrent subscribe/broadcast/unsubscribe under -race.
  • [ ] Step 2: Implement. One-slot buffered channels, non-blocking send, mutex around the map, delete the document's inner map when its last subscriber leaves so the map cannot grow forever.
  • [ ] Step 3: Broadcast from updatesPost, after ApplyUpdate returns a nil error and never otherwise (spec D4). The hub hangs off App/handlers, not off Store (spec D5).

Task 3: The stream endpoint

Files: internal/docs/stream.go, internal/docs/app.go, internal/docs/stream_test.go (new)

  • [ ] Step 1: Tests first, under httptest.NewServer with short injected durations — a recorder cannot express a stream. Cases: the connect-time event carries the current max seq; a POST from a second request arrives as event: update with that row's seq; a stranger's document is 404; the TTL ends the stream cleanly; a heartbeat appears on a quiet stream; cancelling the request context ends the handler.
  • [ ] Step 2: Implement per spec §5.2 — http.Flusher check, http.NewResponseController, text/event-stream + Cache-Control: no-store, 200 and flush, 30s write deadline re-armed before every write, : ping every 15s, 5-minute TTL, select including r.Context().Done(), and no database tick.
  • [ ] Step 3: Register r.Get("/d/{sqid}/stream", h.stream) inside the existing RequireSession group.

Task 4: The client module

Files: fixtures/live-updates.mjs (new), fixtures/live-updates.test.mjs (new), .amadan/ci.d/10-gate

Interfaces: createLiveUpdates({ url, initialSeq, fetchSince, openStream, onUpdates, now, setTimeoutFn, addActivityListener }) → { start, stop, noteOwnSeq, get cursor }. Everything injected, per the convention save-queue.mjs set.

  • [ ] Step 1: Tests first. noteOwnSeq advances only on cursor + 1 (D3), and explicitly does not on a gap; the cursor is monotonic under an out-of-order response (D2); a burst of events produces one in-flight fetch, then one more if something arrived meanwhile; a stopped instance drops a late-resolving fetch; hidden closes the stream, activity reopens it; a visible, focused, idle tab closes and then reopens on pointermove/scroll/keydown (spec §6.3's regression test); a stream error never reports a save failure.
  • [ ] Step 2: Implement. No native reconnection anywhere (S3).
  • [ ] Step 3: Add the test to the gate, beside the other two, with a comment saying which failure it guards — the convention those two set.

Task 5: Wiring, and the bundle

Files: fixtures/editor-entry.js, internal/docs/static/editor.<hash>.js

  • [ ] Step 1: Seed the cursor from the initial GET's Docs-Seq inside attemptResync.
  • [ ] Step 2: currentLive, started at the end of attemptResync and stopped at its top beside the existing view teardown, so a resync replaces it exactly as it replaces the queue and the view.
  • [ ] Step 3: Read Docs-Seq in the post closure on a 2xx, feeding noteOwnSeq. save-queue.mjs is not touched (S4).
  • [ ] Step 4: Stop applying while diverged, and let attemptResync replace the instance.
  • [ ] Step 5: Rebuild the bundle — cd fixtures && node build-editor.mjs — and commit the result. Do not hand-edit it. On a rebase that brings in a sibling's bundle, discard both and rebuild on top (the branch convention agreed across three sessions).

Task 6: It actually works

  • [ ] Step 1: .amadan/ci.d/10-gate, exit 0.
  • [ ] Step 2: End-to-end without a browser. run-dev.sh, sign in, hold the stream open with curl -N, POST an update from a second request, and see the event arrive — proof the whole path is connected, in a form CI could keep.
  • [ ] Step 3: The actual ask. Two real tabs on one document; type in one; watch the other. Nothing in this plan is done until someone has seen that.