MechaCat02 f447f881c0 feat(sync): tombstones, the wire protocol, and the client loop
Row-level last-write-wins on updated_at, cursor-based on a server-assigned
change_seq. The schema was built for this in step 1, so the work here is
the three things it did not yet have.

Tombstones (migration 4). Row-level sync cannot express a delete: with the
row gone there is nothing to compare timestamps against, so the other
device pushes its still-live copy back and the row silently returns. Every
delete path now writes a tombstone inside the same transaction.

The wire format lives in shared/sync-protocol.mjs and is imported by both
sides, so there is one definition rather than two that drift. It carries
the syncable-meta allowlist, which is the load-bearing part: meta mixes the
learner's preferences with bookkeeping that describes one install, and
replicating dict.loadedBands would tell a phone that had loaded bands 0-2
it holds every row the desktop has — the word rail would then fail to find
words it believes are present.

The sync loop pushes first, then pages the pull. Two details it would be
easy to get wrong, both commented at their site:

- 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 skips them permanently, and nothing ever asks for
  that range again.
- Pulled rows advance sync.pushedAt too, bounded by the instant the sync
  started. Otherwise they look like local edits and get pushed straight
  back, and an edit made during the sync is not swept up with them.

Seeded rows carry updated_at = 0, so a fresh device is never dirty and can
never win a conflict — the artifact's clobbering bug stays unrepresentable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:57:32 +02:00
2026-09-08 19:13:53 +02:00

Hankan · 한칸

A Korean reading tutor for manhwa. One codebase, two shells: a web app and an Android app. Works entirely offline — there is no server.

Ported from a single-file Claude artifact. The curriculum, the tutor prompt and the five logic modules came finished and tested; this repo is the application built around them.


What is here

data/  lib/  prompt/  validate.mjs     verbatim from the export bundle — do not edit
shared/                                band table + phonological ladder, shared by app and build
types/                                 TypeScript declarations for lib/ and shared/
tools/dict/                            the dictionary build pipeline
app/                                   Vite + React + TypeScript, and the Capacitor shell
  src/db/                              one storage interface, two SQLite drivers
  src/domain/                          the gate, the lexicon, SRS, the stub tutor
  src/ui/                              six tabs, the review overlay, the 한글 keyboard
  public/dict/                         generated dictionary — committed, shipped
test/                                  golden tests for lib/, driver conformance, domain logic
server/                                placeholder; PORT.md steps 4-6

data/, lib/, prompt/ and validate.mjs are byte-identical to the export and are excluded from lint and formatting. lib/conjugation.js encodes the seven Korean irregular classes and is the reason no runtime morphological analyser is needed; lib/hangul.js implements 두벌식 composition. Types are supplied alongside in types/, so neither file had to be touched.

Running it

npm install
npm run dict:build     # only needed if app/public/dict/ is missing or stale
npm run dev            # http://localhost:5173
npm run check          # validate.mjs, typecheck, tests, roadmap assertion
npm run build          # production build + service worker

Android

The Capacitor project lives at app/android/ and is committed. It needs the Android SDK, which this repo does not install:

export ANDROID_HOME=/path/to/Android/Sdk
npm run build
npm run cap:sync
cd app/android && ./gradlew assembleDebug

npx cap sync copies app/dist/ — including the dictionary — into the APK's assets, so the phone build is as offline as the web one.

How it works

Storage — one interface, two drivers

app/src/db/ exposes a single Db interface. sqlite.web.ts runs @sqlite.org/sqlite-wasm in a dedicated Worker over the OPFS SAHPool VFS (the plain OPFS VFS needs COOP/COEP headers, which neither a static host nor the Capacitor webview reliably provides). sqlite.native.ts uses the Capacitor SQLite plugin. Both apply the same migration array from migrations.ts.

Seeded state never carries a write timestamp. The artifact had a sync bug where a fresh device stamped its own empty defaults as newer than the server's real history and clobbered it. Three layers stop that from being expressible: every syncable table declares updated_at INTEGER NOT NULL DEFAULT 0, so forgetting the column is the safe failure; db/writes.ts splits every mutation into seedX() (never stamps) and editX() (always stamps) and is the only file allowed to read the clock; an ESLint rule enforces that, and the conformance suite asserts a freshly seeded database has no non-zero updated_at anywhere.

The dictionary

npm run dict:build merges a dictionary source, a frequency list, the curated data in data/, and a hand-written grammar lexicon, then runs lib/conjugation.js → surfaceForms() over every verb and adjective to fill the surface table. Lookup of a conjugated form is therefore an index hit, not an analysis — no runtime morphological analyser ships.

Sources are chosen automatically: KRDICT if a download has been vendored (it has curated learner glosses and a graded 초급/중급/고급 level), otherwise the kaikki.org Korean extract. KRDICT's download page is a JavaScript form behind anti-bot protection, so it cannot be fetched by a script — see the comment at the top of tools/dict/fetch.mjs.

Frequency needs care: a subtitle frequency list holds surface forms while a dictionary holds lemmas, and a naive join on the headword gives every verb a frequency of roughly zero. tools/dict/freq-forms.mjs inverts the join — expanding each lemma into the forms it plausibly takes and summing — which recovers 하다 from 118 to 89,041.

Output is one gzipped row dump per band, committed under app/public/dict/. They are static assets in the bundle and in the APK's assets, so no server is involved. Attribution is in NOTICE.md; share-alike attaches to the dictionary data, not to this code.

The gate

lib/gate.js computes what the tutor may teach from the curriculum plus progress, and renderGate() renders it into {{GATE}} in prompt/tutor-system.md, which ships unchanged. buildGate() takes a vocabQuery hook; app/src/domain/gate.ts fills it with a frequency-band query so vocabulary grows as units are finished. Three refinements sit inside that hook, all of them narrowing:

  1. words a not-yet-finished unit is the first to introduce are excluded, so a band ceiling cannot smuggle 3.4's material into 2.1;
  2. during Phase 1 the results are filtered by the same phonological ladder validate.mjs checks, so a band cannot hand the learner a 겹받침 at 1.4;
  3. the list is capped at 800 by frequency, because renderGate() inlines it into the prompt. The cap is strictly more restrictive than the band.

Confidence is clamped to MAX_DELTA_PER_TURN per turn. The artifact wrote the model's number straight into the advancement gate, so one hallucinated ::progress 95 could skip a unit.

The tutor

app/src/domain/stub-tutor.ts stands in for the model and implements the exact contract the real endpoint will (onText receives cumulative text; an aborted turn keeps what it streamed). It rotates all four exercise types, builds its ::words from the current unit's real vocabulary, and climbs ::progress gradually, so every render path — including the 85% advancement banner — is reachable with no server. Swapping in the Pi's SSE endpoint later touches only that file.

Checks

Command What it guards
node validate.mjs the curriculum. PASS — 0 blocking, 0 advisory
npm test lib golden tests, driver conformance, the seeded-timestamp rule, the gate
npm run dict:assert 371/371 roadmap words resolve (was 167 unglossable)
npm run typecheck · npm run lint types, and the clock guard on src/db/

CI runs them in that order, validate.mjs first.

Not in this pass

No sync layer, no tutor server endpoint, no Play Store packaging — PORT.md steps 46. The schema carries updated_at on every syncable row and the dictionary is fetched by URL, so both land without rework.

Known limitation

lib/blocks.js parse() does not close a ::gloss block on its = line, so a multi-sentence gloss collapses into one run-on line. Since lib/ ships unchanged, app/src/domain/gloss.ts splits the block and parses each sentence separately. The behaviour is pinned in test/lib/blocks.test.ts; if the library is ever revised, the one-line fix is cur = null and both workarounds go.

Description
No description provided
Readme 4.2 MiB
Languages
TypeScript 77.4%
JavaScript 13.6%
CSS 8.1%
Python 0.4%
PLpgSQL 0.2%
Other 0.2%