A self-service registration app for Human Library events in Hyderabad. Readers register with name + email, browse available "books" (people sharing their stories), and claim reading slots — all in real-time as 250+ people sign up simultaneously.
┌─────────────────────────────────────────────────────────┐
│ Client (React + Pico CSS) │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Register │→ │ Browse Books │→ │ Library Card View │ │
│ │ (name + │ │ (polls every │ │ (reading history) │ │
│ │ email) │ │ 3 seconds) │ │ │ │
│ └──────────┘ └──────┬───────┘ └──────────────────┘ │
│ │ │
│ localStorage: { id, name, email } │
└───────────────────────┼─────────────────────────────────┘
│ HTTP
┌───────────────────────┼─────────────────────────────────┐
│ Server (Hono) │ │
│ ▼ │
│ /api/events/active GET → active event │
│ /api/events/:id/availability GET → sessions + slot counts│
│ /api/reader POST → find-or-create reader│
│ /api/register POST → atomic slot booking │
│ /api/register DELETE → cancel booking │
│ /api/readers/:id/card GET → library card data │
│ │
│ DB Layer: Drizzle ORM (sqlite-proxy → Val Town SQLite) │
└──────────────────────────────────────────────────────────┘
Val Town's serverless model keeps a Deno isolate alive per SSE connection. With 250 concurrent readers, that's 250 long-lived isolates — unsustainable. Instead, we poll every 3s. That's ~83 req/s of fast SQLite reads, which Val Town handles fine.
The critical path is two readers grabbing the last slot simultaneously. We solve this with a conditional INSERT:
INSERT INTO ll_registrations (reader_id, book_session_id, session_id)
SELECT ?, ?, ?
WHERE (SELECT COUNT(*) FROM ll_registrations WHERE book_session_id = ?) <
(SELECT max_slots FROM ll_book_sessions WHERE id = ?)
If the slot was taken between the reader's last poll and their click, 0 rows are affected → friendly error.
Val Town provides SQLite via https://esm.town/v/std/sqlite (a Turso HTTP wrapper). We bridge it with drizzle-orm/sqlite-proxy which lets us plug in custom execute/batch callbacks. This gives us type-safe queries while staying on Val Town's native SQLite.
Enforced at the DB level with UNIQUE(reader_id, session_id) on ll_registrations. The API deletes any existing registration for a session before inserting a new one (in a transaction), so changing your pick is seamless.
ll_events → An event (e.g. "Living Library Hyderabad #6")
ll_sessions → Time slots within an event (Session 1, 2, 3...)
ll_books → "Books" (people) available at an event
ll_book_sessions → Junction: which books are in which sessions (with max_slots)
ll_readers → Registered readers (name + email, unique on email)
ll_registrations → Reader ↔ BookSession assignments
├── CLAUDE.md ← You are here
├── AGENTS.md ← Val Town agent instructions
├── db/
│ ├── schema.ts ← Drizzle table definitions (source of truth)
│ ├── client.ts ← Drizzle instance via sqlite-proxy
│ └── queries.ts ← All query functions
├── api.ts ← Hono API routes
├── views.ts ← React SPA (HTML shell + embedded React)
├── index.ts ← HTTP entry point (Hono app)
└── seed.ts ← Script: populate sample data
Edit seed.ts with your event/book/session details, then run it as a script in Val Town. Re-running clears and re-inserts.
- Update
db/schema.ts(Drizzle table definitions) - Update
db/queries.tsif query logic changed - The schema is applied via
CREATE TABLE IF NOT EXISTSon cold start — for breaking changes, either drop tables manually or bump table names
- Add the query function in
db/queries.ts - Add the route in
api.ts - Update the React app in
views.tsif it needs to call the new endpoint
We trust readers to provide accurate name/email. No auth, no verification. The UNIQUE(email) constraint on readers means returning with the same email resumes their session.