Skip to content

CRDT-native line blame: persistent per-line authorship#1

Merged
plosson merged 1 commit into
mainfrom
feat/line-blame
Jul 12, 2026
Merged

CRDT-native line blame: persistent per-line authorship#1
plosson merged 1 commit into
mainfrom
feat/line-blame

Conversation

@plosson

@plosson plosson commented Jul 12, 2026

Copy link
Copy Markdown
Owner

What

Git-blame-style per-line authorship, computed from the CRDT itself rather than from diffs: every Yjs item permanently carries the clientID that inserted it, so blame survives concurrent edits by construction.

  • State sidecars — each room's full Y.Doc (created with gc: false) persists to .sharemd/<path>.yjs alongside the markdown, so authorship survives server restarts. The markdown file remains the content source of truth; any divergence (offline edit, deleted file, corrupt/truncated sidecar) is reconciled at hydration as a minimal prefix/suffix splice attributed to disk, preserving surrounding authorship.
  • authors map — a new shared contract (src/shared/blame.ts): a Y.Map inside the doc translating per-session clientIDs to durable identities. Browser, MCP agents, and server hydration all register before editing.
  • SurfacesGET /api/blame/<path> (server-side, for future UI gutters) and a blame_document MCP tool computed locally from the agent's own replica. Lines list every contributing author with UTF-16 char counts.

Tests

45 tests pass (bun run test:all, incl. Playwright e2e). New adversarial coverage in tests/blame.test.ts:

  • cross-peer deletion → only surviving chars attributed; whole-line deletion renumbers
  • server restart → authorship survives via sidecar
  • offline markdown edit inside another author's line → disk gets only the spliced middle
  • corrupt and truncated sidecars → fall back/reconcile to markdown, never crash
  • deleted markdown with surviving sidecar → file wins (empty doc)
  • traversal + .sharemd/ access on the new endpoint → 400; sidecar dir hidden from /api/docs
  • unregistered peers → unknown; same name across sessions aggregates; astral chars don't split

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gp1Mh7NtAG55JBosrWizKx

Persist each room's full Y.Doc as a .sharemd/<path>.yjs sidecar (gc:false)
so insert authorship survives restarts, and add an `authors` map inside the
doc translating per-session clientIDs to durable identities. Blame is
computed from the CRDT itself via snapshot diffs — no offsets, so it
survives concurrent edits by construction.

- markdown stays the content source of truth: offline edits, deleted files,
  and corrupt/truncated sidecars reconcile as a minimal "disk"-authored
  splice that preserves surrounding authorship
- every peer (browser, MCP agent, server hydration) registers in the
  authors map before editing
- new surfaces: GET /api/blame/<path> and a blame_document MCP tool
  (computed locally from the agent's own replica)
- adversarial test suite: cross-peer deletes, restarts, offline edits,
  corrupt/truncated sidecars, traversal on the new endpoint, unregistered
  peers, astral chars, same-name sessions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp1Mh7NtAG55JBosrWizKx
@plosson
plosson merged commit 8f5bf57 into main Jul 12, 2026
@plosson
plosson deleted the feat/line-blame branch July 12, 2026 08:04
plosson added a commit that referenced this pull request Jul 12, 2026
CRDT-native line blame: persistent per-line authorship
plosson pushed a commit that referenced this pull request Jul 14, 2026
Adds snapshots: named checkpoints of a document you can roll back to.
The room Y.Doc is already gc:false "so authorship (and later, snapshot
history) survives" — this builds on that anticipated seam.

Server (human-only REST, consistent with the CRUD/edit-only split):
  GET  /api/projects/:p/docs/*d/snapshots              list versions
  POST /api/projects/:p/docs/*d/snapshots {label}      capture current state
  POST /api/projects/:p/docs/*d/snapshots/:id/restore  roll back to a version
A snapshot stores the full Y.encodeStateAsUpdate (base64) plus display
metadata in a <path>.snapshots.json sidecar, which travels on rename/move
and is removed on delete like .yjs/.log. The list endpoint strips the
heavy state blob.

Restore converges the live text forward via the existing reconcileText
middle-splice, authored to the restorer — so it lands in blame and
history and is itself reversible. It deliberately does NOT re-apply the
old CRDT state (that would fork live peers); other collaborators simply
see the text change. RoomRegistry.peek() (added for #1) is unrelated here;
capture/restore use registry.open + the new Room.snapshotState /
Room.restoreContent.

Web UI: a "versions" button opens a panel to name+save the current state
and restore any prior version (confirm dialog, API errors surfaced).

Tests: server capture/list/restore, blame attribution of a restore,
sidecar travel on move + removal on delete, 404/400 guards; a Playwright
flow that saves a checkpoint, diverges, and restores it in the real UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Q1wmZU3s6DvjypiSHm2au
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants