Skip to content

Self-hosted docs-collab-server + deployment (replaces TipTap Cloud) - #371

Open
jhodapp wants to merge 56 commits into
mainfrom
feat/docs-collab-deploy
Open

Self-hosted docs-collab-server + deployment (replaces TipTap Cloud)#371
jhodapp wants to merge 56 commits into
mainfrom
feat/docs-collab-deploy

Conversation

@jhodapp

@jhodapp jhodapp commented Jul 17, 2026

Copy link
Copy Markdown
Member

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

  • New docs-collab-server crate: Hocuspocus/Yjs WebSocket server, HS256 JWT auth, REST document management, debounced Postgres persistence into collab_documents.
  • Production: collab runs on its OWN dedicated managed Postgres (DOCS_COLLAB_DATABASE_URL + that cluster's CA), separate from the app DB; connection pool right-sized to 4.
  • Dedicated docs-collab-server/Dockerfile + its own GHCR image; compose service (prod + PR preview); nginx /collab and /pr-<NUM>/collab routes.
  • PR preview pipeline builds and deploys the collab image and wires NEXT_PUBLIC_DOCS_COLLAB_URL (frontend build-arg + runtime).
  • TipTap Cloud -> collab_documents importer (dry-run capable) for the eventual cutover.

Testing Strategy

  • Local e2e runbook docs/test-plans/docs-collab-server-local-e2e.md (spot-checked on dev).
  • PR preview on RPi5: dispatch dispatch-pr-preview.yml for this PR (workflow ref feat/docs-collab-deploy), import real Cloud docs into the preview DB, run the 7-step checklist against ws://<rpi>/pr-<NUM>/collab.
  • Preview shares the per-PR Postgres container; the dedicated managed-DB verify-full TLS path is production-only and is validated at cutover.

Concerns

  • Migration m20260604_000000_create_collab_documents is 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.
  • Production cutover (import + TIPTAP_URL flip) is out of scope here and requires provisioning the managed cluster + setting DOCS_COLLAB_DATABASE_URL / DOCS_COLLAB_SSL_ROOT_CERT prod secrets first.
  • Frontend-only previews reference docs-collab:main-arm64 (not yet published); backend-PR previews build the per-PR image and work.
  • Spec-fidelity follow-ups tracked separately (idle keepalive, SyncStatus-after-initial-sync, DELETE-evict of a live doc, constant-time management-auth compare).

jhodapp added 30 commits May 31, 2026 14:03
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.
… 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.
…-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.
Comment thread docker-compose.pr-preview.yaml
…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.
Comment thread docker-compose.pr-preview.yaml Outdated
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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code

@jhodapp
jhodapp marked this pull request as draft July 21, 2026 22:32
@jhodapp jhodapp self-assigned this Jul 21, 2026
@jhodapp jhodapp added enhancement Improves existing functionality or feature refactor Refactors part of the code infrastructure DevOps related labels Jul 21, 2026
@jhodapp jhodapp moved this to 🏗 In progress in Refactor Coaching Platform Jul 21, 2026
@jhodapp jhodapp added this to the 1.0.0-beta3 milestone Jul 21, 2026
…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.
@jhodapp jhodapp changed the title feat: self-hosted docs-collab-server + deployment (replaces TipTap Cloud) Self-hosted docs-collab-server + deployment (replaces TipTap Cloud) Jul 21, 2026
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.
jhodapp added 2 commits July 22, 2026 09:42
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.
jhodapp added 4 commits July 22, 2026 10:43
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.
@github-actions

Copy link
Copy Markdown
Contributor

🚀 PR Preview Environment Deployed!

🔗 Access URLs

Service URL
Frontend http://neo/pr-371/
Backend API http://neo/pr-371/api
Health Check http://neo/pr-371/health
Base Path /pr-371/

📊 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

🧹 Cleanup

Environment auto-cleaned when PR closes/merges


Deployed: 2026-07-22T17:14:53.764Z
Architecture: Native ARM64 build on Neo + Multi-tier caching

@jhodapp jhodapp moved this from 🏗 In progress to Review in Refactor Coaching Platform Jul 27, 2026
@jhodapp
jhodapp marked this pull request as ready for review July 27, 2026 19:52
@jhodapp
jhodapp requested review from calebbourg and lmcdonough July 27, 2026 19:53
jhodapp added 2 commits August 7, 2026 11:09
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improves existing functionality or feature infrastructure DevOps related refactor Refactors part of the code

Projects

Status: Review

Development

Successfully merging this pull request may close these issues.

1 participant