Repository navigation
feat(docs): page byline (creator + contributors); fix(collab): offline edits never reached the server - #35
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
doc_contributors(doc_id, user_id, first_edited_at, last_edited_at)table. It is upserted in the same statement as thedoc_updatesinsert, so an edit and its credit commit together, andlast_edited_atis refreshed at most once a minute. Contributors live in their own table because snapshot GC deletes olddoc_updatesrows.by_user_id = NULL. They now carry the socket's user.documents.contributors_sincecolumn records when tracking started. For existing pages that's the migration time, and the list ends with "Edits before … aren't listed".GET /api/docs/{id}/contributorsreturns{created_by, created_at, contributors_since, contributors[]}. Any doc role can read it; it returns 401 without a session and 403 without access.KnotLinkskips 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
KnotProviderapplied 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.Engine::apply_update_changedlets 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
cargo nextest run --workspace --all-featurespasses 397 tests (352 before).clippy -D warningsandfmt --checkare clean.offline_sync.rsdrives 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_integration.rshas 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.vitestpasses 307 tests (275 before).tscandeslint --max-warnings 0are clean.contributors.spec.ts, the new offline test inws-reconnect.spec.ts, and a regression sweep (collab, two-users-converge, history, templates, import-markdown, excalidraw, doc-header, mobile, role-gating, editor).autolink.test.tscovers the guard directly.Fixes from the independent review (
5a55f93)main. The 4403 close relied on the room dropping the connection's sender, butserve()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.ConnHandlegains arevokedtoken. The room cancels it on revoke, and the writer turns it into a 4403 close.ws_writer::run, which stops when the read half ends, and vice versa.insert_setinstead of building two full state vectors per update.SyncStep1only for writers. It is now sent only to connections that can write; a viewer's answer was always dropped.ON CONFLICT, which had row-locked them on every 250 ms flush.require_viewerfor 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
fixTablesalso 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.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_atis refreshed at most once a minute, so people who edited within the same minute can be misordered.doc_updates.by_user_idvalues written by the old writer, which credited a whole batch to its first job's author.Notes for reviewers
insert_batchrelies onUNNEST … WITH ORDINALITY … ORDER BY ordassigningseqin 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.doc_contributors.user_idhas noON DELETE CASCADE, matchingdoc_updates.by_user_id. No code path deletes users today.//opens knot's date picker, so the e2e spec typeswww.example.comto trigger autolink.🤖 Generated with Claude Code