Skip to content

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