oficina / docs Public
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 ingo.mod.- Never call
sessions.UserID; never usescope.Owned. The AST guard ininternal/lintfails 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-gatebefore reporting any task complete. - Environment, under an agent sandbox:
GOCACHEmust point somewhere writable ($TMPDIR/go-build); the default~/.cache/go-buildis read-only here and fails as "setup failed", which reads like a build break and is not one. A fresh worktree needsnpm ciinfixtures/first. The Rust toolchain lives at~/.cargo/binand 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 withseq > N.Docs-Seqresponse header on both update routes.
- [ ] Step 1: Tests first.
?since=returns only later rows;Docs-Seqon a full GET is the highest row returned;Docs-Seqon an empty slice echoessinceand never reports a higher number (D2's regression test — write it so it fails against aMAX(seq)implementation);Docs-Seqon a POST is the row just written; a malformedsinceis 400;maxLogBytesstill applies to a slice. - [ ] Step 2:
ApplyUpdatereturns the seq. HoistmaxSeq + 1out 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:94andhandlers.go:378. Seeding ignores the seq. - [ ] Step 4:
updatesGetlearnssince, and both handlers setDocs-Seqper 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
broadcastreturns 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, afterApplyUpdatereturns a nil error and never otherwise (spec D4). The hub hangs offApp/handlers, not offStore(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.NewServerwith 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 asevent: updatewith 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.Flushercheck,http.NewResponseController,text/event-stream+Cache-Control: no-store, 200 and flush, 30s write deadline re-armed before every write,: pingevery 15s, 5-minute TTL,selectincludingr.Context().Done(), and no database tick. - [ ] Step 3: Register
r.Get("/d/{sqid}/stream", h.stream)inside the existingRequireSessiongroup.
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.
noteOwnSeqadvances only oncursor + 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 onpointermove/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-SeqinsideattemptResync. - [ ] Step 2:
currentLive, started at the end ofattemptResyncand 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-Seqin thepostclosure on a 2xx, feedingnoteOwnSeq.save-queue.mjsis not touched (S4). - [ ] Step 4: Stop applying while diverged, and let
attemptResyncreplace 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 withcurl -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.