Files
Hankan/server/README.md
MechaCat02 1c666cdf3b chore(server): container, compose, and the Pi deployment notes
compose.yaml joins the Pi's existing network as external and reaches the
Postgres and Caddy already there by name; nothing is published to the
host. The schema applies itself on boot, so there is no migration step to
run by hand.

The Caddy snippet in the README is the part worth reading. Excluding
/api/tutor from `encode` matters more than flush_interval: compression
delays the header flush until body bytes arrive and holds already-flushed
events inside an unfinished frame, which presents as a stream that hangs
rather than as an error.

Untested against the actual Pi — written from PORT.md and verified only
as far as building the image and running it against a local Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:58:01 +02:00

166 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# server — sync and the tutor
Two endpoints, both stateless. The client owns its database and its
transcript; this process owns a Postgres table and an API key.
**The app does not need this to work.** With no server configured it runs
entirely offline against its own SQLite and a local stand-in tutor. Adding a
server turns on two things: the real 선생님, and syncing between devices.
```
POST /api/tutor {system, history, message} → SSE token stream
GET /api/sync ?cursor=N → rows newer than the cursor
POST /api/sync {rows} → upsert, last-write-wins
GET /health → no auth, for the healthcheck
```
Everything under `/api` requires `Authorization: Bearer $HANKAN_TOKEN`.
## Setting it up on the Pi
### 1. A database in the Postgres you already run
```sh
docker exec -it <your-postgres-container> psql -U postgres <<'SQL'
CREATE ROLE hankan LOGIN PASSWORD 'pick-something-long';
CREATE DATABASE hankan OWNER hankan;
SQL
```
The schema applies itself on boot — `server/sql/001-schema.sql` is idempotent,
so there is no migration step to run by hand.
### 2. Configure
```sh
cd server
cp .env.example .env
$EDITOR .env # DATABASE_URL, HANKAN_TOKEN, ANTHROPIC_API_KEY
docker network ls # find the network your Postgres and Caddy share
```
`compose.yaml` declares that network `external`, so it attaches to your
existing stack rather than starting a second Postgres. Nothing is published
to the host: Caddy reaches the container by name on the shared network.
### 3. Caddy
```caddyfile
hankan.example.com {
# Compression must not touch the tutor stream — see below.
encode zstd gzip {
match {
not path /api/tutor*
}
}
handle /api/tutor* {
reverse_proxy hankan:8787 {
flush_interval -1
transport http {
read_timeout 0
write_timeout 0
}
}
}
handle {
reverse_proxy hankan:8787
}
}
```
**Compression is what usually breaks SSE, not buffering.** `encode` delays the
header flush until body bytes arrive and holds already-flushed events inside
an unfinished compression frame, so the stream looks like it hangs. Excluding
the tutor route is the important line; `flush_interval -1` is belt-and-braces
(Caddy already auto-flushes `text/event-stream`) and the zero timeouts stop a
long turn being cut off mid-lesson. Do not set `response_buffers` on this
route.
The server also sends `Cache-Control: no-cache, no-transform` and a `: ping`
heartbeat every 15s, which defeat most intermediary caching and idle timeouts.
### 4. Connect the app
In the app: 오늘 → 서버 → the URL and the token. It syncs on connect, when the
tab regains focus, and every five minutes.
## How sync works
Row-level, last-write-wins on `updated_at`, cursor-based on a server-assigned
`change_seq`. One user, so the loser of a conflict is at worst one SRS grade.
Rows are stored generically — primary key as text, body as JSONB — because the
server never reads inside a row. It stores and orders them; the client
interprets them. That keeps the two schemas from having to move in lockstep.
Three things are load-bearing:
- **`change_seq` advances on every update, via a trigger.** A row edited after
a client last pulled would otherwise sit below that client's cursor and
never be delivered. Putting it in a trigger means no write path can forget.
- **The pull cursor advances only as rows are applied**, never from the push
response. The server's newest `change_seq` includes rows this device has not
seen; adopting it would skip them permanently, and nothing would ever ask
for that range again.
- **Deletes travel as tombstones.** A deleted row leaves nothing to compare
timestamps against, so without one the other device pushes its still-live
copy back and the row silently returns.
### What never syncs
`meta` holds the learner's preferences *and* bookkeeping that describes one
install, so an allowlist decides what may leave the device
(`shared/sync-protocol.mjs`). `dict.loadedBands` is the dangerous one:
replicating it would tell a phone that had loaded bands 02 that it holds
every row the desktop has, and the word rail would then fail to find words it
believes are present. `server.token`, `sync.*` and `schema_version` are
excluded for related reasons.
### The bug this schema is shaped around
The original artifact stamped a fresh device's empty defaults as newer than
the server's real history, and clobbered it. Here seeded and defaulted rows
carry `updated_at = 0`, so they can never be dirty and can never win a
conflict. It is not avoided, it is unrepresentable —
`test/sync/roundtrip.test.ts` asserts it against a real Postgres.
## The tutor
`POST /api/tutor` takes the assembled system prompt, the transcript the client
owns, and the new message; it holds nothing between requests, so a dropped
connection costs one turn rather than the conversation.
The system prompt is passed as a cached block. It is ~12k characters of gate
and is byte-identical for as long as the learner stays in one unit — many
turns — so every turn after the first reads the prefix at a fraction of the
input price. This is the single biggest cost lever in the design.
`backends/` holds the seam. `anthropic.ts` is the Claude API and is the
default. `agent-sdk.ts` documents the subscription-billed path PORT.md
originally specified and why it is not implemented — chiefly that its prompt
accepts only user-role messages, so the transcript would have to be flattened
into one turn.
## Running it locally
```sh
docker run -d --name hankan-pg-test \
-e POSTGRES_PASSWORD=test -e POSTGRES_DB=hankan -p 55432:5432 postgres:16-alpine
DATABASE_URL=postgres://postgres:test@localhost:55432/hankan \
HANKAN_TOKEN=test-token PORT=8788 HANKAN_TEST_MODE=1 \
node --experimental-strip-types server/src/main.ts
# from the repo root, in another shell
HANKAN_TEST_SERVER=http://localhost:8788 npm test
```
`HANKAN_TEST_MODE=1` adds `POST /api/test/reset`, which wipes the user's rows
so each test starts clean. It exists only when that variable is set, so it
cannot be reached on the Pi even if the token leaks. **Never set it in
production.**
The tutor's own tests run against a mock backend and need no API key.