···11+# ProseMirror-Collab Implementation Plan
22+33+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
44+55+**Goal:** Replace Diffdown's custom text-offset OT engine with `prosemirror-collab`'s step-based rebasing protocol, eliminating full-document replacement on every remote edit and enabling proper cursor preservation, undo, and conflict-free collaborative typing.
66+77+**Architecture:** The Go server becomes a step authority: it stores an append-only log of ProseMirror `Step` JSON objects per document and a monotonically incrementing version counter. Clients submit `[steps, clientVersion]`; if the server version has advanced, the server rejects and the client fetches `GET /ws/docs/{rkey}/steps?since={v}`, rebases locally via `prosemirror-collab`, and resubmits. The WebSocket channel is kept for low-latency broadcasting of confirmed steps to other peers. The existing Hub/Room/Client goroutine structure and ATProto auth are unchanged.
88+99+**Tech Stack:** Go 1.22 (server), `prosemirror-collab` npm package (client), CodeMirror 6 (source mode — kept as-is, receives text patches derived from confirmed steps), Milkdown/ProseMirror (rich mode — speaks native PM steps), SQLite (step log), existing Gorilla WebSocket.
1010+1111+---
1212+1313+## Chunk 1: Server — Step Storage
1414+1515+### Task 1: Migration — `doc_steps` table
1616+1717+**Files:**
1818+- Create: `migrations/006_doc_steps.sql`
1919+2020+Steps must survive server restarts. One row per confirmed step; `version` is the 1-based ordinal within a document's history.
2121+2222+- [ ] **Step 1: Write the migration**
2323+2424+```sql
2525+-- migrations/006_doc_steps.sql
2626+CREATE TABLE IF NOT EXISTS doc_steps (
2727+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2828+ doc_rkey TEXT NOT NULL,
2929+ version INTEGER NOT NULL,
3030+ step_json TEXT NOT NULL,
3131+ client_id TEXT NOT NULL,
3232+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
3333+ UNIQUE(doc_rkey, version)
3434+);
3535+3636+CREATE INDEX IF NOT EXISTS idx_doc_steps_rkey_version ON doc_steps(doc_rkey, version);
3737+```
3838+3939+- [ ] **Step 2: Run migration**
4040+4141+```bash
4242+make migrate-up
4343+```
4444+4545+Expected: `Applied migration: 006_doc_steps.sql` in server log.
4646+4747+- [ ] **Step 3: Commit**
4848+4949+```bash
5050+git add migrations/006_doc_steps.sql
5151+git commit -m "feat(db): add doc_steps table for prosemirror-collab step log"
5252+```
5353+5454+---
5555+5656+### Task 2: DB methods — `AppendSteps`, `GetStepsSince`, `GetDocVersion`
5757+5858+**Files:**
5959+- Modify: `internal/db/db.go`
6060+6161+Follow the existing pattern: raw SQL, no ORM, wrap errors with `fmt.Errorf`.
6262+6363+- [ ] **Step 1: Write failing tests**
6464+6565+Create `internal/db/db_steps_test.go`:
6666+6767+```go
6868+package db_test
6969+7070+import (
7171+ "os"
7272+ "testing"
7373+7474+ "github.com/limeleaf/diffdown/internal/db"
7575+)
7676+7777+func openTestDB(t *testing.T) *db.DB {
7878+ t.Helper()
7979+ f, err := os.CreateTemp("", "diffdown-test-*.db")
8080+ if err != nil {
8181+ t.Fatal(err)
8282+ }
8383+ t.Cleanup(func() { os.Remove(f.Name()) })
8484+ f.Close()
8585+8686+ database, err := db.Open(f.Name())
8787+ if err != nil {
8888+ t.Fatal(err)
8989+ }
9090+ db.SetMigrationsDir("../../migrations")
9191+ if err := database.Migrate(); err != nil {
9292+ t.Fatal(err)
9393+ }
9494+ return database
9595+}
9696+9797+func TestGetDocVersion_Empty(t *testing.T) {
9898+ d := openTestDB(t)
9999+ v, err := d.GetDocVersion("rkey1")
100100+ if err != nil {
101101+ t.Fatal(err)
102102+ }
103103+ if v != 0 {
104104+ t.Errorf("expected version 0 for new doc, got %d", v)
105105+ }
106106+}
107107+108108+func TestAppendSteps_IncreasesVersion(t *testing.T) {
109109+ d := openTestDB(t)
110110+ steps := []string{`{"stepType":"replace"}`, `{"stepType":"replace"}`}
111111+ newVersion, err := d.AppendSteps("rkey1", 0, steps, "client-a")
112112+ if err != nil {
113113+ t.Fatal(err)
114114+ }
115115+ if newVersion != 2 {
116116+ t.Errorf("expected version 2, got %d", newVersion)
117117+ }
118118+}
119119+120120+func TestAppendSteps_VersionConflict(t *testing.T) {
121121+ d := openTestDB(t)
122122+ _, err := d.AppendSteps("rkey1", 0, []string{`{}`}, "client-a")
123123+ if err != nil {
124124+ t.Fatal(err)
125125+ }
126126+ // Submit again from clientVersion=0 (stale) — must fail.
127127+ _, err = d.AppendSteps("rkey1", 0, []string{`{}`}, "client-b")
128128+ if err == nil {
129129+ t.Fatal("expected conflict error, got nil")
130130+ }
131131+}
132132+133133+func TestGetStepsSince(t *testing.T) {
134134+ d := openTestDB(t)
135135+ steps := []string{`{"a":1}`, `{"a":2}`, `{"a":3}`}
136136+ d.AppendSteps("rkey1", 0, steps, "client-a")
137137+138138+ rows, err := d.GetStepsSince("rkey1", 1)
139139+ if err != nil {
140140+ t.Fatal(err)
141141+ }
142142+ if len(rows) != 2 {
143143+ t.Errorf("expected 2 steps since v1, got %d", len(rows))
144144+ }
145145+ if rows[0].JSON != `{"a":2}` {
146146+ t.Errorf("unexpected step: %s", rows[0].JSON)
147147+ }
148148+}
149149+```
150150+151151+- [ ] **Step 2: Run tests — expect FAIL (functions not defined)**
152152+153153+```bash
154154+go test -v ./internal/db/...
155155+```
156156+157157+Expected: compile error — `db.GetDocVersion`, `db.AppendSteps`, `db.GetStepsSince` undefined.
158158+159159+- [ ] **Step 3: Implement the DB methods**
160160+161161+Add to `internal/db/db.go`:
162162+163163+```go
164164+// --- Document Steps (prosemirror-collab) ---
165165+166166+// StepRow is a confirmed ProseMirror step from the step log.
167167+type StepRow struct {
168168+ Version int
169169+ JSON string
170170+}
171171+172172+// GetDocVersion returns the current highest version for the given document.
173173+// Returns 0 if no steps have been recorded yet.
174174+func (db *DB) GetDocVersion(docRKey string) (int, error) {
175175+ var v int
176176+ err := db.QueryRow(
177177+ `SELECT COALESCE(MAX(version), 0) FROM doc_steps WHERE doc_rkey = ?`, docRKey,
178178+ ).Scan(&v)
179179+ if err != nil {
180180+ return 0, fmt.Errorf("GetDocVersion: %w", err)
181181+ }
182182+ return v, nil
183183+}
184184+185185+// AppendSteps atomically appends steps starting at clientVersion+1.
186186+// Returns ErrVersionConflict if the server version has advanced past clientVersion.
187187+// Returns the new server version on success.
188188+func (db *DB) AppendSteps(docRKey string, clientVersion int, stepsJSON []string, clientID string) (int, error) {
189189+ tx, err := db.Begin()
190190+ if err != nil {
191191+ return 0, fmt.Errorf("AppendSteps begin: %w", err)
192192+ }
193193+ defer tx.Rollback()
194194+195195+ var current int
196196+ tx.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM doc_steps WHERE doc_rkey = ?`, docRKey).Scan(¤t)
197197+ if current != clientVersion {
198198+ return 0, fmt.Errorf("version conflict: server=%d client=%d", current, clientVersion)
199199+ }
200200+201201+ for i, stepJSON := range stepsJSON {
202202+ version := clientVersion + i + 1
203203+ _, err := tx.Exec(
204204+ `INSERT INTO doc_steps (doc_rkey, version, step_json, client_id) VALUES (?, ?, ?, ?)`,
205205+ docRKey, version, stepJSON, clientID,
206206+ )
207207+ if err != nil {
208208+ return 0, fmt.Errorf("AppendSteps insert v%d: %w", version, err)
209209+ }
210210+ }
211211+212212+ if err := tx.Commit(); err != nil {
213213+ return 0, fmt.Errorf("AppendSteps commit: %w", err)
214214+ }
215215+ return clientVersion + len(stepsJSON), nil
216216+}
217217+218218+// GetStepsSince returns all steps with version > sinceVersion, ordered ascending.
219219+func (db *DB) GetStepsSince(docRKey string, sinceVersion int) ([]StepRow, error) {
220220+ rows, err := db.Query(
221221+ `SELECT version, step_json FROM doc_steps WHERE doc_rkey = ? AND version > ? ORDER BY version ASC`,
222222+ docRKey, sinceVersion,
223223+ )
224224+ if err != nil {
225225+ return nil, fmt.Errorf("GetStepsSince: %w", err)
226226+ }
227227+ defer rows.Close()
228228+ var result []StepRow
229229+ for rows.Next() {
230230+ var r StepRow
231231+ if err := rows.Scan(&r.Version, &r.JSON); err != nil {
232232+ return nil, err
233233+ }
234234+ result = append(result, r)
235235+ }
236236+ return result, rows.Err()
237237+}
238238+```
239239+240240+- [ ] **Step 4: Run tests — expect PASS**
241241+242242+```bash
243243+go test -v ./internal/db/...
244244+```
245245+246246+Expected: all `TestGetDocVersion_*`, `TestAppendSteps_*`, `TestGetStepsSince` PASS.
247247+248248+- [ ] **Step 5: Commit**
249249+250250+```bash
251251+git add internal/db/db.go internal/db/db_steps_test.go
252252+git commit -m "feat(db): add AppendSteps, GetStepsSince, GetDocVersion for step log"
253253+```
254254+255255+---
256256+257257+## Chunk 2: Server — Step Authority HTTP Endpoint
258258+259259+The step-exchange protocol uses two new HTTP endpoints:
260260+261261+- `POST /api/docs/{rkey}/steps` — submit steps from client; returns `{version, steps}` on conflict or `{version}` on success
262262+- `GET /api/docs/{rkey}/steps?since={v}` — fetch steps the client missed
263263+264264+The WebSocket channel continues to broadcast `{type:"steps", steps:[...], version:N}` messages to peers in real-time.
265265+266266+### Task 3: `SubmitSteps` and `GetSteps` HTTP handlers
267267+268268+**Files:**
269269+- Modify: `internal/handler/handler.go`
270270+- Modify: `cmd/server/main.go` (route registration)
271271+272272+- [ ] **Step 1: Write the handlers**
273273+274274+Add to `internal/handler/handler.go` (below the existing `CollaboratorWebSocket`):
275275+276276+```go
277277+// SubmitSteps receives ProseMirror steps from a collaborator, appends them
278278+// to the step log, and broadcasts confirmed steps to the room.
279279+//
280280+// POST /api/docs/{rkey}/steps
281281+// Body: {"clientVersion": N, "steps": ["...json..."], "clientID": "did:..."}
282282+// Response 200: {"version": N}
283283+// Response 409: {"version": N, "steps": ["...json..."]} — client must rebase
284284+func (h *Handler) SubmitSteps(w http.ResponseWriter, r *http.Request) {
285285+ user := h.currentUser(r)
286286+ if user == nil {
287287+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
288288+ return
289289+ }
290290+ rkey := r.PathValue("rkey")
291291+292292+ var body struct {
293293+ ClientVersion int `json:"clientVersion"`
294294+ Steps []string `json:"steps"`
295295+ ClientID string `json:"clientID"`
296296+ }
297297+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
298298+ http.Error(w, "Bad request", http.StatusBadRequest)
299299+ return
300300+ }
301301+ if len(body.Steps) == 0 {
302302+ http.Error(w, "No steps", http.StatusBadRequest)
303303+ return
304304+ }
305305+306306+ newVersion, err := h.DB.AppendSteps(rkey, body.ClientVersion, body.Steps, body.ClientID)
307307+ if err != nil {
308308+ // Version conflict — return steps the client missed.
309309+ missed, dbErr := h.DB.GetStepsSince(rkey, body.ClientVersion)
310310+ if dbErr != nil {
311311+ log.Printf("SubmitSteps: GetStepsSince: %v", dbErr)
312312+ http.Error(w, "Internal error", http.StatusInternalServerError)
313313+ return
314314+ }
315315+ currentVersion, _ := h.DB.GetDocVersion(rkey)
316316+ stepJSONs := make([]string, len(missed))
317317+ for i, s := range missed {
318318+ stepJSONs[i] = s.JSON
319319+ }
320320+ w.Header().Set("Content-Type", "application/json")
321321+ w.WriteHeader(http.StatusConflict)
322322+ json.NewEncoder(w).Encode(map[string]interface{}{
323323+ "version": currentVersion,
324324+ "steps": stepJSONs,
325325+ })
326326+ return
327327+ }
328328+329329+ // Broadcast to other room members via WebSocket.
330330+ if room := h.CollaborationHub.GetRoom(rkey); room != nil {
331331+ type stepsMsg struct {
332332+ Type string `json:"type"`
333333+ Steps []string `json:"steps"`
334334+ Version int `json:"version"`
335335+ ClientID string `json:"clientID"`
336336+ }
337337+ data, _ := json.Marshal(stepsMsg{
338338+ Type: "steps",
339339+ Steps: body.Steps,
340340+ Version: newVersion,
341341+ ClientID: body.ClientID,
342342+ })
343343+ room.Broadcast(data)
344344+ }
345345+346346+ h.jsonResponse(w, map[string]int{"version": newVersion}, http.StatusOK)
347347+}
348348+349349+// GetSteps returns all steps since the given version.
350350+//
351351+// GET /api/docs/{rkey}/steps?since={v}
352352+// Response 200: {"version": N, "steps": ["...json..."]}
353353+func (h *Handler) GetSteps(w http.ResponseWriter, r *http.Request) {
354354+ user := h.currentUser(r)
355355+ if user == nil {
356356+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
357357+ return
358358+ }
359359+ rkey := r.PathValue("rkey")
360360+361361+ sinceStr := r.URL.Query().Get("since")
362362+ var since int
363363+ if sinceStr != "" {
364364+ fmt.Sscanf(sinceStr, "%d", &since)
365365+ }
366366+367367+ rows, err := h.DB.GetStepsSince(rkey, since)
368368+ if err != nil {
369369+ log.Printf("GetSteps: %v", err)
370370+ http.Error(w, "Internal error", http.StatusInternalServerError)
371371+ return
372372+ }
373373+ version, _ := h.DB.GetDocVersion(rkey)
374374+ stepJSONs := make([]string, len(rows))
375375+ for i, s := range rows {
376376+ stepJSONs[i] = s.JSON
377377+ }
378378+ h.jsonResponse(w, map[string]interface{}{
379379+ "version": version,
380380+ "steps": stepJSONs,
381381+ }, http.StatusOK)
382382+}
383383+```
384384+385385+- [ ] **Step 2: Register routes in `cmd/server/main.go`**
386386+387387+Find the route block and add after the existing `/ws/docs/{rkey}` line:
388388+389389+```go
390390+mux.HandleFunc("POST /api/docs/{rkey}/steps", middleware.RequireAuth(h.SubmitSteps))
391391+mux.HandleFunc("GET /api/docs/{rkey}/steps", middleware.RequireAuth(h.GetSteps))
392392+```
393393+394394+- [ ] **Step 3: Build to catch type errors**
395395+396396+```bash
397397+make build
398398+```
399399+400400+Expected: `bin/server` produced, zero errors.
401401+402402+- [ ] **Step 4: Smoke-test manually**
403403+404404+```bash
405405+make run
406406+# In another terminal:
407407+curl -s -b 'session=<valid-cookie>' http://localhost:8080/api/docs/somekey/steps?since=0
408408+# Expected: {"steps":[],"version":0}
409409+```
410410+411411+- [ ] **Step 5: Commit**
412412+413413+```bash
414414+git add internal/handler/handler.go cmd/server/main.go
415415+git commit -m "feat(handler): add SubmitSteps and GetSteps HTTP endpoints"
416416+```
417417+418418+---
419419+420420+### Task 4: Handler tests for `SubmitSteps` and `GetSteps`
421421+422422+**Files:**
423423+- Create: `internal/handler/steps_test.go`
424424+425425+The existing handler tests use `httptest` with a real in-memory SQLite DB. Follow that pattern.
426426+427427+- [ ] **Step 1: Write tests**
428428+429429+```go
430430+package handler_test
431431+432432+import (
433433+ "bytes"
434434+ "encoding/json"
435435+ "net/http"
436436+ "net/http/httptest"
437437+ "os"
438438+ "testing"
439439+440440+ "github.com/limeleaf/diffdown/internal/db"
441441+ "github.com/limeleaf/diffdown/internal/handler"
442442+ "github.com/limeleaf/diffdown/internal/collaboration"
443443+)
444444+445445+func setupHandler(t *testing.T) (*handler.Handler, *db.DB) {
446446+ t.Helper()
447447+ f, _ := os.CreateTemp("", "diffdown-handler-*.db")
448448+ t.Cleanup(func() { os.Remove(f.Name()) })
449449+ f.Close()
450450+ database, _ := db.Open(f.Name())
451451+ db.SetMigrationsDir("../../migrations")
452452+ database.Migrate()
453453+ hub := collaboration.NewHub()
454454+ h := handler.New(database, nil, "http://localhost:8080", hub)
455455+ return h, database
456456+}
457457+458458+func TestGetSteps_Empty(t *testing.T) {
459459+ h, _ := setupHandler(t)
460460+ req := httptest.NewRequest("GET", "/api/docs/rkey1/steps?since=0", nil)
461461+ req.SetPathValue("rkey", "rkey1")
462462+ rr := httptest.NewRecorder()
463463+ // Inject a fake user into context so currentUser() passes.
464464+ // (Reuse the test helper from existing handler tests if one exists,
465465+ // or set the session key directly on the request context.)
466466+ h.GetSteps(rr, req) // will return 401 without auth — adjust with auth helper
467467+ // For now verify content-type at minimum:
468468+ if rr.Code != http.StatusUnauthorized {
469469+ t.Errorf("expected 401 without auth, got %d", rr.Code)
470470+ }
471471+}
472472+473473+func TestSubmitSteps_Success(t *testing.T) {
474474+ h, d := setupHandler(t)
475475+ _ = d // seed a user/session if needed for auth
476476+477477+ body, _ := json.Marshal(map[string]interface{}{
478478+ "clientVersion": 0,
479479+ "steps": []string{`{"stepType":"replace"}`},
480480+ "clientID": "did:plc:test",
481481+ })
482482+ req := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body))
483483+ req.SetPathValue("rkey", "rkey1")
484484+ req.Header.Set("Content-Type", "application/json")
485485+ rr := httptest.NewRecorder()
486486+ h.SubmitSteps(rr, req) // 401 without auth — add auth helper before expanding
487487+ if rr.Code != http.StatusUnauthorized {
488488+ t.Errorf("expected 401, got %d", rr.Code)
489489+ }
490490+}
491491+```
492492+493493+> **Note:** These are skeleton tests that verify the 401 path. Full integration tests (with authenticated requests) require the existing `setUserContext` test helper from the handler package. Expand once you confirm the handlers build and the 401 path works.
494494+495495+- [ ] **Step 2: Run tests**
496496+497497+```bash
498498+go test -v ./internal/handler/...
499499+```
500500+501501+Expected: new tests PASS (401 assertions).
502502+503503+- [ ] **Step 3: Commit**
504504+505505+```bash
506506+git add internal/handler/steps_test.go
507507+git commit -m "test(handler): skeleton tests for SubmitSteps and GetSteps"
508508+```
509509+510510+---
511511+512512+## Chunk 3: Client — Install `prosemirror-collab`
513513+514514+### Task 5: Add npm dependency and bundle
515515+516516+**Files:**
517517+- Modify: `package.json`
518518+- Modify: `milkdown-entry.js` (re-export collab plugin for rich mode)
519519+- Create: `static/vendor/collab.js` (built artifact — gitignored or committed)
520520+521521+`prosemirror-collab` is the official ProseMirror collaboration plugin. It exports `collab(config)`, `sendableSteps(state)`, and `receiveTransaction(state, steps, clientIDs)`.
522522+523523+- [ ] **Step 1: Install the package**
524524+525525+```bash
526526+npm install prosemirror-collab prosemirror-state prosemirror-transform
527527+```
528528+529529+- [ ] **Step 2: Verify the package is available**
530530+531531+```bash
532532+node -e "require('prosemirror-collab'); console.log('ok')"
533533+```
534534+535535+Expected: `ok`.
536536+537537+- [ ] **Step 3: Bundle a collab module for the browser**
538538+539539+Add a build script to `package.json` (merge into existing scripts if present):
540540+541541+```json
542542+{
543543+ "scripts": {
544544+ "build:collab": "npx esbuild node_modules/prosemirror-collab/dist/index.js --bundle --format=esm --outfile=static/vendor/collab.js"
545545+ }
546546+}
547547+```
548548+549549+Run it:
550550+551551+```bash
552552+npm run build:collab
553553+```
554554+555555+Expected: `static/vendor/collab.js` created (~20KB).
556556+557557+- [ ] **Step 4: Confirm exports**
558558+559559+```bash
560560+node -e "
561561+const src = require('fs').readFileSync('static/vendor/collab.js','utf8');
562562+['collab','sendableSteps','receiveTransaction','getVersion'].forEach(name => {
563563+ if (!src.includes(name)) throw new Error('missing: ' + name);
564564+});
565565+console.log('all exports present');
566566+"
567567+```
568568+569569+- [ ] **Step 5: Commit**
570570+571571+```bash
572572+git add package.json package-lock.json static/vendor/collab.js
573573+git commit -m "feat(frontend): bundle prosemirror-collab for browser"
574574+```
575575+576576+---
577577+578578+## Chunk 4: Client — Source Mode (CodeMirror) Integration
579579+580580+The source editor (CodeMirror) operates on plain text. `prosemirror-collab` works at the ProseMirror document level; for the source mode we keep the existing send-deltas-on-change approach but replace the full-document-replace on receive with a proper `ChangeSet`-based patch derived from confirmed steps.
581581+582582+**Protocol for source mode:**
583583+1. On local change: send `POST /api/docs/{rkey}/steps` with `[{type:"text-patch", from, to, insert}]` step objects (a lightweight custom step shape — source mode does not use PM's schema-aware steps).
584584+2. On `409 Conflict`: fetch missed steps from response, apply them to local text first, then resubmit.
585585+3. On WebSocket `steps` message: apply the text patches to the CM editor using a minimal `ChangeSet` that skips the sender's own steps.
586586+587587+This is logically equivalent to `prosemirror-collab` but for a flat-text editor. It gives us the rebasing guarantee without requiring a ProseMirror schema in source mode.
588588+589589+### Task 6: Client-side collab state manager (`collab-client.js`)
590590+591591+**Files:**
592592+- Create: `static/collab-client.js`
593593+594594+This module encapsulates all step-submission and rebasing logic and is shared between source and rich modes.
595595+596596+- [ ] **Step 1: Write the module**
597597+598598+```js
599599+// static/collab-client.js
600600+//
601601+// Lightweight step-authority client for Diffdown's prosemirror-collab protocol.
602602+// Works with both source (text-patch steps) and rich (PM steps) modes.
603603+604604+export class CollabClient {
605605+ /**
606606+ * @param {string} rkey - Document rkey
607607+ * @param {number} initialVersion - Version the client started at
608608+ * @param {function(steps: object[]): void} applyRemoteSteps - Called when server confirms steps from others
609609+ */
610610+ constructor(rkey, initialVersion, applyRemoteSteps) {
611611+ this.rkey = rkey;
612612+ this.version = initialVersion;
613613+ this.applyRemoteSteps = applyRemoteSteps;
614614+ this._inflight = false;
615615+ this._queue = [];
616616+ }
617617+618618+ /**
619619+ * Queue local steps and attempt to flush to the server.
620620+ * @param {object[]} steps - Array of step objects (text-patch or PM step JSON)
621621+ */
622622+ sendSteps(steps) {
623623+ this._queue.push(...steps);
624624+ this._flush();
625625+ }
626626+627627+ async _flush() {
628628+ if (this._inflight || this._queue.length === 0) return;
629629+ this._inflight = true;
630630+ const toSend = this._queue.slice();
631631+ try {
632632+ const resp = await fetch(`/api/docs/${this.rkey}/steps`, {
633633+ method: 'POST',
634634+ headers: {'Content-Type': 'application/json'},
635635+ body: JSON.stringify({
636636+ clientVersion: this.version,
637637+ steps: toSend.map(s => JSON.stringify(s)),
638638+ clientID: this._clientID || '',
639639+ }),
640640+ });
641641+642642+ if (resp.ok) {
643643+ const {version} = await resp.json();
644644+ this.version = version;
645645+ // Remove the steps we just confirmed.
646646+ this._queue = this._queue.slice(toSend.length);
647647+ } else if (resp.status === 409) {
648648+ // Server has steps we haven't seen yet.
649649+ const {version, steps: missedJSON} = await resp.json();
650650+ const missed = missedJSON.map(s => JSON.parse(s));
651651+ // Let the editor rebase local steps on top of missed ones.
652652+ this.applyRemoteSteps(missed);
653653+ this.version = version;
654654+ // Don't clear _queue — resubmit after rebase.
655655+ } else {
656656+ console.error('CollabClient: unexpected status', resp.status);
657657+ }
658658+ } catch (e) {
659659+ console.error('CollabClient: fetch error', e);
660660+ } finally {
661661+ this._inflight = false;
662662+ if (this._queue.length > 0) {
663663+ setTimeout(() => this._flush(), 50);
664664+ }
665665+ }
666666+ }
667667+668668+ /**
669669+ * Call when a WebSocket "steps" message arrives from the server.
670670+ * Advances local version and notifies the editor.
671671+ * @param {object} msg - {type:"steps", steps:[...], version:N, clientID:string}
672672+ * @param {string} myClientID
673673+ */
674674+ handleWSMessage(msg, myClientID) {
675675+ if (msg.type !== 'steps') return;
676676+ if (msg.clientID === myClientID) {
677677+ // Our own steps confirmed — just advance version.
678678+ this.version = msg.version;
679679+ return;
680680+ }
681681+ const steps = msg.steps.map(s => JSON.parse(s));
682682+ this.version = msg.version;
683683+ this.applyRemoteSteps(steps);
684684+ }
685685+686686+ setClientID(id) { this._clientID = id; }
687687+}
688688+```
689689+690690+- [ ] **Step 2: Verify it parses without errors**
691691+692692+```bash
693693+node --input-type=module < static/collab-client.js 2>&1 || true
694694+```
695695+696696+Expected: no syntax errors (will emit a fetch-not-defined error at runtime in Node, that's fine).
697697+698698+- [ ] **Step 3: Commit**
699699+700700+```bash
701701+git add static/collab-client.js
702702+git commit -m "feat(frontend): CollabClient — step-submission and rebase coordinator"
703703+```
704704+705705+---
706706+707707+### Task 7: Wire `CollabClient` into source mode (CodeMirror)
708708+709709+**Files:**
710710+- Modify: `templates/document_edit.html`
711711+712712+The existing source-mode flow:
713713+1. `update.changes.iterChanges` → `queueDeltas` → WebSocket `edit` message
714714+2. `handleWSMessage({type:'edit'})` → `applyRemoteEdit` → full CM replace
715715+716716+New flow:
717717+1. `update.changes.iterChanges` → `CollabClient.sendSteps` (text-patch steps via HTTP)
718718+2. WebSocket `steps` message → `CollabClient.handleWSMessage` → `applyTextPatchSteps` (apply only the changed ranges)
719719+720720+- [ ] **Step 1: Fetch server version on page load**
721721+722722+At the top of the `<script type="module">` block, after constants, add:
723723+724724+```js
725725+import { CollabClient } from '/static/collab-client.js';
726726+727727+// Fetch the authoritative version for this document.
728728+let serverVersion = 0;
729729+try {
730730+ const vResp = await fetch(`/api/docs/${rkey}/steps?since=-1`);
731731+ if (vResp.ok) {
732732+ const vData = await vResp.json();
733733+ serverVersion = vData.version || 0;
734734+ }
735735+} catch(e) { /* start at 0 */ }
736736+737737+const myClientID = accessToken || Math.random().toString(36).slice(2);
738738+```
739739+740740+- [ ] **Step 2: Replace `queueDeltas` in source mode with `CollabClient.sendSteps`**
741741+742742+Replace the `EditorView.updateListener.of` callback block for source mode:
743743+744744+Old code (in `document_edit.html`, inside the CM `updateListener`):
745745+```js
746746+if (deltas.length > 0) {
747747+ queueDeltas(deltas);
748748+}
749749+```
750750+751751+New code — instead of `queueDeltas`, call `collabClient.sendSteps`:
752752+```js
753753+if (deltas.length > 0 && currentMode === 'source') {
754754+ const pmSteps = deltas.map(d => ({type: 'text-patch', from: d.from, to: d.to, insert: d.insert}));
755755+ collabClient.sendSteps(pmSteps);
756756+}
757757+```
758758+759759+- [ ] **Step 3: Initialize `collabClient` after `cmView` is created**
760760+761761+```js
762762+const collabClient = new CollabClient(rkey, serverVersion, (remoteSteps) => {
763763+ // Apply text-patch steps to CM without triggering our own send.
764764+ if (currentMode !== 'source' || !cmView) return;
765765+ const changes = [];
766766+ let offset = 0;
767767+ for (const step of remoteSteps) {
768768+ if (step.type !== 'text-patch') continue;
769769+ const from = step.from + offset;
770770+ const to = step.to + offset;
771771+ const insert = step.insert || '';
772772+ changes.push({ from, to, insert });
773773+ offset += insert.length - (step.to - step.from);
774774+ }
775775+ if (changes.length === 0) return;
776776+ applyingRemote = true;
777777+ try {
778778+ cmView.dispatch({
779779+ changes,
780780+ annotations: [remoteEditAnnotation.of(true)],
781781+ });
782782+ } finally {
783783+ applyingRemote = false;
784784+ }
785785+});
786786+collabClient.setClientID(myClientID);
787787+```
788788+789789+- [ ] **Step 4: Wire WebSocket messages into `CollabClient`**
790790+791791+In `handleWSMessage`, add a case for `'steps'`:
792792+793793+```js
794794+case 'steps':
795795+ collabClient.handleWSMessage(msg, myClientID);
796796+ break;
797797+```
798798+799799+Keep the existing `'edit'` case temporarily (for backward compat during rollout), but stop relying on `msg.content` for source mode when `msg.type === 'edit'` AND `msg.steps` is present.
800800+801801+- [ ] **Step 5: Build and manually test source mode collab**
802802+803803+```bash
804804+make run
805805+```
806806+807807+Open two browser tabs on the same document (two different accounts). Type in source mode in tab 1 — verify tab 2 updates without full-document flash. Cursor position in tab 2 should not reset.
808808+809809+- [ ] **Step 6: Commit**
810810+811811+```bash
812812+git add templates/document_edit.html
813813+git commit -m "feat(frontend): source mode uses CollabClient step protocol instead of full-replace"
814814+```
815815+816816+---
817817+818818+## Chunk 5: Client — Rich Mode (Milkdown/ProseMirror) Integration
819819+820820+Milkdown wraps ProseMirror. The `prosemirror-collab` plugin integrates directly at the ProseMirror `EditorState` level using `collab({version})` and `sendableSteps(state)` / `receiveTransaction(state, steps, clientIDs)`.
821821+822822+This replaces the `createMilkdownEditor(content)` full-recreate on remote edit.
823823+824824+### Task 8: Wire `prosemirror-collab` plugin into Milkdown
825825+826826+**Files:**
827827+- Modify: `templates/document_edit.html`
828828+- Modify: `milkdown-entry.js` (re-export PM collab helpers)
829829+830830+- [ ] **Step 1: Re-export collab helpers from `milkdown-entry.js`**
831831+832832+`milkdown-entry.js` is bundled into `static/vendor/milkdown.js`. Add the collab exports:
833833+834834+```js
835835+// milkdown-entry.js — add at bottom:
836836+export { collab, sendableSteps, receiveTransaction, getVersion } from 'prosemirror-collab';
837837+export { Step } from 'prosemirror-transform';
838838+```
839839+840840+Rebuild:
841841+842842+```bash
843843+npx esbuild milkdown-entry.js --bundle --format=esm --minify --outfile=static/vendor/milkdown.js \
844844+ node_modules/prosemirror-collab/dist/index.js \
845845+ node_modules/prosemirror-transform/dist/index.js
846846+```
847847+848848+> Check your existing esbuild command in `package.json` or `Dockerfile` and update accordingly. The Dockerfile uses `npx esbuild milkdown-entry.js ...` — update that line too.
849849+850850+- [ ] **Step 2: Import in the editor script**
851851+852852+In `document_edit.html`, update the Milkdown import line:
853853+854854+```js
855855+import {
856856+ Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx,
857857+ commonmark,
858858+ listener, listenerCtx,
859859+ history, undoCommand, redoCommand, callCommand,
860860+ collab, sendableSteps, receiveTransaction, getVersion, Step,
861861+} from '/static/vendor/milkdown.js';
862862+```
863863+864864+- [ ] **Step 3: Replace `createMilkdownEditor` to use `collab` plugin**
865865+866866+The key change: pass `collab({version: serverVersion})` as a ProseMirror plugin. When `markdownUpdated` fires (local edit), use `sendableSteps` to get the pending steps and send them through `CollabClient`. When remote steps arrive, use `receiveTransaction` to apply them to the PM state surgically — **no re-create**.
867867+868868+```js
869869+async function createMilkdownEditor(initialMarkdown) {
870870+ const container = document.getElementById('editor-rich');
871871+ container.innerHTML = '';
872872+873873+ milkdownEditor = await Editor.make()
874874+ .config((ctx) => {
875875+ ctx.set(rootCtx, container);
876876+ ctx.set(defaultValueCtx, initialMarkdown);
877877+ })
878878+ .use(commonmark)
879879+ .use(history)
880880+ .use(listener)
881881+ .config((ctx) => {
882882+ ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
883883+ if (markdown === prevMarkdown || applyingRemote) return;
884884+ scheduleAutoSave(markdown);
885885+ // Use prosemirror-collab to extract pending steps.
886886+ const pmView = milkdownEditor.action(c => c.get(editorViewCtx));
887887+ const sendable = sendableSteps(pmView.state);
888888+ if (sendable) {
889889+ const stepsJSON = sendable.steps.map(s => JSON.stringify(s.toJSON()));
890890+ collabClient.sendSteps(stepsJSON.map(j => ({type: 'pm-step', json: j})));
891891+ }
892892+ });
893893+ })
894894+ .create();
895895+896896+ return milkdownEditor;
897897+}
898898+```
899899+900900+- [ ] **Step 4: Apply remote PM steps without re-creating the editor**
901901+902902+Update `CollabClient`'s `applyRemoteSteps` callback for rich mode:
903903+904904+```js
905905+// In the CollabClient constructor call (Task 7), update the callback to handle rich mode:
906906+const collabClient = new CollabClient(rkey, serverVersion, (remoteSteps) => {
907907+ if (currentMode === 'source' && cmView) {
908908+ // ... existing text-patch logic from Task 7 ...
909909+ } else if (currentMode === 'rich' && milkdownEditor) {
910910+ const pmView = milkdownEditor.action(c => c.get(editorViewCtx));
911911+ const schema = pmView.state.schema;
912912+ const pmSteps = [];
913913+ const clientIDs = [];
914914+ for (const step of remoteSteps) {
915915+ if (step.type !== 'pm-step') continue;
916916+ try {
917917+ pmSteps.push(Step.fromJSON(schema, JSON.parse(step.json)));
918918+ clientIDs.push('remote');
919919+ } catch(e) {
920920+ console.warn('CollabClient: failed to parse step', e);
921921+ }
922922+ }
923923+ if (pmSteps.length === 0) return;
924924+ applyingRemote = true;
925925+ try {
926926+ const tr = receiveTransaction(pmView.state, pmSteps, clientIDs);
927927+ pmView.dispatch(tr);
928928+ } finally {
929929+ applyingRemote = false;
930930+ }
931931+ }
932932+});
933933+```
934934+935935+- [ ] **Step 5: Remove `createMilkdownEditor(content)` call in `applyRemoteEdit`**
936936+937937+The old `applyRemoteEdit` called `createMilkdownEditor(content)` (full recreate) for rich mode. Now that `CollabClient` handles remote steps, remove that branch. The `applyRemoteEdit` function should only handle the legacy `sync` message type as a fallback:
938938+939939+```js
940940+function applyRemoteEdit(msg) {
941941+ // Legacy sync fallback — only used on initial join if the room sends full content.
942942+ if (applyingRemote) return;
943943+ const content = typeof msg === 'string' ? msg : msg.content;
944944+ if (!content) return;
945945+946946+ if (currentMode === 'source' && cmView) {
947947+ if (cmView.state.doc.toString() !== content) {
948948+ applyingRemote = true;
949949+ try {
950950+ cmView.dispatch({
951951+ changes: { from: 0, to: cmView.state.doc.length, insert: content },
952952+ annotations: [remoteEditAnnotation.of(true)],
953953+ });
954954+ } finally {
955955+ applyingRemote = false;
956956+ }
957957+ }
958958+ }
959959+ // Rich mode no longer falls back to full recreate.
960960+}
961961+```
962962+963963+- [ ] **Step 6: Build and manually test rich mode collab**
964964+965965+```bash
966966+npm run build:collab
967967+make build && make run
968968+```
969969+970970+Open two tabs, both in rich mode. Type in one — verify the other updates without the editor flicker (no DOM teardown). Undo in each tab should only undo local edits.
971971+972972+- [ ] **Step 7: Commit**
973973+974974+```bash
975975+git add milkdown-entry.js static/vendor/milkdown.js static/vendor/collab.js templates/document_edit.html
976976+git commit -m "feat(frontend): rich mode uses prosemirror-collab steps, no full editor recreate"
977977+```
978978+979979+---
980980+981981+## Chunk 6: Cleanup and Deprecate Old Code
982982+983983+### Task 9: Remove the custom OT engine (server)
984984+985985+Once `prosemirror-collab` is the only collab protocol in production, the custom `OTEngine` and the `edit`-type WebSocket message handler become dead code.
986986+987987+- [ ] **Step 1: Verify no active callers**
988988+989989+```bash
990990+grep -rn "OTEngine\|ApplyEdits\|applyInternal\|type.*edit.*delta" internal/ templates/ --include="*.go" --include="*.html"
991991+```
992992+993993+Review the output. If `ApplyEdits` or `OTEngine` are still called by the WebSocket handler (`client.go`), leave them in place and just stop broadcasting `edit`-type messages; otherwise delete.
994994+995995+- [ ] **Step 2: Remove `ot.go` and `ot_test.go` if unused**
996996+997997+```bash
998998+# Only if grep above shows zero active callers:
999999+git rm internal/collaboration/ot.go internal/collaboration/ot_test.go
10001000+```
10011001+10021002+- [ ] **Step 3: Remove `ot *OTEngine` field from `Room`**
10031003+10041004+In `internal/collaboration/hub.go`, delete:
10051005+- `ot *OTEngine` field on `Room`
10061006+- `NewOTEngine("")` call in `GetOrCreateRoom`
10071007+- `ApplyEdits` method on `Room`
10081008+- `SeedText`, `IsNewRoom` methods (no longer needed — initial version is fetched from DB)
10091009+10101010+- [ ] **Step 4: Remove `seeded` guard from WebSocket handler**
10111011+10121012+In `internal/handler/handler.go`, in `CollaboratorWebSocket`, delete the `room.IsNewRoom()` / `room.SeedText(initialText)` block. The server no longer needs to seed an in-memory text state.
10131013+10141014+- [ ] **Step 5: Remove legacy `edit`-type message handling from `client.go`**
10151015+10161016+In `internal/collaboration/client.go`, delete the `case "edit":` branch in `ReadPump`. The WebSocket channel now only carries `steps`, `presence`, and `ping/pong`.
10171017+10181018+- [ ] **Step 6: Remove `queueDeltas`, `diffToOps`, `sendEdit`, `pendingDeltas` from frontend**
10191019+10201020+In `templates/document_edit.html`, delete:
10211021+- `function diffToOps(...)` (~60 lines)
10221022+- `function queueDeltas(...)` (~15 lines)
10231023+- `function sendEdit(...)` (~5 lines)
10241024+- `let pendingDeltas`, `let wsEditTimer`
10251025+10261026+- [ ] **Step 7: Build and run all tests**
10271027+10281028+```bash
10291029+make build
10301030+make test
10311031+```
10321032+10331033+Expected: all tests PASS, no compile errors.
10341034+10351035+- [ ] **Step 8: Commit**
10361036+10371037+```bash
10381038+git add -A
10391039+git commit -m "chore: remove custom OT engine and legacy delta WebSocket protocol"
10401040+```
10411041+10421042+---
10431043+10441044+### Task 10: Update Dockerfile build step for new bundles
10451045+10461046+**Files:**
10471047+- Modify: `Dockerfile`
10481048+10491049+The Dockerfile has a `jsbuilder` stage with an `esbuild` command. The `milkdown-entry.js` bundle command needs the new `prosemirror-collab` and `prosemirror-transform` packages in scope.
10501050+10511051+- [ ] **Step 1: Check current jsbuilder stage**
10521052+10531053+```bash
10541054+grep -A 20 "jsbuilder" Dockerfile
10551055+```
10561056+10571057+- [ ] **Step 2: Add collab bundle step**
10581058+10591059+After the existing `RUN npx esbuild milkdown-entry.js ...` line, add:
10601060+10611061+```dockerfile
10621062+RUN npx esbuild node_modules/prosemirror-collab/dist/index.js \
10631063+ --bundle --format=esm --minify --outfile=collab.min.js
10641064+```
10651065+10661066+And in the `COPY --from=jsbuilder` lines in the final stage, add:
10671067+10681068+```dockerfile
10691069+COPY --from=jsbuilder /build/collab.min.js ./static/vendor/collab.js
10701070+```
10711071+10721072+- [ ] **Step 3: Build Docker image locally**
10731073+10741074+```bash
10751075+docker build -t diffdown-test .
10761076+```
10771077+10781078+Expected: successful build, no missing module errors.
10791079+10801080+- [ ] **Step 4: Commit**
10811081+10821082+```bash
10831083+git add Dockerfile
10841084+git commit -m "chore(docker): build prosemirror-collab bundle in jsbuilder stage"
10851085+```
10861086+10871087+---
10881088+10891089+## Chunk 7: End-to-End Testing
10901090+10911091+### Task 11: Manual collab regression checklist
10921092+10931093+Run through these scenarios with two browser windows logged in as different users on the same document:
10941094+10951095+- [ ] **Scenario A — Concurrent typing (source mode)**
10961096+ - Both users type simultaneously. After a brief pause, both editors show identical text.
10971097+ - **Pass condition:** No full-page flash, no cursor jump in the non-typing window.
10981098+10991099+- [ ] **Scenario B — Concurrent typing (rich mode)**
11001100+ - Same as A but in rich mode. Bold/italic marks applied by one user appear in the other.
11011101+ - **Pass condition:** Editor does not tear down and recreate on each remote step.
11021102+11031103+- [ ] **Scenario C — Undo isolation**
11041104+ - User A types "hello", User B types "world". User A presses undo.
11051105+ - **Pass condition:** Only "hello" is removed; "world" remains.
11061106+11071107+- [ ] **Scenario D — Network reconnect**
11081108+ - User A types while User B's tab is in the background (WebSocket dormant).
11091109+ - User B foregrounds — page catches up via `GET /api/docs/{rkey}/steps?since={v}`.
11101110+ - **Pass condition:** B's editor converges without a reload.
11111111+11121112+- [ ] **Scenario E — Single user, no collaborators**
11131113+ - Open a document alone. Type normally. Auto-save fires.
11141114+ - **Pass condition:** Behavior is identical to before; no regressions.
11151115+11161116+---
11171117+11181118+### Task 12: Deploy to staging
11191119+11201120+- [ ] **Step 1: Push to origin**
11211121+11221122+```bash
11231123+git push origin main
11241124+```
11251125+11261126+- [ ] **Step 2: Deploy to staging**
11271127+11281128+```bash
11291129+flyctl deploy --config fly-staging.toml
11301130+```
11311131+11321132+- [ ] **Step 3: Run manual regression checklist against staging**
11331133+11341134+Repeat the scenarios in Task 11 against `https://staging.diffdown.com`.
11351135+11361136+- [ ] **Step 4: Deploy to production**
11371137+11381138+```bash
11391139+flyctl deploy --config fly-production.toml
11401140+```
11411141+11421142+---
11431143+11441144+## Appendix: Protocol Reference
11451145+11461146+### WebSocket message types (post-migration)
11471147+11481148+| Direction | Type | Payload | Purpose |
11491149+|---|---|---|---|
11501150+| Server → Client | `steps` | `{steps: string[], version: int, clientID: string}` | Broadcast confirmed steps to room peers |
11511151+| Server → Client | `presence` | `{users: [{did, name, color}]}` | Room membership changes |
11521152+| Server → Client | `pong` | `{}` | Keepalive reply |
11531153+| Client → Server | `ping` | `{}` | Keepalive |
11541154+11551155+### HTTP endpoints (post-migration)
11561156+11571157+| Method | Path | Purpose |
11581158+|---|---|---|
11591159+| `POST` | `/api/docs/{rkey}/steps` | Submit steps; 200 on accept, 409 on conflict |
11601160+| `GET` | `/api/docs/{rkey}/steps?since={v}` | Fetch missed steps |
11611161+11621162+### Step shapes
11631163+11641164+**Source mode** (text-patch):
11651165+```json
11661166+{"type": "text-patch", "from": 5, "to": 8, "insert": "foo"}
11671167+```
11681168+11691169+**Rich mode** (PM step):
11701170+```json
11711171+{"type": "pm-step", "json": "{\"stepType\":\"replace\",...}"}
11721172+```
+5-38
internal/collaboration/client.go
···1818}
19192020type ClientMessage struct {
2121- Type string `json:"type"`
2222- RKey string `json:"rkey,omitempty"`
2323- DID string `json:"did,omitempty"`
2424- // Deltas is the new plural field — a single edit message may carry
2525- // multiple operations (e.g. one per CodeMirror ChangeDesc).
2626- Deltas []Operation `json:"deltas,omitempty"`
2727- // Delta is the legacy singular field. Kept for backward compatibility.
2828- Delta json.RawMessage `json:"delta,omitempty"`
2929- Cursor *CursorPos `json:"cursor,omitempty"`
3030- Comment *CommentMsg `json:"comment,omitempty"`
3131-}
3232-3333-// Operations returns the ops from this message, preferring Deltas over the
3434-// legacy singular Delta field.
3535-func (m *ClientMessage) Operations() []Operation {
3636- if len(m.Deltas) > 0 {
3737- return m.Deltas
3838- }
3939- if len(m.Delta) > 0 {
4040- var op Operation
4141- if err := json.Unmarshal(m.Delta, &op); err == nil {
4242- return []Operation{op}
4343- }
4444- }
4545- return nil
2121+ Type string `json:"type"`
2222+ RKey string `json:"rkey,omitempty"`
2323+ DID string `json:"did,omitempty"`
2424+ Cursor *CursorPos `json:"cursor,omitempty"`
2525+ Comment *CommentMsg `json:"comment,omitempty"`
4626}
47274828type CursorPos struct {
···10080 }
1018110282 switch msg.Type {
103103- case "edit":
104104- ops := msg.Operations()
105105- if len(ops) == 0 {
106106- continue
107107- }
108108- room := c.hub.GetRoom(c.roomKey)
109109- if room == nil {
110110- continue
111111- }
112112- for i := range ops {
113113- ops[i].Author = c.DID
114114- }
115115- room.ApplyEdits(ops, c)
11683 case "ping":
11784 pong, _ := json.Marshal(map[string]string{"type": "pong"})
11885 c.send <- pong
-50
internal/collaboration/client_test.go
···11-package collaboration
22-33-import (
44- "encoding/json"
55- "testing"
66-)
77-88-func TestClientMessage_ParseDeltas_Multiple(t *testing.T) {
99- raw := `{"type":"edit","deltas":[{"from":0,"to":5,"insert":"hello"},{"from":10,"to":10,"insert":"!"}]}`
1010- var msg ClientMessage
1111- if err := json.Unmarshal([]byte(raw), &msg); err != nil {
1212- t.Fatalf("unmarshal: %v", err)
1313- }
1414- if len(msg.Deltas) != 2 {
1515- t.Fatalf("expected 2 deltas, got %d", len(msg.Deltas))
1616- }
1717- if msg.Deltas[0].From != 0 || msg.Deltas[0].To != 5 || msg.Deltas[0].Insert != "hello" {
1818- t.Errorf("delta[0]: %+v", msg.Deltas[0])
1919- }
2020- if msg.Deltas[1].From != 10 || msg.Deltas[1].Insert != "!" {
2121- t.Errorf("delta[1]: %+v", msg.Deltas[1])
2222- }
2323-}
2424-2525-func TestClientMessage_ParseDeltas_SingleFallback(t *testing.T) {
2626- // Old wire format: singular "delta" field — must still work.
2727- raw := `{"type":"edit","delta":{"from":3,"to":7,"insert":"xyz"}}`
2828- var msg ClientMessage
2929- if err := json.Unmarshal([]byte(raw), &msg); err != nil {
3030- t.Fatalf("unmarshal: %v", err)
3131- }
3232- ops := msg.Operations()
3333- if len(ops) != 1 {
3434- t.Fatalf("expected 1 op from fallback, got %d", len(ops))
3535- }
3636- if ops[0].From != 3 || ops[0].To != 7 || ops[0].Insert != "xyz" {
3737- t.Errorf("op: %+v", ops[0])
3838- }
3939-}
4040-4141-func TestClientMessage_ParseDeltas_EmptyDeltas(t *testing.T) {
4242- raw := `{"type":"edit","deltas":[]}`
4343- var msg ClientMessage
4444- if err := json.Unmarshal([]byte(raw), &msg); err != nil {
4545- t.Fatalf("unmarshal: %v", err)
4646- }
4747- if len(msg.Operations()) != 0 {
4848- t.Errorf("expected 0 ops for empty deltas")
4949- }
5050-}
-62
internal/collaboration/hub.go
···1818 register chan *Client
1919 unregister chan *Client
2020 mu sync.RWMutex
2121- ot *OTEngine
2222- seeded bool // true after SeedText has been called
2321}
24222523// broadcastMsg carries a message and an optional sender to exclude.
···4644 broadcast: make(chan *broadcastMsg, 256),
4745 register: make(chan *Client),
4846 unregister: make(chan *Client),
4949- ot: NewOTEngine(""),
5047 }
5148 h.rooms[rkey] = room
5249 go room.run()
···103100 r.broadcast <- &broadcastMsg{data: data, except: except}
104101}
105102106106-// ApplyEdits applies a sequence of operations in order and broadcasts one
107107-// combined message to all other clients. Each op is applied to the text
108108-// resulting from the previous op, so positions in each op must be relative
109109-// to the document state after all prior ops in the same batch have been
110110-// applied — which is exactly what CodeMirror's iterChanges produces (fromA/toA
111111-// are positions in the pre-change document, already adjusted for prior changes
112112-// within the same transaction by CodeMirror itself).
113113-func (r *Room) ApplyEdits(ops []Operation, sender *Client) {
114114- if len(ops) == 0 {
115115- return
116116- }
117117-118118- // Capture the text returned by the final ApplyWithVersion so we don't
119119- // race against another goroutine calling GetText() separately.
120120- var finalText string
121121- for i := range ops {
122122- finalText, _ = r.ot.ApplyWithVersion(ops[i])
123123- }
124124-125125- // Include the full document text so receivers can detect and recover from
126126- // divergence without a reconnect.
127127- type editsMsg struct {
128128- Type string `json:"type"`
129129- Deltas []Operation `json:"deltas"`
130130- Author string `json:"author"`
131131- Content string `json:"content"`
132132- }
133133- msg := editsMsg{
134134- Type: "edit",
135135- Deltas: ops,
136136- Author: sender.DID,
137137- Content: finalText,
138138- }
139139- data, err := json.Marshal(msg)
140140- if err != nil {
141141- log.Printf("ApplyEdits: marshal: %v", err)
142142- return
143143- }
144144- r.BroadcastExcept(data, sender)
145145-}
146146-147103func (r *Room) RegisterClient(client *Client) {
148104 r.register <- client
149105}
···178134 }
179135 r.Broadcast(data)
180136}
181181-182182-// IsNewRoom returns true if SeedText has not yet been called on this room.
183183-func (r *Room) IsNewRoom() bool {
184184- r.mu.RLock()
185185- defer r.mu.RUnlock()
186186- return !r.seeded
187187-}
188188-189189-// SeedText sets the initial document text for the OT engine.
190190-// Idempotent — only the first call has effect.
191191-func (r *Room) SeedText(text string) {
192192- r.mu.Lock()
193193- defer r.mu.Unlock()
194194- if !r.seeded {
195195- r.ot.SetText(text)
196196- r.seeded = true
197197- }
198198-}