Self-hosted docs-collab-server + deployment (replaces TipTap Cloud) - #371
Open
jhodapp wants to merge 56 commits into
Open
Self-hosted docs-collab-server + deployment (replaces TipTap Cloud)#371jhodapp wants to merge 56 commits into
jhodapp wants to merge 56 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).
Replace the hardcoded postgres://refactor:password@localhost connection string with a pointer to the environment/.env, so no local credentials live in the doc.
…es sslmode=verify-full)
…preview) and healthcheck
… status in living plan
…er (dry-run capable)
… done; 5,6,8 outstanding)
…url env passthrough
…flags - Add domain::gateway::auth::Authenticator with SecretAuth (raw shared-secret, combinator-built headers). Dedupe tiptap.rs and tiptap_metrics.rs onto one auth contract; raw secret is the scheme both the per-document and list endpoints accept (Bearer is rejected). - Importer mode flags move from CLI args to env vars (IMPORT_LIST / IMPORT_DRY_RUN): Config::new() parses argv strictly via clap and rejected unknown flags, so the prior --dry-run path never actually worked. - Verified live against prod TipTap Cloud: list 258 docs, per-document export 200.
| NEXT_PUBLIC_BACKEND_SERVICE_API_PATH: ${NEXT_PUBLIC_BACKEND_SERVICE_API_PATH} | ||
| NEXT_PUBLIC_BACKEND_API_VERSION: ${NEXT_PUBLIC_BACKEND_API_VERSION:-1.0.0-beta1} | ||
| NEXT_PUBLIC_TIPTAP_APP_ID: ${TIPTAP_APP_ID} | ||
| NEXT_PUBLIC_DOCS_COLLAB_URL: ${NEXT_PUBLIC_DOCS_COLLAB_URL} |
Contributor
There was a problem hiding this comment.
Frontend Preview Has No Upstream
Frontend-only previews bake NEXT_PUBLIC_DOCS_COLLAB_URL into the frontend, but their deployment leaves DOCS_COLLAB_IMAGE empty and does not enable the collab profile. Opening the collaborative editor in one of those previews therefore connects to /pr-<NUM>/collab even though no docs-collab container exists for nginx to proxy to, resulting in a 502. Provide an ARM-compatible image for this path or omit the public collab URL when the service is not deployed.
jhodapp
marked this pull request as draft
July 21, 2026 22:32
…w 2-DB mirror Importer (Option B): drop the coaching_sessions intersection so it works with a dedicated collab database. It now imports every non-archived, non-empty Cloud doc into collab_documents via a single connection to whatever DATABASE_URL points at (the collab DB), reading nothing else from the DB. Docs whose sessions were deleted import as harmless orphan rows. Summary drops skipped_no_session. Preview: add collab-db-init (idempotent CREATE DATABASE refactor_collab) and repoint docs-collab at that dedicated DB, mirroring prod's separate DOCS_COLLAB_DATABASE_URL so the two-DB topology is rehearsed faithfully in preview. docs-collab bootstraps its own schema + collab_documents there.
Long-lived docs-collab WebSocket sessions each consume 2 file descriptors (client + upstream), so the 1024 soft-FD default capped concurrency at roughly 500 sessions per worker regardless of worker_connections. Set worker_rlimit_nofile 16384 (container hard limit already permits it) and bump worker_connections to 4096. No compose or firewall change needed; 443 stays the only host port and docs-collab 1234 stays internal.
This was referenced Jul 22, 2026
Addresses review: the prod docs-collab service coupled the whole stack to collab provisioning. An empty DOCS_COLLAB_SSL_ROOT_CERT rendered an invalid volume spec (:/app/root.crt:ro), aborting docker compose config --quiet and every deploy. Give the cert bind a /dev/null fallback so config stays valid on app-only deploys before collab secrets are provisioned, and drop nginx's depends_on docs-collab so nginx no longer waits on a service the operator did not intend to touch. Also pin the importer's schema assumption (refactor_platform, matching the collab server's default DATABASE_SCHEMA) with a comment.
The organization rename (Refactor Coaching -> migration-created Refactor Group), the jim@refactorgroup.com / jimrg-james relationship + sessions, and Caleb's Acme role change are unrelated to docs-collab and are being split into their own PR to keep this deployment change focused.
Bind the jim-other relationship so other_user has sessions, and add recurring series via entity_api (coaching_session_series::create + bulk_create_recurring): jim-caleb weekly x4, caleb-dinah bi-weekly x3, jimrg-james weekly x4. Duration follows each coach's default so the series rule and materialized sessions agree. Verified end-to-end against a local rebuild + seed.
The production image workflow built only the backend (:stable); docs-collab had no released image, so DOCS_COLLAB_IMAGE_NAME had nothing stable to point at. Mirror the backend step for docs-collab: build docs-collab-server/Dockerfile multi-arch, push ghcr.io/<repo>/docs-collab:stable, and attest provenance. Uses a docs-collab-scoped build cache so it doesn't clobber the backend cache.
docs-collab-server is a workspace member but excluded from default-members, so the existing 'cargo clippy --all-targets' and 'cargo test' steps skipped it entirely; the workflows built and pushed its Docker image without ever linting or running its Rust tests. Add explicit '-p docs-collab-server' clippy and test steps to build-test-push.yml and ci-deploy-pr-preview.yml (the live CI paths). DB-backed tests are ignored without a live Postgres, so the suite runs headless.
Contributor
🚀 PR Preview Environment Deployed!🔗 Access URLs
📊 Environment Details
🧪 Testing# Health check
curl http://neo/pr-371/health
# Frontend
curl http://neo/pr-371/
# API test
curl http://neo/pr-371/api/v1/users🧹 CleanupEnvironment auto-cleaned when PR closes/merges Deployed: 2026-07-22T17:14:53.764Z |
…ROOT_CERT The collab database lives on the same managed cluster as the app, so its CA cert secret was a byte-for-byte duplicate of POSTGRES_SSL_ROOT_CERT. Mount the app's cert path in the docs-collab container instead, matching the rust-app and migrator mounts. This also removes the service's :-/dev/null volume fallback, which made docs-collab the only service that tolerated an unset cert. All three now fail identically when POSTGRES_SSL_ROOT_CERT is missing. Verified `docker compose config` still validates with every DOCS_COLLAB_* var unset (the app-only deploy case the fallback was added for). One less production secret to provision. Updates the deployment doc, which also still described the superseded "own managed cluster" placement.
…AB_URL
Every other NEXT_PUBLIC value follows the same convention: the NEXT_PUBLIC_
prefix belongs to the container env var and the Docker build-arg, never to the
GitHub variable or the .env key (NEXT_PUBLIC_BACKEND_SERVICE_PROTOCOL:
${BACKEND_SERVICE_PROTOCOL}). The collab URL was the sole exception, carrying
the prefix all the way through.
Renames only the variable/.env-key side in both compose files and both deploy
paths. The container env var and the frontend build-arg keep their
NEXT_PUBLIC_DOCS_COLLAB_URL names, since Next.js requires that prefix to expose
the value to the browser bundle.
No production variable exists under either name yet, so this costs nothing now
and avoids renaming live prod config later.
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
Self-host collaborative document editing by replacing TipTap Cloud with an in-repo
docs-collab-server(Rust / axum / yrs), and deploy it alongside the existing stack. Real-time editing, persistence, and the Hocuspocus wire protocol are implemented and locally verified; this PR is the crate plus the deployment, routing, secrets, and DB wiring.Changes
docs-collab-servercrate: Hocuspocus/Yjs WebSocket server, HS256 JWT auth, REST document management, debounced Postgres persistence intocollab_documents.DOCS_COLLAB_DATABASE_URL+ that cluster's CA), separate from the app DB; connection pool right-sized to 4.docs-collab-server/Dockerfile+ its own GHCR image; compose service (prod + PR preview); nginx/collaband/pr-<NUM>/collabroutes.NEXT_PUBLIC_DOCS_COLLAB_URL(frontend build-arg + runtime).collab_documentsimporter (dry-run capable) for the eventual cutover.Testing Strategy
docs/test-plans/docs-collab-server-local-e2e.md(spot-checked on dev).dispatch-pr-preview.ymlfor this PR (workflow reffeat/docs-collab-deploy), import real Cloud docs into the preview DB, run the 7-step checklist againstws://<rpi>/pr-<NUM>/collab.Concerns
m20260604_000000_create_collab_documentsis SUPERSEDED for prod (the server self-bootstraps the table on its dedicated single-role DB); it is left registered, so it creates a harmless empty table in the app DB. Leave-vs-remove is a follow-up decision.TIPTAP_URLflip) is out of scope here and requires provisioning the managed cluster + settingDOCS_COLLAB_DATABASE_URL/DOCS_COLLAB_SSL_ROOT_CERTprod secrets first.docs-collab:main-arm64(not yet published); backend-PR previews build the per-PR image and work.