oficina / docs Public

Plan 5: live updates across tabs

Six tasks, each test-first. Seq becomes a client-facing cursor, an
in-process hub fans a committed seq out per document, an SSE endpoint
blocks on it, and a new client module owns the cursor and the
connection lifecycle. editor-entry.js gets wiring only.

The sequencing decisions are mostly guardrails against undoing the
review's findings: S2 forbids filling Docs-Seq from a second MAX(seq)
query, and S3 forbids relying on EventSource's native reconnection,
because each looks like a simplification and each restores a bug the
spec exists to prevent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 278776783774c92549c017ef6a63fdeff1726fed parent 04994c7
1 file changed, +117 −0
  • docs/superpowers/plans/2026-09-01-live-sync.md +117 −0
diff --git a/docs/superpowers/plans/2026-09-01-live-sync.md b/docs/superpowers/plans/2026-09-01-live-sync.md
new file mode 100644
index 0000000..da3d7d4
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-01-live-sync.md
@@ -0,0 +1,117 @@
+# 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.