oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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
29Every 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
60Without 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