Two-Service Architecture
chronicle beginner 4 min read
ELI5
Chronicle is a postal system with two buildings. The app/ building (Next.js) is the front desk where customers write letters. The relay/ building (Rust/Axum) is the sorting room that stamps every letter with a clock, files a copy, and shouts the contents into rooms where other customers are listening.
Technical Deep Dive
Repository Layout
| Path | Stack | Role |
|---|---|---|
app/ | Next.js 14 App Router, Zustand+Immer, TanStack Query, Tailwind, Vitest | Editor UI, presence, timeline scrubbing |
relay/ | Rust, Axum, tokio-tungstenite, sqlx, SQLite, jsonwebtoken | Auth, CRDT op relay, persistence, timeline reconstruction |
docs/adr/ | Markdown | ADRs 001–006 (protocol, auth, persistence, CRDT, editor integration, timeline) |
There is no workspace tooling — each service has its own package.json / Cargo.toml and CI.
Service Boundary
flowchart LR subgraph Browser UI[Next.js App<br/>app/src] CRDT[CrdtDocument<br/>+ ClientHlc<br/>app/src/services] end subgraph Relay[Rust Relay] HTTP["/api routes<br/>handlers/documents.rs"] WS["/ws/documents/:id<br/>handlers/websocket.rs"] AUTH[AuthService<br/>services/auth.rs] OPS[OperationRepository<br/>services/operation.rs] HLC[HlcClock<br/>services/hlc.rs] end DB[(SQLite<br/>chronicle.db)] UI -- REST: documents CRUD --> HTTP UI -- WebSocket JSON --> WS WS --> AUTH WS --> OPS WS --> HLC HTTP --> OPS OPS --> DB HTTP --> DBProtocol Mirror
The wire protocol is mirrored in two languages and must stay in sync:
- Rust source of truth:
relay/src/protocol/(schema.rs,error_codes.rs,validation.rs) - TypeScript mirror:
app/src/services/protocol.ts
Any change to a message shape, error code, or PROTOCOL_VERSION lands in both files. See ADR-001.
Top-level Context
---title: "Chronicle-HQ system context"---flowchart TD user(("<b>Editor user</b><br/>Writes documents collaboratively")):::person subgraph chronicle ["**Chronicle-HQ**"] app["<b>app/ (Next.js)</b><br/>Editor + Timeline UI"]:::system relay["<b>relay/ (Rust/Axum)</b><br/>Auth, CRDT relay, persistence"]:::system sqlite[("<b>SQLite</b><br/>documents, operations, snapshots")]:::db end idp["<b>External Auth Provider</b><br/>Issues HS256 JWTs"]:::ext user -- "HTTPS" --> app app -- "REST /api + WebSocket /ws" --> relay relay -- "sqlx (WAL mode)" --> sqlite idp -- "Tokens" --> app
classDef person fill:#1c1c24,stroke:#e85d3e,color:#f0ece6 classDef system fill:#1c1c24,stroke:#d4a574,color:#f0ece6 classDef ext fill:#141419,stroke:#8b7e74,color:#f0ece6,stroke-dasharray: 4 3 classDef db fill:#1c1c24,stroke:#d4a574,color:#f0ece6 classDef container fill:#1c1c24,stroke:#d4a574,color:#f0ece6Key Terms
- relay → the Rust collaboration server in
relay/(the brand also reuses the word, but in code “relay” always means this service). - app → the Next.js frontend in
app/; path alias@/*→app/src/*. - PROTOCOL_VERSION → integer constant defined in both
relay/src/protocol/mod.rsandapp/src/services/protocol.ts; clients send it insideAuthenticate. - mirror file → a TS or Rust file whose entire job is to keep wire types identical across services.
Q&A
Q: Why no workspace manager (turborepo / pnpm workspaces)? A: Per CLAUDE.md, each service has its own deps and CI by design — the only cross-cutting concern is the protocol mirror, which is two files.
Q: Where is the database physically stored at runtime?
A: RELAY_DATABASE_URL defaults to sqlite:/app/data/chronicle.db?mode=rwc, a path that only exists inside the Docker image. Local dev must override.
Q: Does the app talk to SQLite directly?
A: No. Only the relay opens the SQLite pool (relay/src/services/state.rs::AppState::new); the app reaches data via REST and WebSocket.
Examples
A “type a character” round-trip touches all three layers: editor diff (app/src/services/diff.ts) → CRDT op → WebSocket frame → handlers/websocket.rs → OperationRepository::store → SQLite → broadcast back to every other connected client.
neighbors on the map
- WebSocket Session Lifecycle adding a new privileged WS handler
- Documents REST & SQLite Persistence wiring a new document property