Skip to content

feat(docs): page byline (creator + contributors); fix(collab): offline edits never reached the server - #35

Merged
christianhuening merged 3 commits into
mainfrom
feat/doc-contributors
Oct 5, 2026
Merged

christianhuening merged 3 commits into
mainfrom
feat/doc-contributors

Conversation

@christianhuening

@christianhuening christianhuening commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

The feature and the sync fix share one commit (0995370); two follow-up commits fix a duplicate React key found by CI (11355b7) and address an independent review (5a55f93). The byline depends on the sync fix: once the server asks reconnecting clients for their state, their no-op replies must be filtered, or every page open would credit the opener as a contributor.

Byline: who created a page, and who contributed

Under each page title: Created by Alice · 3 Oct 2026 · (avatars) N contributors. Clicking the avatars lists everyone who changed the content, most recent first, with "edited 2h ago" (exact time on hover).

  • Who counts: anyone whose change to the page content was saved: live typing, Markdown import, history restore, task checkbox toggles, create-from-template, workspace import. Renames, moves, permission changes and comments don't count. Opening or watching a page doesn't count either.
  • Who sees it: every role that can open the page, viewers included. Names only, never emails. It isn't shown on public share links.
  • Storage: a new doc_contributors(doc_id, user_id, first_edited_at, last_edited_at) table. It is upserted in the same statement as the doc_updates insert, so an edit and its credit commit together, and last_edited_at is refreshed at most once a minute. Contributors live in their own table because snapshot GC deletes old doc_updates rows.
  • Attribution fixes this needed:
    • Live WS edits were stored with by_user_id = NULL. They now carry the socket's user.
    • The writer stamped a whole 250 ms batch with its first job's author, which would merge two concurrent typists under one name. Each row now keeps its own author.
    • History restore and workspace import are now attributed.
  • Older pages: these edits were never attributed before, so they can't be recovered. A new documents.contributors_since column records when tracking started. For existing pages that's the migration time, and the list ends with "Edits before … aren't listed".
  • API: GET /api/docs/{id}/contributors returns {created_by, created_at, contributors_since, contributors[]}. Any doc role can read it; it returns 401 without a session and 403 without access.
  • Autolink guard: KnotLink skips autolinking when every transaction in a batch is a remote y-sync change, so an observer's editor no longer writes link edits under its user's name when linkable text arrives from someone else (e.g. an import with bare URLs).

Fix: edits typed while disconnected never reached the server

  • The bug: KnotProvider applied offline edits to the local Y.Doc but only sent updates while its socket was open. On reconnect it sent SyncStep1, which only asks for what the client lacks. The server never sent its own SyncStep1, so a reconnecting client was never asked for what the server lacked.
  • The effect: offline text was lost on reload. Worse, later edits that built on it were saved but parked by yrs as pending, invisible to everyone else.
  • The fix: the server sends its state vector as SyncStep1 right after the initial state, for docs and boards. The client already answered that message, so already-loaded clients are fixed without a reload. There's no client change.
  • No-op filter: every reconnecting writer now replies with a SyncStep2 that usually carries nothing new (Yjs always includes the whole delete set). A new Engine::apply_update_changed lets the room drop those: nothing is fanned out, persisted or counted. Updates that can't be integrated yet (pending) still count as changes, so they're never dropped.

Testing

  • Rust: cargo nextest run --workspace --all-features passes 397 tests (352 before). clippy -D warnings and fmt --check are clean.
    • Offline sync: offline_sync.rs drives the real collab socket the way the web provider does. Before the fix it failed with "fresh client never saw the offline edit … the server asked the reconnecting client for its state (SyncStep1): false", for both docs and boards. The no-op tests failed until the filter was added.
    • Contributors: contributors_integration.rs has 11 tests: a live edit credits the editor; a viewer's update credits nobody; two concurrent editors are each credited for their own rows; restore and import credit the actor; an editor who only opens the page is not credited; endpoint access and response shape; former members still resolve by name.
    • Mutation checks:
      • Writer reverted to using the first job's author: the concurrent-editors test fails.
      • Viewer gate forced open: the viewer test fails.
      • No-op filter removed: the "only opened" test fails.
  • Web: vitest passes 307 tests (275 before). tsc and eslint --max-warnings 0 are clean.
  • e2e (Playwright, run locally): 18 of 18 pass. That's the new contributors.spec.ts, the new offline test in ws-reconnect.spec.ts, and a regression sweep (collab, two-users-converge, history, templates, import-markdown, excalidraw, doc-header, mobile, role-gating, editor).
    • With the server's SyncStep1 temporarily removed, the offline e2e test fails: the second tab shows only "Before the outage."
    • The contributors e2e test does not exercise the autolink guard. The typist's own update already carries the link mark, so it passes with or without the guard. autolink.test.ts covers the guard directly.

Fixes from the independent review (5a55f93)

  • ACL revoke now reaches open sockets. This bug was already on main. The 4403 close relied on the room dropping the connection's sender, but serve() holds its own sender for the whole session, so the close never fired. A revoked user kept writing, and with this PR they would also have been credited.
    • ConnHandle gains a revoked token. The room cancels it on revoke, and the writer turns it into a 4403 close.
    • The room ignores updates from connections it no longer holds, and refuses a re-join whose token is already cancelled.
  • No more per-disconnect leak. The same defect leaked one task and one socket per disconnect, because the writer waited on a channel that could never close. Both shims now share ws_writer::run, which stops when the read half ends, and vice versa.
  • No-op detection on docs with stuck pending content. The check now compares pending state before and after applying, instead of using the doc-wide flag. Docs left with pending content by the offline bug made every page open persist a row and credit the opener, because yrs and Yjs both re-send pending content in every state update.
    • It also checks insert_set instead of building two full state vectors per update.
  • SyncStep1 only for writers. It is now sent only to connections that can write; a viewer's answer was always dropped.
  • No row lock per flush. The contributor upsert skips authors credited within the last minute before ON CONFLICT, which had row-locked them on every 250 ms flush.
  • Fresher byline. It refetches every minute and on window focus, and restore and import refresh it immediately.
  • One shared require_viewer for comments and contributors.

Every fix was written test-first, and each new end-to-end test was mutation-checked: reverting the fix makes it fail.

Known limitations

  • Credit goes to the socket that delivers a change. A reconnect reply can carry other people's edits the server never saved, for example after a replica crashes before its writer flushes. The reconnecting user is then credited for them. Fixing that properly needs a mapping from Yjs client IDs to users.
  • Other plugins can still emit phantom edits. prosemirror-tables' fixTables also writes in response to remote changes, but only when a table is malformed. Another plugin appending in the same batch would also defeat the autolink guard.
  • Oversized reconnect replies loop. A reply over MAX_WS_MESSAGE_BYTES (4 MiB) closes the socket, and the client keeps reconnecting. Before this PR those edits were silently lost instead. Hitting this needs more than 4 MiB of offline edits.
  • "Last edited" can be up to a minute stale. last_edited_at is refreshed at most once a minute, so people who edited within the same minute can be misordered.
  • The backfill inherits old misattribution. It reads doc_updates.by_user_id values written by the old writer, which credited a whole batch to its first job's author.

Notes for reviewers

  • Insert order: insert_batch relies on UNNEST … WITH ORDINALITY … ORDER BY ord assigning seq in input order, to zip seqs back onto jobs. The previous multi-row VALUES insert relied on the same thing. The existing order test and a new mixed-author test cover it.
  • No cascade on users: doc_contributors.user_id has no ON DELETE CASCADE, matching doc_updates.by_user_id. No code path deletes users today.
  • Typing URLs in e2e: // opens knot's date picker, so the e2e spec types www.example.com to trigger autolink.

🤖 Generated with Claude Code

christianhuening and others added 3 commits October 3, 2026 19:19
Byline under the title: "Created by <name> · <date>", plus everyone who
changed the page's content, each with when they last edited. Readable by
every role that can open the page; names only. GET /api/docs/{id}/contributors.

Contributors are recorded in a new doc_contributors table, upserted in the
same statement that persists the edit (last_edited_at refreshed at most once
a minute). To make that possible, live WS edits now carry their author, the
writer stores each row's own author instead of the batch's first, and history
restore and workspace import are attributed. Pages that predate this only
list contributions from now on (documents.contributors_since), and say so.

fix(collab): edits typed while disconnected never reached the server

The client applied offline edits locally but only sent while its socket was
open, and the server never sent SyncStep1, so a reconnecting client was never
asked for what the server lacked. The edits were lost on reload, and later
edits building on them were parked as pending, invisible to everyone else.
The server now sends its state vector on join (docs and boards), which
already-loaded clients answer without a reload. No-op replies (nothing new,
just the delete set) are neither persisted nor fanned out, so opening a page
writes no row and credits no one.

Also: KnotLink skips autolinking text that arrives from a remote change, so an
observer's editor no longer writes link edits under its user's name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
DocTitle and DocByline are siblings and both used `key={id}`. Duplicate
sibling keys are unsupported in React: switching docs in a single render
(target doc's metadata already cached) kept the previous doc's title input
mounted next to the new one, which broke search.spec's strict locator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ess)

- Revoking access now reaches open sockets (pre-existing). The 4403 close
  relied on the room dropping the connection's sender, but serve() holds its
  own for the whole session, so it never fired: a revoked user kept writing,
  and with this branch would have been credited. ConnHandle gains a `revoked`
  token the room cancels; the writer turns it into 4403. The room ignores
  updates from connections it no longer holds and refuses a re-join with a
  cancelled handle.
- The same defect leaked a task and socket per disconnect: the writer waited
  on a channel that could not close. Both shims now share ws_writer::run,
  which stops when the read half ends (and vice versa).
- No-op detection compares pending state before/after instead of the
  doc-wide flag. Docs left with pending content by the offline bug made every
  page open persist a row and credit the opener. It also checks insert_set
  rather than building two full state vectors per update.
- SyncStep1 is sent only to connections that can write; a viewer's answer
  was always dropped.
- The contributor upsert skips authors credited within the minute before ON
  CONFLICT, which locked their row on every 250 ms flush.
- The byline refetches every minute and on focus, and restore/import refresh
  it immediately.
- One shared require_viewer for comments and contributors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@christianhuening
christianhuening merged commit 37f44fc into main Oct 5, 2026
6 checks passed
@christianhuening
christianhuening deleted the feat/doc-contributors branch October 5, 2026 05:31
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.

1 participant