Lettura: a queue the vault writes and the phone reads
The head of the queue is derived, never stored: the lowest-position item with no done event. That one choice is what makes the app unable to show tomorrow's chapter early.
Companion to the pitch. Same stack as two earlier projects here: an edge worker, a small SQL database, an object store. One worker, one phone target.
The flow
vocabulary app pull seen words writing session coverage check
(phone) ────▶ (script) ────▶ (by hand) ────▶ (script)
│
▼
phone app ◀── read the queue ── worker + database + audio store ◀── publish
│ │
└──── done / again / word ────▶ └──── pull events ──▶ back into
the vocabulary notes
Everything left of the worker exists today. The worker replaces two email senders and two state files. The phone replaces the inbox.
Data model
Two tables.
| Table | Columns | Notes |
|---|---|---|
items | id, position, kind (chapter or track), volume, chapter number, title, text, glossary, audio key, word list, created | position is the queue order. Immutable once published. |
events | id, item id, type (done, again, word, phrase, opened, played), value, timestamp | Append only. done is the only event that moves the queue. |
Derived, never stored: the head is the lowest-position item with no done event. That is the whole design. The queue holds one live item by construction rather than by discipline, and there is no cursor to get out of sync with anything.
The again event does nothing to the queue. It records that the head stayed a day, and it feeds the next volume’s difficulty choices.
Audio lives in the object store, uploaded by the publishing script, never generated in the cloud.
API
Five routes. Two callers.
| Route | Who | Does |
|---|---|---|
GET /queue | app | Today, everything done, and a count of what remains. Today is the head with a signed audio URL and the glossary filtered to its own words. Never returns an item after the head. Today is null when the queue drains, which the app renders as “ask for a new volume.” |
POST /events | app | One event. Idempotent on the item, type, value and day together, so a double tap is one event. |
GET /audio/:key | app | Streams from the object store with correct byte-range handling. The one piece of code to test on a real phone first. |
POST /items | publishing script | A whole volume, in order, audio uploaded in the same call. Refuses if a chapter of that volume already exists. |
GET /events | pull script | Everything the phone recorded since a high-water mark. The same call returns the remaining count, which is what triggers a refill. |
Auth is a single bearer secret held in the worker’s config and in the app bundle. Two callers, one secret, rotated by hand.
The two scripts
Publish replaces the evening email sender. It runs after the coverage check, the volume split and the audio render, in that order, and it refuses to publish a volume whose coverage falls outside 95 to 97 percent of words I already know, or whose audio is missing. Same rule as today: nothing generative sits between verification and delivery.
Pull replaces the reply scraper. It appends word events to the list of words I had to guess and phrase events to the phrase file, keeps a high-water mark, and commits. The morning job runs this and only this. Both email senders are disabled rather than deleted until the app has run for a month.
Phone side
One screen. On launch: read the queue, cache today’s audio, show title, play button, text, glossary. Audio session in playback mode and the now-playing info centre wired up, so the lock screen has play, pause and scrub. Buttons: Fatto, Ancora, and a text field that posts one word event per submit. Below today, the finished chapters as a plain list, newest first; tapping one opens the same view without the buttons.
Nothing ahead of today is ever rendered, because the API never sends it. A daily local notification at half six reads the cached title. No login, no settings. Sideloaded from Xcode.
The six screens this was designed against are published as a separate artifact.
Refill: when new chapters get written
When: the queue, not the calendar. Each morning the pull script reads the remaining count. At two or fewer it refreshes the seen word list, diffs it against the last pull so the growth is visible, and files exactly one item: volume N needed, seen set at so many word types, so many captured words waiting. That is the only nudge. Two chapters is two days of runway, which is enough for a session and a render.
How: the writing session, unchanged. Write against the seen list, salting in the words I had to guess first, then check coverage until it lands between 95 and 97 percent, split, render, publish. The new volume lands behind the head, so nothing about today changes and the app cannot show it early.
Why the writing is not on a timer. The seen set grows twenty to forty word types a week. A generator on a schedule either outruns that boundary or writes filler inside a set that has not moved, and choosing which one new word a chapter should teach needs judgment. Depth-triggered, written by hand, verified by machine.
Migration, day one
Publish the last unread chapter as item 1 and the six recurring listening tracks as items 2 through 7, in the order they were last heard. Write the next volume against a fresh word list and publish it behind them. Disable both email senders. Archive the fourteen emails.
Risks
Byte-range requests on iOS, mitigated by testing that first and on hardware. Audio cache eviction on the phone, where re-downloading is cheap. The bearer secret sitting in a sideloaded bundle, which is acceptable for one user whose data is Italian children’s stories. And the pull script silently dropping an event type it does not recognise, which is a failure this shop has already had once: it must print every unrecognised event rather than skip it.
Get updates
Occasional notes on what's happening at Enginery. No spam, no marketing.