oficina / docs Public

app: edit a document — ProseMirror bound to the shared fragment

Binds ProseMirror to the Yjs XmlFragment (y-prosemirror's ySyncPlugin),
loads the update log on mount, and posts local edits back as
debounced, coalesced updates — dropped bytes go back on the queue on
a failed save, and a beforeunload beacon covers a close mid-debounce.

Two bugs found only by actually typing into the running app and
reloading (not by the test suite, which was green throughout):

- new EditorView(el, {state}) appends a second DOM node inside el
  rather than replacing its contents (prosemirror-view only takes
  over the given node's own children when passed {mount: el}), so a
  click could land on the inert server-rendered read view instead of
  the real editable surface. Fixed by mounting with {mount: el}.

- handlers.go's GET /d/{sqid}/updates streams the log as a raw
  concatenation of one encoded Yjs update per accepted POST, with no
  framing between messages. A single Y.applyUpdate call only decodes
  the first message in that buffer — its decoder reads exactly the
  client count declared in that message's own header and stops,
  silently dropping every later message — so a second edit loaded
  fine in memory during the session but vanished on reload. Fixed by
  looping Y.readUpdate over one shared lib0/decoding decoder until it
  is exhausted, replaying every message the log actually holds.

Also: views/show.html's existing [docs-text] container gets
docs-editor and data-sqid, plus a deferred module script tag for the
bundle — ProseMirror takes over that container's own contents once it
mounts, so the server-rendered HTML is what a scriptless reader sees
and what a scripted reader sees until the editor takes over
(pinned by editor_view_test.go's TestShowRendersContentWithoutScript).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
Paul Campbell pushed by paul@keymail.dev c52259f62b0ccbb4c5d7c00f3be4f4aa301a1572 parent f1be04a
8 files changed, +213 −40
  • fixtures/editor-entry.js +130 −24
  • fixtures/package-lock.json +1 −0
  • fixtures/package.json +1 −0
  • internal/docs/editor_view_test.go +39 −0
  • internal/docs/handlers.go +9 −1
  • internal/docs/static/editor.283b4d376ff04289.js +14 −0
  • internal/docs/static/editor.f13f5e0ce6fa7467.js +0 −14
  • internal/docs/views/show.html +19 −1

Intent

Human

<task-notification> <task-id>af1d2dca782186aa3</task-id> <tool-use-id>toolu_017GV37mof3YZVQUKaPLeTQs</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-docs/70f302d1-9d5d-4eb2-8b38-e09a15fdd1c3/tasks/af1d2dca782186aa3.output</output-file> <status>completed</status> <summary>Agent "Editing Task 5 update endpoints" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>## Summary Status: done. Commit `f1be04a` on branch `editing` (fix round 1, on top of `d1d0884`). `.amadan/ci.d/10-gate` exits 0. **(1) 400/500 classification** — `internal/core/core.go` gains `RejectedError`/`Rejected(err)` mirroring the existing `RecoveredError`/`Recovered(err)`. `store.go`'s `ApplyUpdate` wraps only the *new* update's `h.Apply` error in an unexported `*updateRejection` (a replay failure calls the same `core.Handle.Apply` and would otherwise be indistinguishable). `handlers.go` requires that wrapper *and* `Recovered`/`Rejected`/`ErrUpdateLogFull` before returning 400/Info; everything else is 500/Error with the doc id. Induced a real 500 (`TestInternalApplyFailureReturns500NotBadUpdate`) by writing a garbage row directly into `doc_updates` so the next POST's replay genuinely fails through the real wasm core — no mock. **(2) seq race** — chose "compute and insert inside one transaction" over retry-on-collision: `rastrillo/db`'s write pool caps at one SQLite connection, so `gorm.DB.Transaction` makes a racing request block on `Begin` until the first commits, closing the race by queuing rather than racing-and-retrying. Verified this matters: a bounded 5-attempt retry-loop version I built first genuinely exhausted its budget and returned real 500s under a 21-goroutine race, while the transaction version passed 15/15 clean runs. Added `UNIQUE(doc_id, seq)` (migration `0003`) as a backstop, safe to apply as-is since no live database was ever exposed to the vulnerable code. Confirmed `TestConcurrentUpdatesDoNotCollideOnSeq` fails against the pre-fix two-statement code: reverted locally (never committed) and it failed 3/3 runs (rows going missing / genuine 500s) before the transaction fix, 15/15 clean after. One correction en route: my first race-test draft posted 7 different fixtures concurrently and hit real, unrelated core rejections, because every fixture pins the same `clientID` for reproducibility, making them structurally incompatible when layered together — switched to reposting one fixture N times instead. Full report: `/home/paulca/amadan.net/carlos/docs/.superpowers/sdd/2026-08-31-editing/task-5-report.md`</result> <usage><subagent_tokens>296361</subagent_tokens><tool_uses>90</tool_uses><duration_ms>1082797</duration_ms></usage> </task-notification>

fixtures/editor-entry.js

Loading diff…

fixtures/package-lock.json

Loading diff…

fixtures/package.json

Loading diff…

internal/docs/editor_view_test.go

Loading diff…

internal/docs/handlers.go

Loading diff…

internal/docs/static/editor.283b4d376ff04289.js

Loading diff…

internal/docs/static/editor.f13f5e0ce6fa7467.js

Loading diff…

internal/docs/views/show.html

Loading diff…