docs: the port as it now is — the enforced turn, the mobile shell, protocol 2
README: the bundle's checks and what they measure, stable lemma ids and curriculum words as cards, the one resolver and the audit it matches, the turn's rules (earned progress, recall evidence, the 다지기 checklist, the letter-level check, the prompt's cached prefix), the five-destination shell with history, answer mode and word lookup, and sync's three rules. The known limitation is the one lib/blocks.js still has. server/README: the endpoints as they are — systemTail, the paged pull, the compare-and-swap push, the protocol header and its 426 — and how protocol 2 works: hydrate first, a counter not a clock, the copy that holds more wins, shrinking only ever declared. The Caddy setup is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
112
server/README.md
112
server/README.md
@@ -8,13 +8,16 @@ 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
|
||||
POST /api/tutor {system, systemTail, history, message} → SSE token stream
|
||||
GET /api/sync ?cursor=N → {epoch, rows, cursor, more}: a page of rows after N
|
||||
POST /api/sync {rows} → {epoch, applied, conflicts}: compare-and-swap writes
|
||||
GET /health → no auth, for the healthcheck
|
||||
```
|
||||
|
||||
Everything under `/api` requires `Authorization: Bearer $HANKAN_TOKEN`.
|
||||
Everything under `/api` requires `Authorization: Bearer $HANKAN_TOKEN`, and
|
||||
`/api/sync` also requires `x-hankan-protocol: 2`. An older app gets
|
||||
**426 Upgrade Required** rather than a write the server would misread. See
|
||||
[How sync works](#how-sync-works).
|
||||
|
||||
### CORS — required for the phone
|
||||
|
||||
@@ -42,8 +45,11 @@ 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.
|
||||
The schema applies itself on boot. `server/sql/001-schema.sql` and
|
||||
`002-protocol-2.sql` are idempotent, so there is no migration step to run by
|
||||
hand. The first boot on protocol 2 drops any protocol-1 rows once; every
|
||||
device then hydrates and offers its own database back, which was always the
|
||||
source of truth.
|
||||
|
||||
### 2. Configure
|
||||
|
||||
@@ -98,30 +104,62 @@ 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.
|
||||
In the app: 오늘 → ⚙ 설정 → Server and sync → the URL and the token. It syncs
|
||||
on connect, when the app 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.
|
||||
Protocol 2 (`shared/sync-protocol.mjs`, `app/src/sync/`, `server/src/db.ts`).
|
||||
It is row-level, and every row carries three columns on the device: `base_seq`,
|
||||
the server version it last agreed with; `dirty`, set by every edit; and `rev`,
|
||||
which tells a push that lands after a further edit not to clear it.
|
||||
|
||||
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.
|
||||
The artifact lost a week of work to its sync, and each of the three rules
|
||||
below closes one way that happened.
|
||||
|
||||
Three things are load-bearing:
|
||||
**1. Hydrate before pushing.** A device pulls every page before it may push
|
||||
anything. A laptop last opened a week ago comes back, adopts the week of work
|
||||
the phone did, and only then offers its own edits. Changing the server, or the
|
||||
server's *epoch* (a random id replaced whenever its copy is thrown away),
|
||||
forces a full hydration again.
|
||||
|
||||
- **`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.
|
||||
**2. A counter, not a clock.** The server keeps a `change_seq` per row and
|
||||
writes a pushed row only if the push's `base` equals it: compare-and-swap.
|
||||
Anything else comes back as a conflict, with the server's current copy.
|
||||
`updated_at` is kept for display and decides nothing, so a device with its
|
||||
clock an hour out cannot win by it. Pushes are serialised and pulls take a
|
||||
shared advisory lock, so a pull can never skip a `change_seq` that a
|
||||
concurrent push is about to commit.
|
||||
|
||||
**3. No silent shrinking.** A conflict goes to `app/src/sync/resolve.ts`,
|
||||
where the copy that holds more wins: a card with more reviews behind it,
|
||||
evidence with more observed, progress merged (done if either finished it,
|
||||
the higher answer count), the further unit on the roadmap, grammar notes and
|
||||
flags unioned, the better reading-drill round. A tie goes to the server, so
|
||||
every device lands on one copy.
|
||||
|
||||
Shrinking is always declared, never inferred:
|
||||
|
||||
- **Deliberate deletes** travel as tombstones, and a delete is obeyed against a
|
||||
concurrent edit: a card forgotten on one device stays forgotten.
|
||||
- **Resets** (clearing the lesson, resetting the roadmap or everything) raise
|
||||
a marker, `reset.<scope>`, a counter every device compares with the last one
|
||||
it applied. A device that had not heard of the reset empties the same
|
||||
things, its own offline work included, because a reset it did not know about
|
||||
still wins.
|
||||
- **Trimming the transcript is local.** A device keeps its last turns on
|
||||
screen, but the trim is never a deletion, and a turn not yet pushed is never
|
||||
trimmed. The artifact's trim tombstoned the other device's turns.
|
||||
|
||||
Also fixed on the way: keys are JSON arrays, so a key containing a space
|
||||
(몇 명) no longer stops sync permanently; chat ids are UUIDv7, so two devices
|
||||
writing offline cannot collide; and the study log and peek tallies are kept per
|
||||
device and summed, so two devices' reviews of the same day both count.
|
||||
|
||||
Rows are stored generically on the server, the primary key as text and the
|
||||
body as JSONB, because the server never reads inside a row. It stores, orders
|
||||
and versions them; the client interprets them. That keeps the two schemas from
|
||||
having to move in lockstep.
|
||||
|
||||
### What never syncs
|
||||
|
||||
@@ -133,13 +171,15 @@ 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 bug this 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 server's real history, and clobbered it. Here a seeded row is never dirty,
|
||||
so it has nothing to push, and a device hydrates before it pushes at all. The
|
||||
week-old-laptop case, clock skew, offline chat on two devices, resets against
|
||||
devices that missed them, a forget racing a review, keys with spaces, and a
|
||||
server that lost its data are each a scenario in `test/sync/roundtrip.test.ts`,
|
||||
run against a real Postgres.
|
||||
|
||||
## The tutor
|
||||
|
||||
@@ -147,10 +187,14 @@ conflict. It is not avoided, it is unrepresentable —
|
||||
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.
|
||||
The system prompt comes in two parts. `system` is the shipped prompt with the
|
||||
gate filled in, ~12k characters that stay byte-identical for as long as the
|
||||
learner stays in one unit. It is sent as a cached block, so every turn after
|
||||
the first reads it at a fraction of the input price, the single biggest cost
|
||||
lever in the design. `systemTail` holds what changes every round (the
|
||||
practice set, the 다지기 checklist, the reason a draft was refused) and follows
|
||||
as a second, uncached block, so it never breaks the cached prefix. The
|
||||
OpenAI-compatible backend joins the two, prefix first.
|
||||
|
||||
### Choosing a model
|
||||
|
||||
|
||||
Reference in New Issue
Block a user