Add a self-hosted Hocuspocus collaboration server (docs-collab-server)#342
Draft
jhodapp wants to merge 18 commits into
Draft
Add a self-hosted Hocuspocus collaboration server (docs-collab-server)#342jhodapp wants to merge 18 commits into
jhodapp wants to merge 18 commits into
Conversation
New binary+lib crate that will reimplement the Hocuspocus collaboration wire protocol over generic yrs 0.26 for self-hosted document sync. Phase 1 of the plan at docs/implementation-plans/docs-collab-server.md. Wired into [workspace].members but excluded from default-members so it does not build with the main application (mirrors the testing-tools exclusion). Module skeletons (protocol, storage, auth, document, registry, config) expose the public API the frozen Phase 2 test suite will compile against; all bodies are todo!() and will be filled in per-phase. Config follows the service crate idioms: clap derive with #[arg(long, env)], private fields with accessors, a from_args test constructor, and a side- effect-free Default that uses from_args(["docs-collab-server"]). Required secrets (jwt_signing_key, management_auth_key) are Option<String> so Default works for tests; the runtime bootstrap checks Some. Dependency versions pin to copies already resolved in the workspace lockfile (axum 0.7.9, dashmap 6.2.1, sqlx 0.8.6, jsonwebtoken 10, async-trait 0.1) so no third or duplicate crate copy is introduced. sqlx declares the postgres feature explicitly because this crate does not pull it in via sea-orm.
… harness
Phase 2 of the docs-collab-server experiment. Writes the conformance gate
that subsequent implementation phases must satisfy, then physically freezes
it (chmod a-w) so any edit during implementation is a deliberate, visible
act rather than a quiet bias.
Test layout (all read-only post-commit):
* tests/protocol_conformance.rs: per-fixture decode + byte-stable round-trip,
proptest round-trip on simple bodies, negative cases (truncated, unknown
tag, bad utf8, unknown auth/sync sub-tag).
* tests/auth.rs: HS256 wildcard scope, aud-tolerant verify, expired/wrong-key/
garbage rejection. Mints tokens with the same claim shape the app uses
(domain/src/jwt/claims.rs) and the same aws_lc_rs crypto backend.
* tests/storage_pg.rs: PostgresStorage round-trip + idempotent upsert +
missing-row semantics, all #[ignore], gated on DATABASE_URL.
* tests/document_sync.rs: two yrs::Doc peers converge through one Document,
awareness fan-out without sender echo, evict+reload persistence, registry
Arc-identity for unevicted names.
* tests/e2e_provider.rs: #[ignore] tungstenite replay of the auth fixture
against an externally-running server (Phase 7 will expose the in-process
serve hook this test should be lifted onto).
Wire fixtures: tests/fixtures/*.bin + manifest.json are the source of truth
for the protocol layer. They are captured by tests/fixtures/capture/capture.mjs,
which calls the same lib0 + y-protocols + @hocuspocus/common primitives the
real @hocuspocus/provider 2.15.3 invokes internally (its OutgoingMessage
classes are not in the package "exports" surface, so we go through their
underlying functions: byte-identical output).
White-box unit tests live in sibling files src/{protocol,document,registry}_
tests.rs, wired in via #[cfg(test)] #[path = "..."] mod tests; from each
source module. This pattern was chosen over in-file #[cfg(test)] mod tests {}
blocks specifically so the test files can chmod a-w without locking the
source above them.
API signatures pinned by the tests (Phase 3 forward, change deliberately):
* Document: name(), join() -> (ConnectionId, broadcast::Receiver<Vec<u8>>),
leave(id), handle(from, body) -> Result<Vec<Body>, StorageError>, flush().
* DocumentRegistry: evict_now(name) -> Result<bool, StorageError>.
* lib re-exports: AuthError, ConnectionId.
The plan doc (docs/implementation-plans/docs-collab-server.md) is updated in
this commit to record the API pins and the separate-file freeze pattern.
cargo test -p docs-collab-server --no-run: builds cleanly.
cargo test -p docs-collab-server: fails on todo!() stubs as expected
(19 todo-panics, 12 ignored, 0 spurious passes).
cargo clippy -p docs-collab-server --all-targets -- -D warnings: clean.
cargo fmt -p docs-collab-server --check: clean.
…unce-coalesce invariants Promotes three Phase 2 placeholder unit tests to real, executable tests authored to spec while every Phase 5 stub is still todo!(), so the tests cannot be biased by an implementation. Adds a counting in-memory Storage helper, gated #[cfg(test)] only. Adds Document::open_with_debounce as an additive constructor so the existing frozen integration tests using Document::open are unaffected; the new constructor remains a stub for Phase 5 to implement.
Implements Frame::decode and Frame::encode against the @hocuspocus/common 2.15.3 wire format, layered on the lib0 codec exposed by yrs 0.26. State vectors, sync updates, and awareness payloads are round-tripped via the yrs Encode/Decode impls so the bytes match y-protocols' JS encoder exactly. SyncStatus uses lib0's sign-bit varInt (yrs VarInt for i64), not the zigzag SignedVarInt wrapper. Sub-tag dispatch happens before reading sub-payloads so an unknown sync or auth sub-tag surfaces as UnknownSyncTag / UnknownAuthTag rather than Truncated. Doc-name utf-8 validation is done after reading the raw byte buffer so Truncated and Utf8 stay distinct error kinds. All 8 tests in tests/protocol_conformance.rs pass, including the byte-identical fixture round-trip and the simple-body proptest. Documents, registry, storage, auth, and SSE remain todo!() stubs for later phases.
…ends MemoryStorage backs the test suite and the in-process path; PostgresStorage persists to <schema>.collab_documents via sqlx runtime queries (no compile-time macros, so the crate builds with no live DB and no offline metadata). connect() validates the schema identifier against [A-Za-z_][A-Za-z0-9_]* before interpolating it into DDL (Postgres cannot bind identifiers), then bootstraps the schema and table idempotently. The IF NOT EXISTS forms are not atomic at the catalog level, so a small helper absorbs the lost-race SQLSTATEs (23505, 42P06, 42P07) when concurrent connects collide on pg_namespace / pg_class. Verified: storage_pg --ignored 4/4 green against local Postgres; the rest of the default suite still fails on Phase 5/6 todo!() stubs with 0 spurious passes; protocol_conformance 8/8 still green; fmt + clippy clean.
Implements the concurrency core for the collab server (Phase 5). Document - Owns Awareness (which owns the Doc), per-connection broadcast::Senders for fan-out, an Arc<PersistState> shared with a write-behind persist task, and the yrs::Subscription returned by observe_update_v1 (retained as a named field so the observer survives past the first callback). - join allocates a per-connection ConnectionId + dedicated broadcast channel, so echo-skip is structural: peer fan-out iterates the map and skips the originating id, no wire tag required. - handle dispatches SyncStep1/SyncStep2/Update/Awareness/AwarenessQuery; malformed Update bytes are logged and treated as a state-change signal (debounced persist still fires) rather than propagated as a storage error. - Debounced write-behind: a single tokio task waits on Notify, sleeps until last_change + window, re-arms if a fresher poke arrived during sleep, and on quiescence snapshots state under the awareness lock (then drops the guard) before awaiting storage.store. A burst inside the window coalesces to exactly one store. The task is aborted in Document::Drop. DocumentRegistry - DashMap<String, Arc<OnceCell<Weak<Document>>>> with Arc::new_cyclic so each Document receives a Weak<DocumentRegistry> back-reference at construction. - get_or_load uses tokio::sync::OnceCell::get_or_try_init for single-flight loading (one Storage::fetch under concurrent first-load); a per-call Mutex<Option<Arc<Document>>> side-channel hands the initiating caller the strong Arc while the cell stores only a Weak. Concurrent peers upgrade the Weak (the winner is mid-return holding the strong ref). - Document::Drop self-removes its registry cell iff conns is empty, so an unjoined idle doc is collected as soon as its last external Arc releases. A still-joined doc whose last external Arc happens to drop is left in the map so a subsequent evict_now still reports it as present. - evict_now removes the cell, flushes when the Weak upgrades, and returns presence at call time. Dependencies - yrs gains the sync feature so Awareness's observer callbacks are Send + Sync (required to share Arc<Mutex<Awareness>> with the spawned persist task). - parking_lot::Mutex replaces std::sync::Mutex for all internal locks: no Result return on lock(), no .unwrap() on infallible paths. Plan doc updated to reflect the per-connection broadcast topology and the Weak-storage + conditional Drop auto-eviction registry design. Gates green: - tests/document_sync.rs 4/4 - src/document_tests.rs::debounced_writes_coalesce_a_burst - src/registry_tests.rs::evict_drops_inner_entry_after_last_arc_release - src/registry_tests.rs::concurrent_get_or_load_does_not_double_load Remaining suite still fails on stubs (auth Phase 6); zero spurious passes. clippy --all-targets -D warnings clean; cargo fmt --check clean.
forget() removed the cell by name unconditionally. Under a multi-threaded runtime a concurrent reload can replace a dead cell with a fresh live Document reusing the same name between the dying doc's last-Arc drop and its Drop reaching forget(); the unconditional remove would then orphan the live entry, producing two divergent Documents for one name. Guard the removal on the cell's Weak being unupgradeable so only a genuinely dead cell is collected.
Prefer Rust combinator/functional style over procedural loops where it reads clearly, and keep comments compact but useful to a human developer (explain why, one short line, not absent). Adds both as Code Review Checklist items.
…rage.rs Bring Phase 3/4 code up to the functional-style standard where each expression reads like a logical sentence. No behavior change (protocol_conformance 8/8, storage_pg 4/4 still green). - protocol.rs: replace match-on-Result and let-then-wrap with map/map_err chains (read_var_string, Awareness/SyncStep1/SyncStatus decode arms). - storage.rs: validate_schema_ident as a single boolean predicate + then_some; is_concurrent_bootstrap_race via is_some_and; fetch via map(...).transpose().
Drops the three .lock().unwrap() sites in MemoryStorage by switching its std::sync::Mutex to parking_lot::Mutex, matching the no-unwrap-in-production rule and the pattern already used in document.rs/registry.rs. Derives still hold (parking_lot::Mutex is Default + Debug). No behavior change.
Wire the protocol/document/registry/auth/storage layers into a live axum server. New ws.rs hosts the per-connection actor (single-task sink ownership, StreamMap fan-in for peer broadcast, per-doc authz gate); new rest.rs hosts POST/DELETE /api/documents/:name with verbatim Authorization compare. main.rs becomes a thin tracing-subscriber + serve() entrypoint. serve() refuses to start if either shared secret is absent (an empty fallback would silently accept every token and every management call), installs Ctrl-C as the graceful-shutdown future, and after the listener stops calls registry.flush_all() so debounced writes still in-flight at shutdown land in storage before exit. Two additive, non-breaking registry methods: - new_with_debounce(storage, persist_debounce) threads the configured debounce into every Document, while new() keeps the frozen default. - flush_all() snapshots strong refs out of the DashMap and serially flushes each live document. Required because Document::Drop aborts the persist task without flushing. No frozen test was modified. Plan doc updated to reflect the single-sink + StreamMap concurrency shape, the log-and-continue Lagged handling, and the new registry surface.
…e rejected over WS)
… runbook Add a Known gaps / follow-ups section to the plan (DELETE-evict, constant-time auth compare, idle keepalive, SyncStatus-after-initial-sync) capturing the gap between our validated subset and faithful Hocuspocus, and commit the Phase 9 local end-to-end verification runbook.
…e test, retire 3 obsolete/covered stubs
…-collab-server in system/crate/network diagrams - New docs_collab_server_components.md: first-order module view across docs-collab-server, refactor-platform-rs, and refactor-platform-fe, with the three cross-component contracts and the two shared-secret invariants. - system_architecture: external TipTap node becomes docs-collab-server; add the frontend's direct collaboration WebSocket edge (same topology as TipTap Cloud, endpoint moved). - crate_dependency_graph: add docs-collab-server as a standalone, edgeless crate (no app-crate deps, excluded from default-members, extractable). - network_flow: add docs-collab-server as a PLANNED droplet element (validated locally; production routing/deploy not yet done).
Member
Author
|
Frontend companion PR: refactor-group/refactor-platform-fe#409 (config-only provider repoint). Merge/deploy must be coordinated; the JWT signing key and management auth key must match across both. |
Replace the hardcoded postgres://refactor:password@localhost connection string with a pointer to the environment/.env, so no local credentials live in the doc.
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.
Description
Adds docs-collab-server, a self-hosted, Hocuspocus-compatible real-time collaboration server (Rust/Axum) that replaces TipTap Cloud for coaching notes. It speaks the Hocuspocus wire protocol over
yrs(Yjs) and persists document state to our own Postgres, so collaborative editing no longer depends on a paid, document-capped external service.The existing application crates are unchanged: only the
tiptap_*config values repoint from TipTap Cloud to the local server. The frontend change is a config-only repoint and ships as a separate, coordinated PR inrefactor-platform-fe.docs-collab-serveris a standalone workspace member (excluded fromdefault-members, with no dependency on any app crate) so it can be extracted to its own repository later.GitHub Issue: link if applicable
Changes
docs-collab-servercrate: Axum WebSocket + REST server; in-memory document registry with single-flight load and idle eviction; debounced write-behind persistence to acollab_documentstable.yrsencoding, the sync + awareness handshake, and thePOST/DELETE /api/documents/:namemanagement API the app backend already calls.allowedDocumentNameswildcard), using the same signing key the app backend mints with.tiptap_*settings repoint to the local server.Testing Strategy
cargo test -p docs-collab-serverruns the unit suite, the frozen protocol conformance tests (decode/encode verified byte-for-byte against real@hocuspocus/providercapture fixtures), and the document-sync integration tests.storage_pg(Postgres),e2e_provider(real WebSocket handshake), andauthz_e2e(cross-scope / bad-signature rejection over the wire).docs/test-plans/docs-collab-server-local-e2e.md: two-browser real-time text sync, presence, persistence across a server restart, CRDT offline merge, REST document provisioning/deletion, and scope-based auth rejection.Concerns
docs-collab-server.SyncStatus-after-initial-sync. None block local use.