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>
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. |