A self-service registration app for Living 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 (libsql/web → Val Town SQLite, val-scoped) │
└──────────────────────────────────────────────────────────┘
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's SQLite is Turso/libSQL under the hood. We use the official pattern from Val Town docs: drizzle(sqlite as any) via drizzle-orm/libsql/web. The val-scoped import (std/sqlite/main.ts) isolates this project's DB from other vals on the same account. Raw sqlite.execute() is used for hot paths (availability polling) and atomic transactions (registration) where we need precise SQL control.
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.