Architecture¶
The one idea to hold onto is that the study data lives on the devices. Every device keeps a full replica of the workspace in CRDT documents and works on it locally, offline included. The server is an optional peer that stores accounts, files and an opaque sync log, and two devices can also converge with no server at all.
1. The big picture¶
flowchart LR
subgraph Devices
B["Browser SPA<br/>(Svelte 5, IndexedDB)"]
D["Desktop app<br/>(Tauri shell, same SPA)"]
M["Phone<br/>(same shell, iOS/Android)"]
end
subgraph Server["Server (optional peer)"]
A["NestJS API"]
DB[("SQLite or Postgres")]
FS[("uploads/ + CAS")]
end
O["Ollama or any<br/>OpenAI-compatible runtime"]
P["AI providers<br/>(OpenAI / Anthropic)"]
B <-->|"HTTP sync log + files"| A
D <-->|"HTTP sync log + files"| A
D <-.->|"LAN sync (mDNS + WebSocket)"| M
B <-.->|"file pack (.zip)"| D
A --> DB
A --> FS
A -->|"generation + OCR"| P
B & D -.->|"offline generation"| O
Three deployment shapes fall out of this, all first-class:
| Shape | What syncs | Auth |
|---|---|---|
| Browser + server | HTTP sync log | Session cookie or bearer token |
| Desktop standalone | LAN peers and file packs only | None (local mode) |
| Desktop + server | HTTP log, plus LAN and packs | Bearer token |
2. Monorepo layout¶
apps/
web/ Svelte 5 SPA (Vite). All study features live here.
api/ NestJS API (clean architecture, Drizzle ORM, sqlite/pg).
shell/ Tauri 2 shell around the web build + LAN sync plumbing (Rust).
packages/
ai-core/ Provider-agnostic AI calls (prompts, retries, generation, OCR).
design-system/ Svelte UI components and tokens.
docs/ The MkDocs site: user guides, self-hosting, this file.
2.1 Inside the web app¶
apps/web/src has three layers, and imports only flow one way (lib, then
pages, then app). dependency-cruiser checks the rule as part of pnpm lint.
src/
app/ The frame: boot, router, navigation chrome (TopBar, PageMain),
the auth gate. The only layer that may import everything, since
it wires the pages into routes.
pages/ One folder per screen, nested like the navigation: course/
contains viewer/, split-pdf/, exercises/ and settings/ because
that is how you reach them. A page keeps its own component, its
parts and its logic together, imports lib and the app shell
freely, and never imports another top-level page.
lib/ Shared infrastructure with no screen of its own: data/ (CRDT
repos), documents/ (pdf pipeline), sync/, editor/ (Tiptap and
markdown rendering), ai/, plus platform utilities. lib never
imports app or pages, so everything in it stays reusable.
Placement follows use, not naming: LexiconDrawer lives in the viewer because only the viewer opens it, and workspaceExport lives in the course pages because the export button does. When a module gains a second consumer outside its page, it moves down into lib.
3. Client data layer¶
Pages never talk to the network for study data. They call repositories, which read and write Loro CRDT documents; a persistence layer stores every change incrementally in IndexedDB and a sync engine ships it out later.
flowchart TD
UI["Pages + components"] --> R["repos/ (courses, documents, links,<br/>exercises, annotations, attempts,<br/>progress, lexicon)"]
R --> W["workspace.ts<br/>commit() persists before resolving"]
W --> L["loro.ts - doc registry<br/>open, subscribe, importRemote, compact"]
L --> I[("IndexedDB 'synapse'<br/>docs / doc_updates / blobs /<br/>sync_state / meta")]
L <--> E["sync/engine.ts<br/>pull by cursor, push by version vector"]
E <--> T1["transports/http.ts<br/>(server log)"]
E -.-> T2["p2p/tauri.ts<br/>(LAN exchange)"]
F["sync/filePack.ts<br/>(.zip export/import)"] --> L
V["viewer (PDF)"] --> C["data/blobs.ts<br/>content-addressed cache"]
C --> I
The documents themselves:
| Doc id | Contents |
|---|---|
workspace |
Registries of courses, topics, documents, links, exercises, lexicon (child maps keyed by uuid) plus ordering lists (course_order, per-course layout with topic_order and items:<topic> lists) |
attempts |
Append-only, write-once map of exercise attempts (kept separate so its history never bloats workspace snapshots) |
ann:<documentId> |
One doc per PDF: annotations keyed by uid, field-level last-writer-wins |
Persistence is incremental: each local commit appends an update row; after 64 rows the doc is folded into a fresh snapshot (guarded by a web lock so two tabs never compact the same doc at once). Remote updates imported by the sync engine are persisted through the same path, so a reload never loses them.
4. How devices converge¶
Loro updates are idempotent (operations deduplicate by peer and counter), so every channel below can replay or overlap safely. A device can use all three.
4.1 Server log (HTTP)¶
The server stores base64 update blobs in an append-only log per user and doc. It never opens them, with one exception: a compaction job that folds long logs into snapshot rows.
sequenceDiagram
participant C as Client (sync engine)
participant S as Server (sync log)
C->>S: GET /api/sync/manifest
S-->>C: [{doc_id, latest_seq}]
C->>S: GET /api/sync/updates?doc_id=...&after_seq=cursor
S-->>C: {updates: [snapshot?, blobs...], latest_seq}
Note over C: import + persist, cursor := latest_seq
C->>S: POST /api/sync/updates {doc_id, updates since last pushed vv}
Note over C: push debounced 300 ms,<br/>sendBeacon on page hide
Note over S: compaction job folds >=128 rows<br/>into one snapshot row
4.2 LAN peers (desktop)¶
The Rust side of the Tauri shell advertises _synapse-sync._tcp over mDNS and
hosts a WebSocket listener that pipes every message to the webview; the whole
protocol lives in the web code (sync/p2p/exchange.ts). Both sides run the
same symmetric state machine:
sequenceDiagram
participant A as Device A (initiator)
participant B as Device B (responder)
A->>B: hello {device_id, docs: [{doc_id, version_vector}]}
B->>A: hello {...}
B->>A: doc-update* (what A lacks, from A's vv)
A->>B: doc-update* (what B lacks, from B's vv)
A->>B: blob-have {hashes}
B->>A: blob-have {hashes}
A->>B: blob-want / B->>A: blob-data*
A->>B: bye
B->>A: bye
4.3 File packs¶
Settings > Backup exports the whole workspace (doc snapshots + file blobs)
as one zip; importing it on any device merges it in. This is also the
sneakernet path between two machines that share no network.
5. Server API surface¶
The surface is deliberately small. The server holds what a device cannot do alone. There are no CRUD endpoints for courses, chapters, links, exercises, annotations, attempts or lexicon entries, because those exist only inside the Loro docs.
| Area | Endpoints | Notes |
|---|---|---|
| Auth | POST /api/auth/register\|signup\|login\|logout, GET /api/auth/status\|me, GET/POST /api/auth/users*, PUT /api/auth/password |
Passport local + OIDC single sign-on; sessions as cookie or bearer token |
| Sync log | GET /api/sync/manifest, GET/POST /api/sync/updates |
Opaque base64 Loro blobs, per user + doc, cursor-based |
| Files | POST /api/documents/upload, GET /api/documents/:id, GET /api/documents/:id/file, POST /api/documents/:id/ocr, DELETE /api/documents/:id |
Upload converts (LibreOffice/pandoc), extracts text, OCRs scans, stores bytes in the CAS |
| PDF export | POST /api/documents/:id/export |
Client sends its annotations; server bakes them into the PDF bytes |
| Blobs (CAS) | GET/PUT /api/blobs/:hash, POST /api/blobs/diff |
Content-addressed by sha-256, deduplicated across users |
| AI | POST /api/exercises/generate, GET/PUT /api/settings/ai |
Generation returns rows, stores nothing; course context travels with the request |
Server-side tables mirror that surface: users, sessions, documents
(file paths, extracted text, hashes), blobs, crdt_updates,
crdt_snapshots, app_settings. Nothing else.
6. AI routing¶
flowchart TD
G["Generate exercises"] --> Q{Server reachable?}
Q -->|yes| S["POST /api/exercises/generate<br/>server key, configured provider<br/>(OpenAI gpt-4o-mini by default)"]
Q -->|"no (offline / standalone)"| L["Device model via OpenAI-compatible runtime<br/>(Ollama, http://localhost:11434/v1)"]
S --> W["Rows inserted into the local workspace doc"]
L --> W
W --> Y["Synced to other devices like any other change"]
OCR of scanned PDFs runs server-side on upload (it follows the same provider settings). See AI.md for keys and provider setup.
7. Where to start reading¶
| Question | File |
|---|---|
| How is an entity stored and listed? | apps/web/src/lib/data/repos/courses.ts, .../containers.ts |
| How does a change reach the disk? | apps/web/src/lib/data/loro.ts |
| How does a change reach other devices? | apps/web/src/lib/sync/engine.ts, .../protocol.ts |
| What happens on first launch? | apps/web/src/lib/data/bootstrap.ts |
| How do LAN peers talk? | apps/web/src/lib/sync/p2p/exchange.ts, apps/shell/src-tauri/src/lib.rs |
| What does the server accept? | apps/api/src/app.module.ts (controllers list) |
| How is the log kept small? | apps/api/src/application/compaction.service.ts |
| How are files deduplicated? | apps/api/src/infrastructure/external/cas.ts, apps/web/src/lib/data/blobs.ts |