Plan 5, tasks 1-2: seq becomes a cursor, and a hub to announce it
ApplyUpdate returns the seq it assigned, GET /d/{sqid}/updates takes
?since=N, and both update routes answer with a Docs-Seq header. The
header is derived from the rows being written and never from a second
MAX(seq) query: reads go to the read pool and writes to the
single-connection write pool, so a row committing between the two
queries would let a client advance its cursor over content it was
never sent. cursor_test.go's TestEmptySliceEchoesSinceRatherThanThe
CurrentMax is written to fail against that implementation.
The hub is one process's fan-out, keyed by document. Its channels are
one deep and its sends are non-blocking, which is safe because the
seq it carries is advisory: a subscriber that misses ten
notifications still asks for ?since=its own cursor and gets all ten
updates. It lives beside the handlers rather than inside Store, so
Store stays persistence and doc_updates stays the plain append-only
table section 4.3 called for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+635
−16
internal/docs/app.go+1 −1internal/docs/cursor_test.go+266 −0internal/docs/editor_view_test.go+1 −1internal/docs/handlers.go+62 −2internal/docs/hub.go+105 −0internal/docs/hub_test.go+154 −0internal/docs/render.go+9 −0internal/docs/seed.go+6 −1internal/docs/store.go+27 −7internal/docs/store_test.go+4 −4
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 07a1a02..c2e2cfc 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -400,7 +400,7 @@ func New(cfg Config) (*App, error) { |
| return nil, fmt.Errorf("docs: build auth: %w", err) |
| } |
| - h := &handlers{render: render, logger: logger, store: store} |
| + h := &handlers{render: render, logger: logger, store: store, hub: newHub()} |
| r := chi.NewRouter() |
| // Mounted above every route, per rastrillo SKILL.md §5: refuses a |
diff --git a/internal/docs/cursor_test.go b/internal/docs/cursor_test.go| new file mode 100644 |
| index 0000000..f9606fe |
| --- /dev/null |
| +++ b/internal/docs/cursor_test.go |
| @@ -0,0 +1,266 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "net/http" |
| + "strconv" |
| + "testing" |
| +) |
| + |
| +// The cursor protocol (spec 2026-09-01-live-sync-design.md §4) is what |
| +// lets a second tab catch up without reloading: ?since=N slices the |
| +// log, and Docs-Seq tells the client how far the bytes it just |
| +// received actually reach. |
| +// |
| +// These tests live in their own file rather than in updates_test.go |
| +// because they test a protocol rather than the update endpoints' |
| +// existing behaviour, and because the D2 test below is a regression |
| +// test for a specific rejected implementation — the kind that goes |
| +// stale silently if it ends up filed under a general heading. |
| + |
| +// postUpdate posts one real Yjs update as owner and returns the |
| +// response, so a test that only needs "another row exists now" does |
| +// not restate the whole request each time. |
| +func postUpdate(t *testing.T, app *App, sqid, address string, update []byte) *http.Response { |
| + t.Helper() |
| + rec := serve(app, app.TestRequest(t, address, http.MethodPost, |
| + "/d/"+sqid+"/updates", bytes.NewReader(update))) |
| + return rec.Result() |
| +} |
| + |
| +// docsSeq reads the Docs-Seq header off a response, failing the test |
| +// if it is missing or unparseable — every response from either update |
| +// route must carry one, so an absent header is a bug rather than a |
| +// case to tolerate. |
| +func docsSeq(t *testing.T, res *http.Response) int64 { |
| + t.Helper() |
| + raw := res.Header.Get("Docs-Seq") |
| + if raw == "" { |
| + return -1 |
| + } |
| + n, err := strconv.ParseInt(raw, 10, 64) |
| + if err != nil { |
| + t.Fatalf("Docs-Seq: %q does not parse as an integer: %v", raw, err) |
| + } |
| + return n |
| +} |
| + |
| +// A POST answers with the seq of the row it just wrote, which is what |
| +// lets a tab advance its own cursor without a follow-up fetch (D3). |
| +func TestPostAnswersWithTheSeqItWrote(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Cursor") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + res := postUpdate(t, app, doc.Sqid, "owner@example.com", validUpdate(t)) |
| + if res.StatusCode != http.StatusNoContent { |
| + t.Fatalf("POST = %d, want 204", res.StatusCode) |
| + } |
| + if got := docsSeq(t, res); got != 1 { |
| + t.Errorf("Docs-Seq after the first POST = %d, want 1", got) |
| + } |
| + |
| + res = postUpdate(t, app, doc.Sqid, "owner@example.com", validUpdate(t)) |
| + if got := docsSeq(t, res); got != 2 { |
| + t.Errorf("Docs-Seq after the second POST = %d, want 2", got) |
| + } |
| +} |
| + |
| +// ?since=N returns only what came after N, and says so. |
| +func TestSinceSlicesTheLog(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Sliced") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + update := validUpdate(t) |
| + postUpdate(t, app, doc.Sqid, "owner@example.com", update) |
| + postUpdate(t, app, doc.Sqid, "owner@example.com", update) |
| + |
| + full := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, |
| + "/d/"+doc.Sqid+"/updates", nil)) |
| + if full.Code != http.StatusOK { |
| + t.Fatalf("full GET = %d, want 200", full.Code) |
| + } |
| + if got := docsSeq(t, full.Result()); got != 2 { |
| + t.Errorf("Docs-Seq on a full GET = %d, want 2 (the highest row returned)", got) |
| + } |
| + fullLen := full.Body.Len() |
| + |
| + sliced := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, |
| + "/d/"+doc.Sqid+"/updates?since=1", nil)) |
| + if sliced.Code != http.StatusOK { |
| + t.Fatalf("sliced GET = %d, want 200", sliced.Code) |
| + } |
| + if sliced.Body.Len() >= fullLen { |
| + t.Errorf("?since=1 returned %d bytes, the full log is %d — the slice was not applied", |
| + sliced.Body.Len(), fullLen) |
| + } |
| + if sliced.Body.Len() == 0 { |
| + t.Error("?since=1 returned nothing, but row 2 exists") |
| + } |
| + if got := docsSeq(t, sliced.Result()); got != 2 { |
| + t.Errorf("Docs-Seq on ?since=1 = %d, want 2", got) |
| + } |
| +} |
| + |
| +// TestEmptySliceEchoesSinceRatherThanTheCurrentMax is the regression |
| +// test for the design's D2, and it is written to fail against the |
| +// implementation the review rejected. |
| +// |
| +// An earlier draft defined Docs-Seq as "the highest seq included, or |
| +// the document's CURRENT MAX when the slice is empty". That second |
| +// clause needs its own MAX(seq) query, and reads go to the read pool |
| +// while writes go to the single-connection write pool — nothing |
| +// serializes the two. So a row could commit between the slice query |
| +// and the MAX query, and the client would receive an empty body |
| +// alongside a header naming a row it had just been told nothing |
| +// about. A client that trusts the header (which is the whole point of |
| +// the header) advances its cursor past that row and never fetches it |
| +// again: content present on the server, permanently invisible in that |
| +// tab until a manual reload. |
| +// |
| +// Deriving the header from the bytes in hand makes that |
| +// unrepresentable, which is what this asserts: with a row that the |
| +// slice deliberately excludes, the header must still echo `since` |
| +// rather than reporting the higher row. |
| +func TestEmptySliceEchoesSinceRatherThanTheCurrentMax(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Echo") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + // One row exists (seq 1). Asking for everything after seq 1 is |
| + // empty *now*; the point of the test is what the header says. |
| + postUpdate(t, app, doc.Sqid, "owner@example.com", validUpdate(t)) |
| + |
| + empty := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, |
| + "/d/"+doc.Sqid+"/updates?since=1", nil)) |
| + if empty.Code != http.StatusOK { |
| + t.Fatalf("empty slice GET = %d, want 200", empty.Code) |
| + } |
| + if empty.Body.Len() != 0 { |
| + t.Fatalf("?since=1 returned %d bytes, want none", empty.Body.Len()) |
| + } |
| + if got := docsSeq(t, empty.Result()); got != 1 { |
| + t.Errorf("Docs-Seq on an empty slice = %d, want 1 (an echo of since)", got) |
| + } |
| + |
| + // Now the sharp case: a row the client has NOT been given exists, |
| + // and the answer must still not name it. A MAX(seq) implementation |
| + // returns 2 here and loses row 2 in any client that trusts it. |
| + postUpdate(t, app, doc.Sqid, "owner@example.com", validUpdate(t)) |
| + stale := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, |
| + "/d/"+doc.Sqid+"/updates?since=2", nil)) |
| + if stale.Body.Len() != 0 { |
| + t.Fatalf("?since=2 returned %d bytes, want none", stale.Body.Len()) |
| + } |
| + if got := docsSeq(t, stale.Result()); got != 2 { |
| + t.Errorf("Docs-Seq on an empty slice = %d, want 2 (an echo of since, never a row the client did not receive)", got) |
| + } |
| +} |
| + |
| +// An unparseable or negative since is the client's error, and saying |
| +// so beats guessing at an intent: silently treating it as 0 would |
| +// resend the whole log, which is the expensive answer to a question |
| +// nobody asked. |
| +func TestMalformedSinceIsBadRequest(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Malformed") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + for _, since := range []string{"banana", "-1", "1.5", "9999999999999999999999"} { |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, |
| + "/d/"+doc.Sqid+"/updates?since="+since, nil)) |
| + if rec.Code != http.StatusBadRequest { |
| + t.Errorf("?since=%s = %d, want 400", since, rec.Code) |
| + } |
| + } |
| +} |
| + |
| +// A document nobody has written to yet answers 0, so a fresh editor |
| +// starts its cursor at 0 rather than at nothing. |
| +func TestDocsSeqOnAnEmptyDocumentIsZero(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Fresh") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, |
| + "/d/"+doc.Sqid+"/updates", nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET = %d, want 200", rec.Code) |
| + } |
| + if got := docsSeq(t, rec.Result()); got != 0 { |
| + t.Errorf("Docs-Seq on an empty document = %d, want 0", got) |
| + } |
| +} |
| + |
| +// ApplyUpdate's seq is only meaningful when it returns no error |
| +// (design D4). A rejected update must not consume a seq, or a client |
| +// that trusted the number would skip a row that later takes it. |
| +func TestRejectedUpdateConsumesNoSeq(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Rejected") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + seq, err := app.Store().ApplyUpdate(ctx, doc, validUpdate(t)) |
| + if err != nil { |
| + t.Fatalf("ApplyUpdate: %v", err) |
| + } |
| + if seq != 1 { |
| + t.Fatalf("first ApplyUpdate returned seq %d, want 1", seq) |
| + } |
| + |
| + if _, err := app.Store().ApplyUpdate(ctx, doc, []byte{0xff, 0x00, 0x13, 0x37}); err == nil { |
| + t.Fatal("ApplyUpdate accepted garbage") |
| + } |
| + |
| + seq, err = app.Store().ApplyUpdate(ctx, doc, validUpdate(t)) |
| + if err != nil { |
| + t.Fatalf("ApplyUpdate after a rejection: %v", err) |
| + } |
| + if seq != 2 { |
| + t.Errorf("ApplyUpdate after a rejection returned seq %d, want 2 — a refused update consumed a seq", seq) |
| + } |
| +} |
diff --git a/internal/docs/editor_view_test.go b/internal/docs/editor_view_test.go| index 52e5560..c2c1966 100644 |
| --- a/internal/docs/editor_view_test.go |
| +++ b/internal/docs/editor_view_test.go |
| @@ -20,7 +20,7 @@ func TestShowRendersContentWithoutScript(t *testing.T) { |
| owner := app.TestPerson(t, "reader@example.com") |
| doc, _ := app.Store().Create(ctx, owner, "Readable") |
| update := readFixture(t, "../../fixtures/vectors/fragment/simple.bin") |
| - if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil { |
| + if _, err := app.Store().ApplyUpdate(ctx, doc, update); err != nil { |
| t.Fatalf("ApplyUpdate: %v", err) |
| } |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 778790d..cc579d7 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -6,6 +6,7 @@ import ( |
| "io" |
| "log/slog" |
| "net/http" |
| + "strconv" |
| "strings" |
| "time" |
| @@ -35,6 +36,15 @@ type handlers struct { |
| render *renderer |
| logger *slog.Logger |
| store *Store |
| + |
| + // hub is the live-update fan-out (hub.go). It lives here rather |
| + // than on Store on purpose: Store is persistence and knows nothing |
| + // about transports, and doc_updates stays the plain append-only |
| + // table the design spec's §4.3 insisted on when it rejected |
| + // rastrillo/eventlog. The consequence is that a writer which does |
| + // not go through updatesPost does not notify — today that is only |
| + // sample seeding, which runs before any editor can be open. |
| + hub *hub |
| } |
| // listPage is GET /'s view data (spec §6.4). Title and the group |
| @@ -314,9 +324,14 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + since, ok := parseSince(w, r) |
| + if !ok { |
| + return |
| + } |
| + |
| var updates []DocUpdate |
| if err := h.store.db.WithContext(r.Context()). |
| - Where("doc_id = ?", doc.ID). |
| + Where("doc_id = ? AND seq > ?", doc.ID, since). |
| Order("seq ASC"). |
| Find(&updates).Error; err != nil { |
| h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err) |
| @@ -334,7 +349,19 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + // Docs-Seq is derived from the rows in hand — max(since, the |
| + // highest seq actually being written) — and never from a second |
| + // MAX(seq) query. See parseSince's own doc comment and the design's |
| + // D2 for the update this loses if that is ever "simplified". |
| + highest := since |
| + for _, u := range updates { |
| + if u.Seq > highest { |
| + highest = u.Seq |
| + } |
| + } |
| + |
| w.Header().Set("Content-Type", "application/octet-stream") |
| + w.Header().Set("Docs-Seq", strconv.FormatInt(highest, 10)) |
| for _, u := range updates { |
| if _, err := w.Write(u.UpdateBin); err != nil { |
| // The client went away mid-response; nothing left to do |
| @@ -375,7 +402,8 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - if err := h.store.ApplyUpdate(r.Context(), doc, update); err != nil { |
| + seq, err := h.store.ApplyUpdate(r.Context(), doc, update) |
| + if err != nil { |
| if updateIsClientFault(err) { |
| // Info, not Error: a client sending bytes the core |
| // cleanly rejects is an ordinary, expected outcome of |
| @@ -394,9 +422,41 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + // Both of these happen only on a nil error, which is what makes |
| + // the seq trustworthy (design D4). An error here can be returned |
| + // over an already-committed row — ApplyUpdate's trailing |
| + // updated_at bump lives outside its transaction — and in that case |
| + // the client retries, writes a duplicate row Yjs integrates |
| + // idempotently, and the broadcast happens on the retry instead. |
| + w.Header().Set("Docs-Seq", strconv.FormatInt(seq, 10)) |
| + h.hub.broadcast(doc.ID, seq) |
| + |
| w.WriteHeader(http.StatusNoContent) |
| } |
| +// parseSince reads the ?since= cursor off a request: the highest seq |
| +// the client already holds, so the response can carry only what came |
| +// after it. Absent means 0, which asks for the whole log — what the |
| +// editor's first load wants. |
| +// |
| +// A malformed or negative value is a 400 rather than a silent 0. |
| +// Treating "banana" as "send everything" would answer an expensive |
| +// question nobody asked, and would hide a client bug behind a |
| +// response that looks fine; a client that cannot say where it is |
| +// should be told so. |
| +func parseSince(w http.ResponseWriter, r *http.Request) (int64, bool) { |
| + raw := r.URL.Query().Get("since") |
| + if raw == "" { |
| + return 0, true |
| + } |
| + since, err := strconv.ParseInt(raw, 10, 64) |
| + if err != nil || since < 0 { |
| + http.Error(w, rastrillo.T(r, "docs.error.badsince"), http.StatusBadRequest) |
| + return 0, false |
| + } |
| + return since, true |
| +} |
| + |
| // updateIsClientFault classifies a Store.ApplyUpdate error: true means |
| // the core looked at the bytes the client just posted and refused |
| // them — a 400, the client's problem, and unremarkable enough to log |
diff --git a/internal/docs/hub.go b/internal/docs/hub.go| new file mode 100644 |
| index 0000000..7269fa7 |
| --- /dev/null |
| +++ b/internal/docs/hub.go |
| @@ -0,0 +1,105 @@ |
| +package docs |
| + |
| +import "sync" |
| + |
| +// hub is this instance's in-process fan-out: one document's accepted |
| +// update, announced to whoever currently has that document open. It |
| +// is the "broadcast primitive" rastrillo's own SSE handler |
| +// (jobs/events.go) says it deliberately did without — that one polls |
| +// its store once a second because it had nothing to wait on, and |
| +// this exists precisely so the stream handler here can wait instead |
| +// of poll. |
| +// |
| +// Scope, deliberately: one process. Docs is one instance per team |
| +// (the design spec's opening line, and its §3 "One SQLite file per |
| +// instance"), so there is no second process to reach and nothing here |
| +// needs a broker, a database channel, or a network hop. If Docs ever |
| +// runs more than one process per team, this type is the seam that has |
| +// to change, and nothing else does. |
| +// |
| +// What travels is a seq, and it is ADVISORY. It says "there is |
| +// something at least this new"; it never says "here is what changed" |
| +// and a subscriber must not treat it as a cursor assignment. That is |
| +// what makes the lossy one-slot channel below correct rather than |
| +// merely convenient: a subscriber that misses ten notifications still |
| +// fetches ?since=<its own cursor> and receives all ten updates. |
| +type hub struct { |
| + mu sync.Mutex |
| + subs map[int64]map[chan int64]struct{} |
| +} |
| + |
| +func newHub() *hub { |
| + return &hub{subs: make(map[int64]map[chan int64]struct{})} |
| +} |
| + |
| +// subscribe registers interest in docID and returns the channel to |
| +// wait on plus the function that unregisters it. The channel is |
| +// buffered one deep: it carries "something happened", not a queue of |
| +// everything that did. |
| +// |
| +// The returned stop is idempotent, because its caller (the stream |
| +// handler) defers it and may also have reason to stop early. Calling |
| +// it twice, or after the hub has forgotten the document, is a no-op |
| +// rather than a panic on a closed channel — note that stop does not |
| +// close the channel at all. Closing it would race a broadcast that |
| +// has already passed the map lookup and is about to send; leaving it |
| +// to the garbage collector costs nothing, since nothing else holds a |
| +// reference once it is out of the map. |
| +func (h *hub) subscribe(docID int64) (<-chan int64, func()) { |
| + ch := make(chan int64, 1) |
| + |
| + h.mu.Lock() |
| + if h.subs[docID] == nil { |
| + h.subs[docID] = make(map[chan int64]struct{}) |
| + } |
| + h.subs[docID][ch] = struct{}{} |
| + h.mu.Unlock() |
| + |
| + var once sync.Once |
| + return ch, func() { |
| + once.Do(func() { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + subs, ok := h.subs[docID] |
| + if !ok { |
| + return |
| + } |
| + delete(subs, ch) |
| + // Drop the inner map with its last subscriber, or this |
| + // map accumulates one empty entry per document ever |
| + // opened for the life of the process. |
| + if len(subs) == 0 { |
| + delete(h.subs, docID) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// broadcast tells docID's subscribers that seq exists. |
| +// |
| +// It runs on the HTTP request goroutine that just accepted an update |
| +// (handlers.go's updatesPost), so it must never block: a browser tab |
| +// on a wedged connection would otherwise hold a stranger's POST open. |
| +// Hence the default case — a full slot already means "go and fetch", |
| +// and a second notification would tell the subscriber nothing the |
| +// first one does not. |
| +func (h *hub) broadcast(docID, seq int64) { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + |
| + for ch := range h.subs[docID] { |
| + select { |
| + case ch <- seq: |
| + default: |
| + } |
| + } |
| +} |
| + |
| +// documentCount reports how many documents have at least one |
| +// subscriber. It exists for the tests that assert this map does not |
| +// grow forever; nothing in the app reads it. |
| +func (h *hub) documentCount() int { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + return len(h.subs) |
| +} |
diff --git a/internal/docs/hub_test.go b/internal/docs/hub_test.go| new file mode 100644 |
| index 0000000..827d0b3 |
| --- /dev/null |
| +++ b/internal/docs/hub_test.go |
| @@ -0,0 +1,154 @@ |
| +package docs |
| + |
| +import ( |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// recv waits briefly for a seq on ch. The wait exists because a |
| +// broadcast and its delivery are not the same instant; it is not a |
| +// synchronisation mechanism the code under test relies on, only a |
| +// bound on how long a test is willing to be wrong. |
| +func recv(t *testing.T, ch <-chan int64) (int64, bool) { |
| + t.Helper() |
| + select { |
| + case seq := <-ch: |
| + return seq, true |
| + case <-time.After(time.Second): |
| + return 0, false |
| + } |
| +} |
| + |
| +// quiet asserts nothing arrives. Shorter than recv's timeout because |
| +// a false negative here costs a slow test and a false positive costs |
| +// a wrong belief about isolation. |
| +func quiet(t *testing.T, ch <-chan int64) bool { |
| + t.Helper() |
| + select { |
| + case seq := <-ch: |
| + t.Errorf("received seq %d on a channel that should have been quiet", seq) |
| + return false |
| + case <-time.After(50 * time.Millisecond): |
| + return true |
| + } |
| +} |
| + |
| +func TestHubDeliversToItsOwnDocumentOnly(t *testing.T) { |
| + h := newHub() |
| + |
| + mine, stop := h.subscribe(1) |
| + defer stop() |
| + theirs, stopTheirs := h.subscribe(2) |
| + defer stopTheirs() |
| + |
| + h.broadcast(1, 7) |
| + |
| + if seq, ok := recv(t, mine); !ok || seq != 7 { |
| + t.Errorf("subscriber to document 1 got (%d, %v), want (7, true)", seq, ok) |
| + } |
| + quiet(t, theirs) |
| +} |
| + |
| +// A broadcast with nobody listening is not an error and not a leak. |
| +func TestHubBroadcastWithNoSubscribers(t *testing.T) { |
| + h := newHub() |
| + h.broadcast(42, 1) // must not panic or block |
| + |
| + ch, stop := h.subscribe(42) |
| + defer stop() |
| + quiet(t, ch) // and must not have been queued up for a later arrival |
| +} |
| + |
| +// Unsubscribing stops delivery, and doing it twice is safe: the SSE |
| +// handler defers its stop and may also stop early on a write failure. |
| +func TestHubUnsubscribeStopsDeliveryAndIsIdempotent(t *testing.T) { |
| + h := newHub() |
| + ch, stop := h.subscribe(1) |
| + |
| + stop() |
| + stop() |
| + |
| + h.broadcast(1, 3) |
| + quiet(t, ch) |
| +} |
| + |
| +// TestHubSlowSubscriberNeverBlocksABroadcaster is the property the |
| +// whole design rests on: broadcast runs on the HTTP request goroutine |
| +// that just accepted an update, so a subscriber that is not reading — |
| +// a browser tab on a wedged connection, a handler between select |
| +// turns — must never be able to hold that request open. The slot is |
| +// one deep and sends are non-blocking, which is safe precisely |
| +// because the seq is advisory: a client that misses a notification |
| +// still fetches ?since=cursor and gets everything. |
| +func TestHubSlowSubscriberNeverBlocksABroadcaster(t *testing.T) { |
| + h := newHub() |
| + ch, stop := h.subscribe(1) |
| + defer stop() |
| + |
| + done := make(chan struct{}) |
| + go func() { |
| + defer close(done) |
| + for seq := int64(1); seq <= 100; seq++ { |
| + h.broadcast(1, seq) |
| + } |
| + }() |
| + |
| + select { |
| + case <-done: |
| + case <-time.After(time.Second): |
| + t.Fatal("broadcast blocked on a subscriber that was not reading") |
| + } |
| + |
| + // The subscriber missed most of them, which is fine and is the |
| + // point — but it must still be told that something happened, or |
| + // it would never fetch. |
| + if _, ok := recv(t, ch); !ok { |
| + t.Error("a subscriber that fell behind was left with no pending notification at all") |
| + } |
| +} |
| + |
| +// The map must not grow forever: a document with no subscribers left |
| +// holds no entry. |
| +func TestHubForgetsDocumentsWithNoSubscribers(t *testing.T) { |
| + h := newHub() |
| + |
| + _, stopA := h.subscribe(1) |
| + _, stopB := h.subscribe(1) |
| + |
| + stopA() |
| + if got := h.documentCount(); got != 1 { |
| + t.Errorf("after one of two subscribers left, hub tracks %d documents, want 1", got) |
| + } |
| + stopB() |
| + if got := h.documentCount(); got != 0 { |
| + t.Errorf("after the last subscriber left, hub tracks %d documents, want 0", got) |
| + } |
| +} |
| + |
| +// Run with -race, this is the test that catches a missing lock. |
| +func TestHubUnderConcurrentUse(t *testing.T) { |
| + h := newHub() |
| + |
| + var wg sync.WaitGroup |
| + for i := 0; i < 8; i++ { |
| + wg.Add(1) |
| + go func(n int) { |
| + defer wg.Done() |
| + for j := 0; j < 50; j++ { |
| + ch, stop := h.subscribe(int64(n % 3)) |
| + h.broadcast(int64(n%3), int64(j)) |
| + select { |
| + case <-ch: |
| + default: |
| + } |
| + stop() |
| + } |
| + }(i) |
| + } |
| + wg.Wait() |
| + |
| + if got := h.documentCount(); got != 0 { |
| + t.Errorf("hub tracks %d documents after every subscriber left, want 0", got) |
| + } |
| +} |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 5f221cd..5e09493 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -75,6 +75,15 @@ var Catalog = rastrillo.Catalog{ |
| // browser sees for it (Task 5). |
| "docs.error.updatetoolarge": "That update is too large.", |
| + // docs.error.badsince answers GET /d/{sqid}/updates when the |
| + // ?since= cursor is not a non-negative integer (handlers.go's |
| + // parseSince). Only a broken or hand-typed client can produce it — |
| + // the editor's own cursor comes from a Docs-Seq header this app |
| + // wrote — so this is a key nobody should ever see, kept a catalog |
| + // key anyway because "nobody should see it" is exactly the |
| + // reasoning that leaves an untranslated literal in the codebase. |
| + "docs.error.badsince": "That update cursor is not valid.", |
| + |
| // docs.document.untitled is POST /documents' fallback title when |
| // the submitted one is empty or all whitespace — boilerplate copy |
| // this app writes, not the caller's own input, so it is a catalog |
diff --git a/internal/docs/seed.go b/internal/docs/seed.go| index 7a4dbc4..dce1f72 100644 |
| --- a/internal/docs/seed.go |
| +++ b/internal/docs/seed.go |
| @@ -91,7 +91,12 @@ func Seed(ctx context.Context, store *Store, owner int64) error { |
| if err != nil { |
| return fmt.Errorf("docs: seed: read update for %q: %w", sd.Title, err) |
| } |
| - if err := store.ApplyUpdate(ctx, doc, update); err != nil { |
| + // The assigned seq is discarded here on purpose: seeding runs |
| + // when a person's row is first created, before any editor of |
| + // theirs can be open, so there is nobody subscribed to notify |
| + // (design D5's accepted consequence — the broadcast lives in |
| + // updatesPost, not in Store). |
| + if _, err := store.ApplyUpdate(ctx, doc, update); err != nil { |
| return fmt.Errorf("docs: seed: apply update for %q: %w", sd.Title, err) |
| } |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index 2427f5e..8e0693b 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -491,22 +491,22 @@ func (e *updateRejection) Unwrap() error { return e.err } |
| // owner makes the Where match zero rows, First returns |
| // gorm.ErrRecordNotFound, and this method returns before replay is |
| // even called — no core touched, no row written. |
| -func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) error { |
| +func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) (int64, error) { |
| var ownerCheck Document |
| if err := owned(s.db.WithContext(ctx), doc.OwnerID). |
| Where("id = ?", doc.ID). |
| First(&ownerCheck).Error; err != nil { |
| - return fmt.Errorf("docs: apply update to document %d: verify owner %d: %w", doc.ID, doc.OwnerID, err) |
| + return 0, fmt.Errorf("docs: apply update to document %d: verify owner %d: %w", doc.ID, doc.OwnerID, err) |
| } |
| h, err := s.replay(ctx, doc) |
| if err != nil { |
| - return err |
| + return 0, err |
| } |
| defer h.Close(ctx) |
| if err := h.Apply(ctx, update); err != nil { |
| - return fmt.Errorf("docs: apply update to document %d: %w", doc.ID, &updateRejection{err}) |
| + return 0, fmt.Errorf("docs: apply update to document %d: %w", doc.ID, &updateRejection{err}) |
| } |
| // MAX(seq) then INSERT used to be two separate statements, not one |
| @@ -536,6 +536,14 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) e |
| // updates_test.go's TestConcurrentUpdatesDoNotCollideOnSeq (21 |
| // concurrent POSTs to one document) genuinely exhausted it on a |
| // real run before this transaction replaced it. |
| + // assignedSeq is captured out of the transaction closure so this |
| + // method can return it. It is only ever read after Transaction |
| + // returns nil, so it names a row that is committed and durable — |
| + // which is exactly the contract this method's callers rely on |
| + // (design D4: the seq is meaningful only when err == nil). Nothing |
| + // about the single-writer MAX(seq)->INSERT guarantee above changes |
| + // by observing the number the closure already computed. |
| + var assignedSeq int64 |
| if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| var maxSeq int64 |
| if err := tx.Model(&DocUpdate{}). |
| @@ -554,9 +562,10 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) e |
| if err := tx.Create(row).Error; err != nil { |
| return fmt.Errorf("docs: persist update for document %d: %w", doc.ID, err) |
| } |
| + assignedSeq = row.Seq |
| return nil |
| }); err != nil { |
| - return err |
| + return 0, err |
| } |
| // Bumping documents.updated_at here — not just appending the |
| @@ -581,7 +590,18 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) e |
| Model(&Document{}). |
| Where("id = ?", doc.ID). |
| Update("updated_at", time.Now().UTC()).Error; err != nil { |
| - return fmt.Errorf("docs: bump updated_at for document %d: %w", doc.ID, err) |
| + // The doc_updates row above is already committed at this |
| + // point, so this error is returned over durable content. That |
| + // is deliberate and its consequences are named in the design |
| + // (D4): the caller must not broadcast and must not report a |
| + // seq, the client's save queue treats the resulting 500 as |
| + // retryable, and the retry writes a duplicate row — which Yjs |
| + // integrates idempotently, costing one row against |
| + // maxRetainedUpdates rather than any content. Returning 0 here |
| + // rather than assignedSeq is what makes "seq is meaningful |
| + // only on a nil error" true by construction instead of by |
| + // convention. |
| + return 0, fmt.Errorf("docs: bump updated_at for document %d: %w", doc.ID, err) |
| } |
| - return nil |
| + return assignedSeq, nil |
| } |
diff --git a/internal/docs/store_test.go b/internal/docs/store_test.go| index 1a4cc97..886cb0e 100644 |
| --- a/internal/docs/store_test.go |
| +++ b/internal/docs/store_test.go |
| @@ -67,7 +67,7 @@ func TestTextComesFromTheCore(t *testing.T) { |
| // but targets root "content", which Store.Text no longer reads — |
| // it would read back "", proving nothing about the CRDT. |
| update := docs.ReadFragmentFixture(t, "hello") |
| - if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil { |
| + if _, err := app.Store().ApplyUpdate(ctx, doc, update); err != nil { |
| t.Fatalf("ApplyUpdate: %v", err) |
| } |
| @@ -125,7 +125,7 @@ func TestListOrdersByUpdatedAt(t *testing.T) { |
| time.Sleep(5 * time.Millisecond) |
| update := docs.ReadFixture(t, "hello") |
| - if err := app.Store().ApplyUpdate(ctx, older, update); err != nil { |
| + if _, err := app.Store().ApplyUpdate(ctx, older, update); err != nil { |
| t.Fatalf("ApplyUpdate(older): %v", err) |
| } |
| @@ -253,7 +253,7 @@ func TestApplyUpdateRefusesAnotherOwnersDocument(t *testing.T) { |
| // content assertions below go through Store.Text, which reads root |
| // "prosemirror" now — see TestTextComesFromTheCore's own comment. |
| update := docs.ReadFragmentFixture(t, "hello") |
| - if err := app.Store().ApplyUpdate(ctx, &tampered, update); err == nil { |
| + if _, err := app.Store().ApplyUpdate(ctx, &tampered, update); err == nil { |
| t.Fatal("ApplyUpdate returned nil for a mismatched owner — want a real error, before anything is written") |
| } |
| @@ -275,7 +275,7 @@ func TestApplyUpdateRefusesAnotherOwnersDocument(t *testing.T) { |
| // Prove the fix does not break the working path: the legitimate |
| // owner's own ApplyUpdate must still apply end to end. |
| - if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil { |
| + if _, err := app.Store().ApplyUpdate(ctx, doc, update); err != nil { |
| t.Fatalf("ApplyUpdate (legitimate owner): %v", err) |
| } |
| legit, err := app.Store().Text(ctx, doc) |