| 1 | # Live Updates Across Tabs Implementation Plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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. |
| 6 | |
| 7 | **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. |
| 8 | |
| 9 | **Tech Stack:** Go (no new dependencies), the existing chi router and rastrillo middleware, browser `EventSource`, JS `yjs` 13.6.27 unchanged. |
| 10 | |
| 11 | **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. |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## Sequencing decisions made when writing this plan |
| 16 | |
| 17 | **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. |
| 18 | |
| 19 | **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. |
| 20 | |
| 21 | **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. |
| 22 | |
| 23 | **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. |
| 24 | |
| 25 | **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. |
| 26 | |
| 27 | ## Global Constraints |
| 28 | |
| 29 | Every task's requirements implicitly include this section. |
| 30 | |
| 31 | - `CGO_ENABLED=0`. No new module requirements in `go.mod`. |
| 32 | - Never call `sessions.UserID`; never use `scope.Owned`. The AST guard in `internal/lint` fails the build. |
| 33 | - Every route added here is owner-scoped through `getDocumentForUpdates`, and a stranger gets 404, never 403. |
| 34 | - `Store.ApplyUpdate`'s owner re-derivation and its MAX+INSERT transaction are not to be weakened, reordered, or moved. |
| 35 | - The fixtures in `fixtures/` are the contract, not an output. |
| 36 | - Run `.amadan/ci.d/10-gate` before reporting any task complete. |
| 37 | - **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. |
| 38 | |
| 39 | ## File Structure |
| 40 | |
| 41 | | File | Responsibility | |
| 42 | |---|---| |
| 43 | | `internal/docs/stream.go` *(new)* | The subscriber hub, and the SSE handler. The only file that knows a transport exists. | |
| 44 | | `internal/docs/store.go` | `ApplyUpdate` returns the assigned seq. Nothing else changes. | |
| 45 | | `internal/docs/handlers.go` | `?since=` and `Docs-Seq` on the update routes; the broadcast call after a successful POST. | |
| 46 | | `internal/docs/app.go` | One route registration, inside the existing `RequireSession` group. | |
| 47 | | `internal/docs/seed.go` | Call-site update for the new `ApplyUpdate` signature. | |
| 48 | | `internal/docs/stream_test.go` *(new)* | The SSE handler under a real `httptest.NewServer`. | |
| 49 | | `internal/docs/updates_test.go` | `?since=`, `Docs-Seq`, and D2's derivation. | |
| 50 | | `fixtures/live-updates.mjs` *(new)* | The cursor, the connection lifecycle, the idle policy, fetch serialization. | |
| 51 | | `fixtures/live-updates.test.mjs` *(new)* | All of the above under plain Node with injected fakes. | |
| 52 | | `fixtures/editor-entry.js` | Wiring only, inside `attemptResync` and its sibling state. | |
| 53 | | `internal/docs/static/editor.<hash>.js` | Rebuilt bundle, committed. | |
| 54 | | `.amadan/ci.d/10-gate` | One line: the new Node test joins the gate. | |
| 55 | |
| 56 | --- |
| 57 | |
| 58 | ### Task 1: `seq` becomes a wire value |
| 59 | |
| 60 | Without this the client has no cursor, and every later task has nothing to talk about. |
| 61 | |
| 62 | **Files:** `internal/docs/store.go`, `internal/docs/handlers.go`, `internal/docs/seed.go`, `internal/docs/updates_test.go` |
| 63 | |
| 64 | **Interfaces:** |
| 65 | - `func (s *Store) ApplyUpdate(ctx, doc, update) (int64, error)` — the assigned seq, meaningful only when the error is nil. |
| 66 | - `GET /d/{sqid}/updates?since=N` — rows with `seq > N`. |
| 67 | - `Docs-Seq` response header on both update routes. |
| 68 | |
| 69 | - [ ] **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. |
| 70 | - [ ] **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. |
| 71 | - [ ] **Step 3: The two call sites.** `seed.go:94` and `handlers.go:378`. Seeding ignores the seq. |
| 72 | - [ ] **Step 4: `updatesGet` learns `since`,** and both handlers set `Docs-Seq` per S2. |
| 73 | |
| 74 | ### Task 2: The hub |
| 75 | |
| 76 | **Files:** `internal/docs/stream.go` *(new)*, `internal/docs/handlers.go`, `internal/docs/app.go` |
| 77 | |
| 78 | **Interfaces:** |
| 79 | - `newHub() *hub`, `(*hub).subscribe(docID int64) (<-chan int64, func())`, `(*hub).broadcast(docID, seq int64)`. |
| 80 | |
| 81 | - [ ] **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`. |
| 82 | - [ ] **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. |
| 83 | - [ ] **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). |
| 84 | |
| 85 | ### Task 3: The stream endpoint |
| 86 | |
| 87 | **Files:** `internal/docs/stream.go`, `internal/docs/app.go`, `internal/docs/stream_test.go` *(new)* |
| 88 | |
| 89 | - [ ] **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. |
| 90 | - [ ] **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**. |
| 91 | - [ ] **Step 3: Register** `r.Get("/d/{sqid}/stream", h.stream)` inside the existing `RequireSession` group. |
| 92 | |
| 93 | ### Task 4: The client module |
| 94 | |
| 95 | **Files:** `fixtures/live-updates.mjs` *(new)*, `fixtures/live-updates.test.mjs` *(new)*, `.amadan/ci.d/10-gate` |
| 96 | |
| 97 | **Interfaces:** `createLiveUpdates({ url, initialSeq, fetchSince, openStream, onUpdates, now, setTimeoutFn, addActivityListener })` → `{ start, stop, noteOwnSeq, get cursor }`. Everything injected, per the convention `save-queue.mjs` set. |
| 98 | |
| 99 | - [ ] **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. |
| 100 | - [ ] **Step 2: Implement.** No native reconnection anywhere (S3). |
| 101 | - [ ] **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. |
| 102 | |
| 103 | ### Task 5: Wiring, and the bundle |
| 104 | |
| 105 | **Files:** `fixtures/editor-entry.js`, `internal/docs/static/editor.<hash>.js` |
| 106 | |
| 107 | - [ ] **Step 1: Seed the cursor** from the initial GET's `Docs-Seq` inside `attemptResync`. |
| 108 | - [ ] **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. |
| 109 | - [ ] **Step 3: Read `Docs-Seq` in the `post` closure** on a 2xx, feeding `noteOwnSeq`. `save-queue.mjs` is not touched (S4). |
| 110 | - [ ] **Step 4: Stop applying while diverged,** and let `attemptResync` replace the instance. |
| 111 | - [ ] **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). |
| 112 | |
| 113 | ### Task 6: It actually works |
| 114 | |
| 115 | - [ ] **Step 1: `.amadan/ci.d/10-gate`,** exit 0. |
| 116 | - [ ] **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. |
| 117 | - [ ] **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. |
| 118 | |