- HTML 75%
- Go 22%
- Awk 1%
- SCSS 0.8%
- Shell 0.7%
- Other 0.4%
The three states have sizes: 656 books carry no statement at all about who they are for, and that number is flat across every age because it is a property of the record. What varies is the supply of evidence-admitted books - 665 at age five, 1,991 at grade four - which is why the rule is felt only at the young end. The cost is small and the benefit is not. Among books easier than 500L a five-year-old could reach, 8 have no evidence and 114 do, so fail-closed removes 8 books and closes the only path by which adult material reached a child: a difficulty measure saying 'easy enough' with nothing else in the record. Three shelf places on the captured kindergarten shelves are insufficient-evidence books. Two are adult and must go. The third is Sylvia & Aki, a 2011 children's novel that the rule would also remove - which is the argument for naming the state unknown rather than calling it unsuitable. 490 of the 656 sit on a named list, which is the leverage: a per-source audience claim converts much of the 656 without per-book research. |
||
|---|---|---|
| .agents/agents | ||
| cmd | ||
| db | ||
| design | ||
| internal/coverlib | ||
| nix | ||
| plan | ||
| scripts | ||
| templates | ||
| web | ||
| .gitignore | ||
| admin.go | ||
| admin_test.go | ||
| agedrift_test.go | ||
| AGENTS.md | ||
| AR-SEARCHING.md | ||
| auth.go | ||
| auth_test.go | ||
| best.go | ||
| best_test.go | ||
| bookedit.go | ||
| bookedit_test.go | ||
| browse.go | ||
| browse_range.go | ||
| browse_range_test.go | ||
| browse_test.go | ||
| chart.go | ||
| chart_test.go | ||
| compress.go | ||
| compress_test.go | ||
| config.go | ||
| config_test.go | ||
| constellation-textual | ||
| constellations.go | ||
| constellations_test.go | ||
| coverreview.go | ||
| coverreview_test.go | ||
| covers.go | ||
| covers_test.go | ||
| dataaudit.go | ||
| dataaudit_test.go | ||
| edge.go | ||
| edge_test.go | ||
| editions.go | ||
| editions_test.go | ||
| error_page.go | ||
| error_page_test.go | ||
| fetch.go | ||
| fetch_test.go | ||
| fetchcard.go | ||
| fetchgoogle.go | ||
| fetchgoogle_test.go | ||
| flake.lock | ||
| flake.nix | ||
| forget.go | ||
| forget_test.go | ||
| frontdoor_test.go | ||
| go.mod | ||
| go.sum | ||
| grades_test.go | ||
| handoff_test.go | ||
| header_test.go | ||
| hub.go | ||
| hub_live_test.go | ||
| hub_sheet_test.go | ||
| hub_test.go | ||
| interview.go | ||
| interview_test.go | ||
| lexicon.go | ||
| lexicon_test.go | ||
| lexiledate_test.go | ||
| likepage.go | ||
| mail.go | ||
| mail_test.go | ||
| main.go | ||
| maintenance.go | ||
| maintenance_test.go | ||
| Makefile | ||
| pages.go | ||
| paseo.json | ||
| plandoc.go | ||
| plandoc_test.go | ||
| pool.go | ||
| pool_test.go | ||
| profile.go | ||
| profile_shelves_test.go | ||
| profile_test.go | ||
| questions.go | ||
| questions_test.go | ||
| readers.go | ||
| readers_card_test.go | ||
| readers_test.go | ||
| README.md | ||
| readme_test.go | ||
| reason_test.go | ||
| recency_test.go | ||
| recommend.go | ||
| recommend_test.go | ||
| reference_room_test.go | ||
| reminders.go | ||
| reminders_test.go | ||
| review.go | ||
| scatter.go | ||
| scatter_test.go | ||
| SCENARIOS.md | ||
| scenarios_test.go | ||
| search.go | ||
| search_test.go | ||
| secondopinion.go | ||
| secondopinion_test.go | ||
| setaside_test.go | ||
| shelf_print_test.go | ||
| slippage.go | ||
| slippage_test.go | ||
| sources.go | ||
| sources_test.go | ||
| stats.go | ||
| stats_test.go | ||
| summary_test.go | ||
| suppression_test.go | ||
| TAG-RECIPE.md | ||
| trail_test.go | ||
| treasurypilot.go | ||
| treasurypilot_test.go | ||
| wayfinding.go | ||
| wayfinding_test.go | ||
| workroom.go | ||
| workroom_test.go | ||
McKelle's Books
A book-recommendation site for parents, librarians and teachers.
The reader interview is server-rendered, and both its answers and the catalog behind it live in Postgres: 3,579 books with their genres, tags, identifiers, reading measures, reading-research provenance and award/curriculum listings.
The interview's job is not to sort a reader into a type. It gathers the fewest pieces of high-value evidence it can — what a reader wants today, what they have loved and why, what did not work — and turns them into weighted signals a recommendation engine scores the catalog against. Every answer is kept as the reader gave it, alongside whatever was derived from it.
Layout
| Path | Purpose |
|---|---|
main.go |
HTTP server. Listens on $BOOKS_ADDR. |
config.go |
Environment parsing — BOOKS_ADDR, POSTGRES_DSN, and the SMTP settings. |
interview.go |
The question bank, branching rules, and signal derivation. |
recommend.go |
Scoring the catalog, choosing a shelf, and reaction handling. |
review.go |
The -review report, and the notes on a shelf. |
profile.go |
The reader profile page: what we believe, and how to correct it. |
auth.go |
Accounts, sessions, password hashing, and the password-reset flow. |
admin.go |
The admin page and who may reach it. |
bookedit.go |
The book editor: its form, its rules, and the revision it records. |
mail.go |
The SMTP relay, and the reset email it sends. |
stats.go |
The statistics page: its queries, and its chart layout. |
compress.go |
Gzip for text responses, the rules it keeps, and why. |
search.go |
Search, its suggestions, and the two detail pages. |
scatter.go |
The scatter plot: its axes, and the points that link to a book. |
browse.go |
The explorer: the shared filter, the year ladder, and the clickable charts. |
templates/partials.html |
Templates shared by more than one page — today, the scatter plot. |
pages.go |
The site map, routing, and the page handlers. |
templates/ |
html/template sources, embedded in the binary. |
web/scss/ |
Stylesheet sources, including the colour palette. |
web/assets/ |
Served assets, embedded in the binary (see Styles). |
web/design/ |
Design mockups, embedded and served at /design/. |
Makefile |
The css build step, plus build/run wrappers. |
db/ |
Pool, Ex, and the typed Execute* helpers. |
db/migrations/ |
goose SQL migrations, embedded in the binary. |
db/query/ |
Hand-written go-jet queries, one file per table. |
db/gen/ |
go-jet's generated table/model code — committed. |
db/jet/ |
The code generator. Dev tool, not shipped. |
cmd/import/ |
The catalog importer. Dev tool, not shipped. |
nix/package.nix |
buildGoModule derivation for the binary. |
nix/module.nix |
NixOS module: systemd unit + firewall opt. |
flake.nix |
Exposes packages.default and the module. |
Pages
pages.go holds the site map: an ordered list of pages that is at once the
router, the top navbar and the content of the generic page template. Adding a
page is one entry there, plus a handler if it needs one; an entry with no handler
of its own is rendered from the copy written beside it, which is where most of
the prose on this site lives.
The site is four doors, and the addresses are grouped that way below. These
tables are checked against the router by TestReadmeRoutesAreTheRouters: an
address the site does not answer, a page the table has not been told about, or a
retired address presented as current each fails the build. So a new page means
writing it down here as well as registering it in pages.go.
The four doors
| Route | Method | Behaviour |
|---|---|---|
/ |
GET |
Home: the four doors, and nothing else. |
/reading |
GET |
The Reading: the next interview question, or the shelf. Requires an account. |
/reading |
POST |
Stores one answer, then redirects. Requires an account. |
/reading/reaction |
POST |
Records a reaction to a recommended book. |
/reading/retry |
POST |
Answers the offer on a book that was set aside: try it again, or not yet. |
/reading/review |
POST |
Records what a reader thought of a recommended book, or of the shelf as a whole. |
/reading/readers |
GET |
The readers this account holds. Requires an account. |
/reading/readers/switch |
POST |
Acts as one of those readers from this browser. |
/reading/readers/new |
POST |
Opens a blank reader and switches to it. |
/reading/readers/undo |
POST |
Takes a reader back off the shelf it was put on. |
/reading/reader |
GET |
The reader profile: what the system believes about the reader, and where each belief came from. |
/reading/reader |
POST |
Saves the reader's age, grade, reading level or name. |
/reading/reader/signal |
POST |
Records that an inferred belief is wrong. |
/reading/reader/read-on |
POST |
Records (or clears) the date a book was read. |
/reading/reader/forget |
GET |
The screen before a reader's profile is emptied. |
/reading/reader/forget |
POST |
Empties it. |
/reading/reader/delete |
GET |
The screen before a reader is deleted. |
/reading/reader/delete |
POST |
Deletes the reader and everything attached to them. |
/reading/reader/handoff |
GET |
Hand a reader on: make a login now, or take a claim code. |
/reading/reader/handoff/code |
POST |
Mints the claim code, or returns the live one rather than a second. |
/reading/reader/handoff/put-down |
POST |
Puts the reader down on a shared computer, keeping the way back for a day. |
/reading/reader/slip/{code} |
GET |
The slip on its own, to be printed and cut out. |
/reading/reminders |
GET |
The Reminders: the books this reader asked the site to keep. Not a shelf — a list the reader made, which no interview and no deleted session can empty. Requires an account. |
/reading/reminders/remove |
POST |
Takes one book off that list. Requires an account. |
/reading/remember |
POST |
Puts a book on it: the control that stands on a book's page, a shelf card, the Treasury and The Catalogue. Requires an account — a signed-out press is sent to the account form, which carries the book through and puts it on the list once the account exists. |
/reading/survey |
GET |
Placeholder for the expanded interview (T86): the page exists, the interview is not written. The word "survey" is retired — see plan/decisions.md, 2026-10-26. |
/constellations |
GET |
Start from a book they already loved: the books that sit near it, each line with its reason on it. |
/wayfinding |
GET |
Roads: the book they are on now, and the rungs from there to the books worth reaching. Opens at a book when a list sends one (?book={id}). |
/discovery |
GET |
Discover: the rooms behind the fourth door. Four cards, three of them open; Serendipity is still a promise. |
/discovery/browse |
GET |
The Catalogue: the statistics as controls, with the matching books listed underneath. Not a room — it is reached from Miscellany, one level down. |
/discovery/best |
GET |
Treasury: the room — three equal doors, Age, Genre and Merit, in a lamplit field, and nothing else. |
/discovery/best/age |
GET |
Treasury · Age: the lists answered at the age of the reader, and the grade-level collections as they are built. |
/discovery/best/genre |
GET |
Treasury · Genre: the shelves of anchors, best in class kind by kind. |
/discovery/best/merit |
GET |
Treasury · Merit: the fifteen source lists, where they overlap, and Don't miss these. |
/discovery/statistics |
GET |
Curiosity (titled Statistics): what the numbers say — counts of the catalog by genre and by decade. |
/discovery/miscellany |
GET |
Miscellany: the reference room, and the way in to four pages under it — The Catalogue, From the Reference Desk, About the Library and Keep the Lights On (McKelle, 2026-10-31). |
/discovery/reference |
GET |
From the Reference Desk: where the descriptions and measures come from, the eight families of list the catalogue is built from, and the other places worth looking. |
/about |
GET |
About the Library: how Reader’s Atlas works, how it came to be, and what it is trying to do. The footer’s About lands here; /about/history, /about/goals and the rest of the /about/ family forward here. |
/support |
GET |
Keep the Lights On: a thank you for visiting, the library saying it is glad to accept gifts, and the ways to help that cost nothing. It prints no Donate button and no address, because the way a gift arrives is not settled (T127); the acknowledgments (T117) are still owed. |
/how-to |
GET |
How To: start the interview, and who gets the most out of it. |
The top bar carries the four doors and nothing else; the footer carries the pages about the site itself. A footer link is labelled with the subject it offers rather than always with the page's own name: the reference room is filed there as About, and lands on the part of that page that explains the site.
Addresses that are not menu entries
Search, the detail pages, the claim page and the admin tools are addresses rather than destinations — whether they are reachable depends on what is being looked at or who is logged in, which a static menu entry cannot express. The ones a reader can reach are here; the admin ones are below.
| Route | Method | Behaviour |
|---|---|---|
/search |
GET |
Everything matching a query, by title or author. |
/search/suggest |
GET |
The same matches as JSON, for the type-ahead. |
/request |
POST |
A reader asks for a book the catalog does not hold; the row it writes is the record a curator later works from. |
/books/{slug} |
GET |
One book. |
/books/{slug}/like |
GET |
That book's nearest neighbours, each with its reason — what the Constellations door opens. |
/books/{slug}/chart |
GET |
The charted plate around a book: one fixed layout of one declared region, with asserted relationships and their evidence. Only four books are in a region so far; every other book redirects to /like. |
/authors/{id} |
GET |
One author and their books. |
/claim |
GET |
The claim form a slip leads to. |
/claim |
POST |
Redeems a code and adopts the reader. |
/assets/ |
GET |
Embedded stylesheet and JavaScript. |
/favicon.ico |
GET |
The site's icon, at the address a browser asks for unprompted. |
/covers/ |
GET |
A book's picture, kept on this machine (see Book pictures). |
/design/ |
GET |
The design mockups (see Design mockups). |
Accounts have a table of their own, below in Accounts.
Admin
Every address here is administrators only, and the gate is decided per request rather than per session.
| Route | Method | Behaviour |
|---|---|---|
/admin |
GET |
What tools exist, and what is still to come. |
/admin/covers |
GET |
The cover adjudication run: the next book whose picture the resolver found candidates for but would not choose, with its candidates at a size a person can judge. |
/admin/covers |
POST |
Records one verdict — a picture, or no picture — in plan/cover-verdicts/adjudicated.tsv, then moves to the next book. |
/admin/books/{slug} |
GET |
The book editor, filled with what is stored. |
/admin/books/{slug} |
POST |
Saves an edit and records the state it replaced. |
/admin/books/{slug}/lookup |
POST |
Asks the sources about a book already in the catalog. What comes back is proposed, never written. |
/admin/books/{slug}/tags/{action} |
POST |
Adds or removes one tag. It writes immediately, because a tag is a statement about the book rather than a field of it. |
/admin/books/{slug}/remove |
GET |
The screen before a title comes off the shelf. |
/admin/books/{slug}/remove |
POST |
Takes it off, once the confirmation word has been typed. |
/admin/books/{slug}/restore |
POST |
Puts it back. |
/admin/readers/new |
POST |
Makes a reader and its slip together — ownerless until somebody claims it. |
/admin/readers/{id}/slip |
POST |
A slip for any reader, new or already working. |
/admin/readers/{id}/attach |
POST |
Hands a reader that answers to no account to the signed-in administrator. |
/admin/readers/{id}/test |
POST |
Marks a reader as one made for testing. |
/admin/workroom |
GET |
The workroom: what has been asked for, and the moves that take a book from the ask to the shelf. |
/admin/workroom/ask |
POST |
Records an ask. |
/admin/workroom/{id}/stage |
POST |
One move, offered only when it is the next one from where the book stands. |
/admin/workroom/{id}/hold |
POST |
Sets a book aside. |
/admin/workroom/{id}/publish |
POST |
Puts the book on the shelf. It refuses while the book still lacks a genre or a description. |
/admin/workroom/bulk |
POST |
The same move for several books at once. |
/admin/workroom/question/{id}/answer |
POST |
Answers a question about a book already on the shelf. The answer is recorded; the change to the catalogue is made afterwards. |
/admin/tasks |
GET |
The open-work list, drawn as a page. |
/admin/lexicon |
GET |
The site's vocabulary and its page map — the Lexicon and the Floor Plan, drawn from plan/lexicon.md. |
/admin/plan/ |
GET |
Any document in plan/, drawn as a page; /admin/plan/ itself is the plan's index, from plan/README.md. |
/admin/maintenance |
GET |
The yearly job, and what the catalogue says about it. |
/admin/exports/ |
GET |
The working spreadsheets, as files to download. |
/admin/sources |
GET |
The places the metadata lookup asks, and the keys it uses (masked). |
/admin/sources |
POST |
Saves them. |
/admin/editions |
GET |
The records built from an edition in another language, a door onto the book's own screen. |
/admin/second-opinion |
GET |
An outside source's reading measure set beside ours, sorted by how far apart they are. It changes no book. |
/admin/treasury-pilot |
GET |
The Treasury pilot: the Fourth Grade shelf with a reason for every book, the eight Fantasy anchors, and every book two or more institutions recognise. Reads the curator's committed list; stores and changes nothing. |
Retired addresses
Pages that used to stand on their own and are now passages of another one keep
their old addresses working, as a redirect rather than a 404: they were in the
menu, so they are bookmarked, linked and written on paper. Those redirects live
in registerRoutes in pages.go, and they are not listed here, because a table
of addresses that are not pages is a second thing to keep true. The one that is
on paper — a slip printed before the four doors were named carries
/discover/reader/slip/{code} — has to keep working, so it does.
Each interview step is its own screen and its own form-encoded POST, which redirects on success so a reload cannot resubmit an answer. A step that fails validation re-renders with the reader's choices still in place. Bootstrap's own bundle is loaded for the navbar's dropdowns and mobile toggle.
Scripts
The site's own script is web/assets/js/search.js, and it is an enhancement
rather than a dependency: with it, the navbar's box suggests matches as you
type; without it, the same box is an ordinary GET to /search. Nothing else on
the site needs scripts.
That is a standing decision, not an accident of what happens to have been built so far. The readers are mostly parents on phones and teachers on school and library machines — the population least able to absorb a script that fails to load, or a browser that changes underneath it. Three consequences follow, and they are what to check a new feature against:
- The server decides. Filtering, counting and scoring happen in Go. What is
selected is expressed in the URL (
?genre=Fiction&year=1900c&x=lexile&y=year), which is why every view is a shareable address and why a page is complete the moment its HTML arrives. - A script is an improvement on something that already works, both of the two above being exactly that. What a script must never be is the only way to do something: with scripts disabled the reader should lose polish, not function.
- So some things are not built, deliberately. The explorer's scatter plot
has no instant styled hover panel — it uses an
<a>and a<title>, which the browser draws itself — and there is no rubber-band drag to select points. Both were considered and declined on 2026-09-30, for the readers above. Range selection is still planned; it will be written as the same URL a script would have written, so it survives the script not arriving.
The reader interview
/reading is behind an account — a logged-out visitor is sent to the
login form and returned afterwards (see Accounts).
It is adaptive: which questions appear depends on what has already been answered and on what the reader's profile already holds. A new reader sees about five to nine screens; a returning one sees as few as the current request and an avoidance question, because their favourites, dislikes and reading level are already recorded. The interview stops as soon as it has enough to produce a shelf.
interview.go holds the question bank. Each question declares its fields and an
Applies predicate, and the interview is simply the first unasked question whose
predicate holds. The rules that matter:
- Ability already known (a declared Lexile, or two named books the catalog can measure) → the reading-level question is not asked.
- One favourite → ask for a second, unless the reader already named two preferences. One book can mislead a similarity judgement.
- A broad genre on its own ("fantasy") → ask what kind. Not asked when favourite-book evidence already answers it.
- Three or four favourites whose reasons agree → stop asking generic preference questions.
- A disliked book without a reason → ask why. The title alone is weak evidence.
- A reader who already has three books on file → ask "Read anything good lately?", one box, instead of the favourites question. A returning reader is otherwise never asked for a favourite again.
Titles the catalog does not hold
Most of what a reader types in a favourites box is not in the catalog: a series name, a book from outside a children's collection, or a title with a book's words in a different order. Three questions exist for that, and all three keep the interview's promise that nothing is invented on the reader's behalf:
- "Is this the book you meant?" — a typed title is only used as an answer
when the whole title agrees, articles and punctuation aside (
ResolveTitle,sameTitle). A near miss — a dropped subtitle, an added word — is shown back as buttons, with the typed title carried in the answer so a choice belongs to the title it was about. "None of these" falls through to the question below. This replaced a containment match that turned a fantasy reader's The Drowned City into Drowned City: Hurricane Katrina and New Orleans. - "What was the book like?" — what the book was about, what the setting was
like, and what happened. Chips drawn from the subject tags, plus a box for the
reader's own words. Answers become
topicsignals with sourceunknown_favorite, marked with the titles they are about, and a note row keeps the reader's description itself. At most two titles are asked about. - The request list — a title we could not place is recorded in
book_requestwith originsuggestionand the reader's own description as its note, so the collection backlog carries why somebody wanted the book. The workroom page words a suggestion differently from a curation request.
Answers are kept twice: interview_response.answer holds the structured form,
and raw_response holds what the reader actually wrote. The taxonomy in
interview.go will change; the sentences will not.
From answers to signals
An answer becomes weighted rows in reader_signal, each keeping its source and
scope. Evidence accumulates across interviews, but restating the same fact
updates it rather than adding a second copy:
| Family | Matches against | Comes from |
|---|---|---|
topic |
tags, then the description | request, interests, why-liked, a favourite's own tags |
tag |
tags only | a stated dislike (too scary, too sad) |
genre |
the book's genre | a favourite's genre, a reaction |
length |
pages or word count | how much book they want right now |
effort |
Lexile relative to the reader | "something challenging", "too hard" |
density |
words per page, Lexile relative | "the writing was too dense" |
exclusion |
tags only | an explicit avoidance |
note |
nothing | a reason with no book-level meaning |
shelf |
nothing | "surprise me", an instruction about the shelf |
Three decisions are load-bearing:
- Negative evidence stays narrow. "Lord of the Rings was too long and dense"
becomes negative
lengthanddensitysignals — never a negative on fantasy, on Tolkien, or on quests. Anotesignal records "boring" without ranking anything on it, because boredom names nothing a book can be matched against. - Persistent taste is separate from today's appetite.
scopedistinguishes them, and a shelf reads every durable signal plus only this interview's current ones. A request for something short for the car does not permanently lower a reader's length preference. - A repeated fact is one fact. A durable signal is keyed by reader, family and name, and a current one by interview as well; one index serves both. A reader who mentions fantasy in three interviews ranks fantasy once, not three times — before this, repetition was indistinguishable from intensity. The strongest statement of a fact wins, measured by absolute weight so a firm "too long" is not displaced by a mild one, and the newest row breaks a tie.
A favourite is matched against the catalog by its significant words, with
articles and punctuation set aside, so that The Hobbit, Hobbit and
a hobbit all reach the same book however the reader phrases it. The catalog
stores titles in published order — migration 00019 rewrote the 832 that had
arrived inverted, as Hobbit, The — but a reader is not held to that. A title
the catalog does not hold is recorded as such, with no book attached, rather
than forced onto the closest match.
Where a list is alphabetical it is ordered by that published title, article and
all: The Hobbit files under T, between The Holiness of God and The Horse Catcher, not under H beside Hobbit. This is the plan we are working to, not
an accident of rewriting the titles — a list that skipped a leading The, A
or An would have to say so deliberately, and nothing asks for one today. If a
process later turns out to depend on the old shelf order, that is the moment to
revisit the decision, in the open, rather than to bend one query quietly.
What the shelf says it heard
The shelf opens with a short panel — What we heard — that echoes the subjects
the reader gave, and names any the catalog could not answer. It exists because a
typed interest is matched as itself: the buttons on the interests question are
keys of signalTokens and are certain to find books, while a word typed into the
box beside them is not, and if nothing answers it the word contributes nothing to
the shelf. Before the panel, "we used it", "we misheard it" and "we dropped it"
all looked the same from the outside, which is a silence the show-back breaks. A
button appearing in the unanswerable list is a bug the page reports rather than
hides, since a button is a promise.
Two rules keep it honest:
- Only the reader's own words. The panel draws from the
request,kind_ofandinterestsquestions. A favourite's tags are excluded on purpose: those are the engine's reading of a book, not something the parent said, and echoing them back would be putting words in their mouth. The questions that describe a book we do not hold are excluded for the same reason — those words belong to a title, not to a taste. - Unheard means the catalog cannot answer it, not that the shelf lacks it.
Every book about a subject may be one this reader has already read, so the
question is asked of the whole catalog, in features built before the reader's
own screens are applied, and answered with
matchTopic— the matcher's own rule, so the page cannot explain a result the engine did not produce. A tag is decisive; a mention in a description counts, because the engine weighs it at 0.4 rather than zero, and calling that unheard would be a lie. SeeheardTopics,catalogAnswersandranking.catalog.
The panel is drawn whenever anything was heard, so its absence is never the only sign that the interview listened; the sentence naming what could not be matched appears only when something missed.
A word it could not answer is kept. When the interview is finalized, every
word the reader gave and the catalog could not answer is written to
book_request — the same list an unheld title goes to — one row each, with a
note saying which question it came from. It is built from the same two functions
the page is, heardTopicsFrom over the signals this interview wrote answered
against the whole catalog, so the words kept are exactly the words shown. Only
the unanswered ones are kept: "turtles" is a word the shelf can already act on,
and a row for it would ask for books to be found that are already here. A word
typed into two boxes is one row, because it is one subject. Two different
readers asking for the same word stay two rows — that count is the signal. See
recordUnheardSubjects.
What the row is not. It is not a promise that the catalog will hold a book about the word, and it is not the ledger of untagged subjects. It is a reader's ask, kept where asks live: the word sits on the workroom's "Asked for" list beside the titles, and its note — "asked for as a subject, not a title" — is what tells the two apart.
Filters and ranking
Two things can happen to a book: it is ruled out, or it is ranked.
Only three things rule a book out:
-
An explicit avoidance. Matched against tags only. A history of the Second World War is not removed by a reader who asked to avoid intense violence, because the exclusion never reads descriptions.
-
A developmental mismatch. Two statements in the catalog say who a book was written for, and either rules it out when it places the book far outside the reader: the AR Interest Level (LG, MG, MG+, UG), and the ages the catalog associates with the book — the suggested reading range, or the Ambleside year a book is read in where there is no range. A fifth grader is not offered lower-grade books, and a first grader is not offered a book for four-year-olds or for teenagers. Both are compared with the reader as they are today.
The one thing reading difficulty may do is lift the floor of an AR Interest band, and only its floor. "MG" spans grades four to eight, and a six-year-old who has finished Charlotte's Web is in that band already, so a band's floor yields to what the reader has actually read. What that means is narrow: every measure the catalog states for the book and the reader must agree — its Lexile within 150 points of the reader's own level, its ATOS within a grade — and a book with neither measure is not within reach at all, because reach is shown rather than assumed. A band that begins in high school is never opened this way, however easy the book: Night and Beloved are someone else's reader. The age range and the Ambleside year are left alone, because they are statements about one particular book rather than a five-grade span. A book the catalog says nothing about is kept — unknown is not a disqualification.
-
Already spent. A book the reader has finished, quit, dismissed from a shelf, or already listed as a favourite is not suggested again.
Everything else only raises or lowers a book. Ranking sums the signals that match, adds a nudge for curated award and recommended-reading tags, and adds an accessibility term — but a book with no Lexile is never penalised for it, and a reader with no known level is never penalised either. Missing data stays missing.
The collection lists are weighed too, not only drawn on /discovery/best.
A list mark is evidence of a different kind from a tag: it is somebody's
judgement that this book was worth a reader's time, made once and left standing.
The weight is measured rather than declared — how selective the list is, against
the catalog's own size — so a list naming 1,400 books is worth almost nothing per
book and a list of forty is worth a great deal, and a list that grows quietly
loses its weight as it does. Several lists are worth more than one but with
diminishing returns, capped so that no heap of lists can take a shelf over. The
recommendation names the count it stands on ("Named on 3 of our book lists")
only from two lists up, because one list is far too common to be worth saying.
Four of the fifteen lists name books for grown-ups — Harvard Classics, St John's
College, Time Magazine 1923-2005, and TJEd Adult Recommended. They are weighed
only for a reader of adultListAge (15) or more. Below that they are not
weighed at all, which is the age and not the reading level: a nine-year-old
reading at adult level is still nine. The case that set the line was Time's
hundred putting Blood Meridian onto a nine-year-old's shelf of children's
historical fiction. A reader whose profile states neither age nor grade is
treated as a child reader, because readerAge reports unknown rather than
guessing, and a missing birthday is not evidence of being grown up.
None of this changes what the catalog holds. Every membership stays on the book's page and in Browse; this decides only what a mark is worth when a shelf is chosen. The site's audience is children and the adults choosing for them — the home page says parents, librarians and teachers — so the catalog is a children's catalog and an adult shelf drawn from it will lean young. If adults turn out to be a real audience rather than an occasional one, the answer is more titles for them, which is a catalog decision, not a scoring one. Most adults have other ways of finding books.
The shelf is not a top-N. At most two books share a genre while anything else is
available, and one wildcard is added from further down the ranking, chosen
deterministically from the interview's id so reloading does not reshuffle it. A
reader who asked to be surprised gets a stricter genre cap and a second wildcard,
because that is what the request asks for; the request ranks nothing itself. Per
book, the page says why in the reader's own evidence, and offers four reactions.
Each card also carries the book's reading measures where the catalog has them —
the Lexile and, beside it, the ATOS, with the AR interest level underneath — so
"is this at the right level?" can be answered at a glance rather than only
inferred from the explanation. The three are independent and the card shows
whichever the sheet carried, either alone if that is all there is; a book with
none of them says nothing rather than showing a dash. The ATOS is the same number
the book page calls the Accelerated Reader level, under the name the reading
programmes use for it, and the interest level is written as the code and the
grades it covers — "LG (K–3)" — because "LG" alone means nothing to a parent.
The same block is drawn by the search results and an author's bibliography, which
are the other two places a person chooses between titles; one partial draws it
in all three, from public.Levels.
A reaction feeds back: "interested" contributes weak preference signals from the
book's genre and tags, "already read it" is capability evidence, and "not for me"
is recorded without being generalised into a dislike of the genre. "Not for me"
and "already read it" both spend the book, so the card just answered leaves the
shelf; the page returns the reader to the card that has taken its place, rather
than to the top, because with eight cards of a dozen controls each the card they
answered is somewhere below the fold and hunting for it reads as "did that even
save?". Each card posts its own position back for this, so a shelf that has
shrunk past it lands on the last card and an empty shelf lands on the top.
Under each book, and once for the shelf as a whole, is a collapsed "I wish I knew…" box. What it records is not which cell is empty but what information was missing — the question that turns a failed recommendation into a task. Notes are append-only and keep the book they concern, if they concern one.
Reviewing a reader
-review turns the binary into a diagnostic instead of a server. It prints the
evidence a reader has accumulated, what the engine removed and why, every book it
ranked with the contributions that scored it, and the shelf's notes. The header
states the reader's age and grade, the level the ranking used, and the reading
reach the developmental screen weighed a band's floor against — the last two are
what a shelf that looks wrong is usually wrong about:
$ POSTGRES_DSN='postgresql:///books?host=/run/postgresql' ./books -review <reader-uuid>
It takes a comma-separated list, so several readers can be compared in one run,
and -session <uuid> pins a particular interview instead of the reader's latest
finished one. It lives in this package rather than in its own command because it
reports on the engine in this package, and an explanation that could drift from
the ranking it explains would be worse than none.
The reader profile
/reading/reader shows what the system believes about the reader and where each
belief came from: the basics it was told, the signals it worked out, and the
books it has been told about. It exists because the reader model is built by
inference — a favourite's tags become interests, a complaint about length becomes
a length preference — and an inference that is wrong is worse than a missing one,
because it silently steers every shelf that follows.
Every inferred belief has a "Not true" button. That records a suppression rather than only deleting the row: the next interview would otherwise derive the same belief from the same evidence and the correction would quietly revert. A suppression is keyed by the belief, not the row, and the profile lists what has been suppressed so a quiet absence is never a mystery. Suppressed beliefs are permanent; there is no undo yet. A blank field in the basics form means "leave what we have alone", not "forget this".
The page also lists the reader's shelves by date — every interview they have
finished, newest first, each one opening that shelf again, plus the newest
interview they did not finish, offered as somewhere to carry on. It exists because
a shelf has no address of its own: it is served at the interview's address with
the session hung off it (?s=), every such link is built inside a redirect during
the sitting itself, and arriving without one opens a new interview rather than
the last shelf. A reader who closed the tab therefore had nothing left to come
back to. An interview's own answers are kept as session-scoped signals
(SignalsForReader), so reopening one draws from that interview's evidence rather
than from whatever the reader believes today. Readers with no account are listed
too: the cookie is their handle, and the links carry the guest flag the
interview's start screen offers.
What V1 deliberately does not do
- No interpretation of free text. "The dragon was cool" is preserved and keyword-matched; it is not parsed. That is why the raw sentences are kept.
- No complete taxonomy.
signalTokenscovers what the question bank asks about. Which books deserve richer annotation is what the gold-set testing is for; the catalog's tags are user-facing labels, not a recommendation schema. - No second difficulty measure for ranking. Lexile is the measure the ranking reads, and an approximate Lexile↔ATOS conversion would be a guess presented as data. ATOS is read too, but only by the developmental screen and only to compare one book against one reader — never to rank two books against each other.
- No age-based filtering without a reason. A stated age or grade, or one implied by the other, is screened only against what the catalog says about who a book was written for: its AR Interest Level, its suggested reading range, or the Ambleside year it is read in. Age on its own removes nothing, and what the reader has already read can open a band's floor but never close one.
Curiosity
The room is named Curiosity. The page said "Statistics" until 2026-10-26; see
plan/lexicon.md.
/discovery/statistics answers three questions about the catalog. Every figure
is inline SVG laid out in stats.go and scatter.go, so there is no chart
library and no JavaScript: the page works with scripts disabled. Each figure
carries an aria-label describing what it shows, because an SVG is announced as
a single image.
The first two count the catalog by genre, and by decade of publication.
Two figures are deliberately excluded from the charts and reported in their
captions instead: the handful of titles with no genre, and the 462 published
before 1900. Decade-resolution bars across the catalog's full span — the oldest
title is from 800 BC — would flatten every bar before the twentieth century to a
pixel. Widen histogramFrom in stats.go to cover more of it.
The third figure is a scatter plot of any two measures, chosen from the two
menus beside it: word count, pages, Lexile, the AR book level, Guided Reading
Level, the two suggested ages, the two Ambleside years, and year published.
scatter.go holds the menu and the layout. The choice travels in the URL as
?x=pages&y=words, so a plot can be linked to, and the page renders it without
scripts.
Every dot is also a door. Each one is wrapped in a link with a plain <title>,
so the browser names the book on hover and the click opens that book's map of
similar books at /books/{slug}/like, with the book in the middle. Nothing is
asked of the browser beyond an <a> and a <title>, and the dots are kept out
of the tab order deliberately: two thousand points would be two thousand tab
stops, and the plot is announced as one image. The AR level is offered as an
axis at the operator's request; it is a measure a reader may look at, and the
site still does not screen or rank books by it.
Four things about that plot are worth knowing before changing it:
- A missing measure is not a zero. A book with no Lexile has no position on a Lexile axis, so it is left off rather than piled onto the axis; the caption says how many books carry both measures.
- Each axis is drawn over the 1st to 99th percentile of the values on it. An eleven-volume set at 14,777 pages and an 800 BC book are real data points, but an axis spanning the whole of either range compresses the other two thousand books into a corner. The books outside the frame are counted in the caption, not silently dropped.
- Guided Reading Levels are letters, A to Z, stored as the ordinal that
sorts correctly and written back out as the letter it means, in
axisKind.text. Every other measure is already a number of the same kind, so it needs no such translation. - A reading measure is never written in thousands. A Lexile is a point on a
scale rather than a count, so its gridlines read
1000and1200, not1,000and1,200—axisLexile— while word counts and page counts keep their separators. An AR level keeps its decimal, because the decimal is the measure:axisLevelwrites4.0, and4would be a different book.
Miscellany
The reference room. How the site came to be, what it is trying to do, where
its data comes from, the eight families of list the catalogue is built from, and
five other places worth looking. It is where the catalogue is browsed from:
a card near the top hands the reader down to The Catalogue, which has no door of
its own. The room's material has outgrown a single scroll and will be divided
into sections before it is pleasant to read (T124).
It was the page called About until 2026-10-29, when the reference desk was
given the name Miscellany and a room behind the Discover door. /about and
everything under it now forwards to /discovery/miscellany, so old links and
bookmarks still land. The room's own title says Miscellany, and the footer's
link says About and lands further down, on How McKelle's Books came to be
(#history): a footer link offers a subject, and the subject is the site, not the
catalogue card or the eight families of list that share the page. The word and the
anchor are declared on the page's entry in the site map (Foot, FootAt). What
the room does not yet hold — support and contact, credits, the FAQ, and one page
saying how recommendations work — is T116–T119 in plan/tasks.md.
The move reversed the ruling of 2026-10-26 that Miscellany meant the catalogue
browser and "has no opinion"; the browser took the name The Catalogue and kept
its address. Making that browser a fifth room was the first shape tried the same
day and was rejected — Discover keeps four, and the browser sits under Miscellany
instead. plan/lexicon.md, under Miscellany and The Catalogue, records both.
The Catalogue
The page is named The Catalogue. It was called Miscellany from 2026-10-26 to
2026-10-29, and the page said "Browse the Shelves" before that; see
plan/lexicon.md.
It is not a room: Discover draws four cards, and this page is reached from
Miscellany, declared there as a child (Children in pages.go) and drawn one row
below it in the navigation map. Keeping it out of the directory was McKelle's
ruling of 2026-10-29 — the reference room carries the way down, and the browser
stays out of the menu.
/discovery/browse is the explorer: the statistics page's figures turned into
controls, with the books they select listed underneath. It was built to answer
the question the statistics page leaves open — then show me the books — and it
takes its behaviour from the Tableau dashboard the operator built this site
partly in response to.
Two rules decide how it behaves, and both are in browse.go:
- Every panel shows the same filtered set. Pick a genre and the year bars, the scatter plot and the list all narrow together, because all of them are drawn from the same rows. There is one filter on the page, whatever number of views there are of it.
- A chart ignores the filter it is the control for. The genre bars count the books surviving the year filter, and vice versa, so that a bar always answers "how many books would I have if I picked this?". Without this, choosing Fiction would leave Fiction as the only genre bar standing and nothing to choose next. A bar at zero keeps its place, so the chart does not rearrange itself under the reader's hand.
The filter lives only in the URL (?genre=Fiction&year=1900c&x=lexile&y=year),
which is what makes every view a shareable address and what lets the page work
with scripts disabled. browseFor is the whole of the behaviour and is pure —
no database — so the filtering and counting are tested without a catalogue.
Every control hands the reader back to itself. The axis menu is an ordinary
form, so pressing Plot is a page load; without a fragment on the action the
browser lands at the top of the new page, and a phone then shows a summary strip
and two charts between the reader and what they asked for. So the form posts to
#plot, and each chart's bars carry #by-genre or #by-year, landing on the
figure they came from. Those anchors sit on the whole figure — heading, caption
and controls as well as the drawing — rather than on the chart itself, which was
tried first and left the reader at the dots with the heading scrolled off above.
scroll-margin-top in main.scss keeps the heading off the very edge of the
window. The chips and Clear all at the top are deliberately left unanchored —
that is where the reader already is.
TestTheExplorerHandsTheReaderBackToWhatWasClicked pins all of it: that the
anchors exist, that every bar carries one, and that #plot stands above the
heading rather than below it.
Four things are worth knowing before changing it:
- One query, narrowed in Go.
public.BrowseBooksreturns the whole shelf once, with each book's genre, year and measures; the filtering and every count happen over those rows. That is what keeps a page with four filtered panels down to one round trip, and it is what makes the list and the bars incapable of disagreeing. - The year chart covers the whole timeline, unlike the statistics page's
histogram, which starts at 1900 and reports what it left out. Because the
explorer has to be able to reach the catalogue's oldest books, its buckets
widen as they go back — centuries before 1500, quarter-centuries to 1900,
decades after — rather than stopping. The ladder is
browseYearBuckets. - Titles are capped, never truncated silently.
browseListLimitbounds the list;browseData.Hiddencarries how many matched and were not drawn, and the page says the number. - The list is a way in, not a dead end. Each row offers the three ways
onward that the plot's dots already do: the title opens the book's own page,
more like this rings it with the books that sit next to it
(
/books/{slug}/like), and roads from here starts a wayfinding road from it (/wayfinding?book={id}). The row therefore carries the book'sidas well as its slug — the road page names a book by number, the address a reader sees uses the slug.TestEveryListedTitleCanBeLeftfails if a row loses any of the three. - The books with no year stay reachable. Four books carry no publication
year. They have a bar of their own (
undatedBucketKey), a count in the summary strip, and a link that filters to them from outside the chart — because a reader who plots a year axis would otherwise watch them disappear with nothing on the page to explain it, and the caption says how many in view are missing one.
What is not built yet: selecting a range on the scatter plot (?min=, ?max=),
dragging a box on it, and taking a title from the list straight to its own
constellation or to a wayfinding road. The plot's own dots already lead
somewhere: a dot opens the book's map of similar books.
Treasury
The room is named Treasury. The page said "Best books ever" until 2026-10-26;
see plan/lexicon.md. The phrase is not abolished — it may return as a tagline,
but that is a judgement about the collection and has not been made.
/discovery/best is the catalog's curation lists, opened at the age of the reader
being shopped for. It is the one door of the four in the current design that is
already a query: the lists exist, and only a page was missing.
A curation list is a row of collection — a spreadsheet column someone
filled in, promoted in 00016_book_detail_fields.sql. The catalog holds fifteen,
holding 2,837 books between them of which 330 stand on two or more. They are
different kinds of thing: Newbery and the ALSC Notables are awards and librarian
selections, Harvard Classics and St. John's College are canons of adult books,
and Mensa Kids, Beautiful Feet, Deep Roots and the TJEd lists are curricula.
They disagree with each other, which is why the page does not merge them into
one ranking and call the result "the best": each list is drawn as itself, and
every book names the other lists it stands on. Where several lists agree is the
closest thing to a verdict the catalog holds.
The age filter is the page's other half, and it reads
book_reading_level.age_from/age_to — the retailer's grade range converted to
ages at ingestion (see Collections). The ages on offer are the
ones the books actually span, so the filter cannot drift from the data. A book
whose range has one end known covers everything past it, because that is what the
source said. A book with no age recorded is not shown at any age: choosing an
age is what leaves it out, and the page says how many books that is — 879 of the
2,837 — rather than treating an unknown age as a licence to be shown to everyone.
With no age chosen, every book is on the page.
bestLists in best.go names which of the fifteen lists the page draws from,
and that is an editorial decision rather than a technical one: the collection
table says what a list is, and bestLists says whether we trust it. Every list
is named today so that nothing is left off by default while the decision is open.
A name that collection does not hold is logged and skipped rather than drawn as
an empty list.
One list, at one age, is a page of its own — ?list=8, or ?list=8&age=10, with
the age filter keeping whichever list is open. The whole page shows each list's
first bestPreview books; a list's own page shows up to maxBestBooks and says
so when it has cut the list short. There is no paging: the link promises only
what the next page can keep.
The page is drawn light on purpose. It is a directory of fifteen lists and
about a hundred books, so a book is one line — title, then author, year, age and
its reading measures inline — rather than the stacked bookLevels block, which
is what a card about a single book wants and what would make every row of a
hundred three lines tall. Counts are written with their comma by the num
template function, which is likeNumber, so how the site punctuates a number
still lives in one place. A list's own page counts and describes that list; only
the page showing all fifteen counts all fifteen.
Each list is drawn as a panel folded shut. The name, how many books it holds
and what it is are on the page; the titles are behind data-bs-toggle="collapse"
until a reader opens the panel, because fifteen lists and a thousand titles
arriving at once is a wall rather than a directory. The titles are in the HTML
and the fold is only a class, so nothing is fetched to open one, and the panels
do not close each other — they are fifteen lists to read against each other
rather than a set of alternatives. The Best in class roads above them fold
the same way and by the same mechanism, since a road's shelf is a list too; the
classes are named .fold-* rather than after the lists for that reason. The fold is Bootstrap's, whose bundle is on
every page; with no script there is no way to open a panel, so <noscript>
shows every list whole and takes the carets away. Failing to the long directory
is the right failure. The caret is the only thing that says a name is a control,
so it is drawn in web/scss/main.scss and follows aria-expanded — the
attribute Bootstrap keeps in step with the open panel — rather than a class of
our own that could drift out of step with it.
A book standing on three lists is three rows of book_collection, and the page
counts books rather than rows — 2,837, not 3,364. The whole of the chosen lists
comes back from one query and is narrowed and counted in Go, which is what lets
the shelves and the numbers on them be drawn from the same rows.
Accounts
The interview is behind an account. It is the part of the site that records what
a child read and what they thought of it, and that is only worth keeping if the
reader can come back to it — and only theirs to see. The rest of the site stays
open: the catalog, search, statistics, the explorer and the reader profile page
all work without logging in, and an anonymous reader is still just the books_reader
cookie's unguessable public id.
An account is what ties that history to a person: app_user holds the account,
user_session one logged-in browser, and reader.user_id links a reader to the
account that owns it — so every interview, interaction and signal already
attached to that reader is attributable to someone who can log back in.
auth.go holds the whole of it.
| Route | Method | Behaviour |
|---|---|---|
/register |
GET |
The registration form. |
/register |
POST |
Creates the account and logs it in. |
/login |
GET |
The login form. |
/login |
POST |
Verifies the password and starts a session. |
/logout |
POST |
Ends the session. |
/forgot-password |
GET |
The "email me a link" form. |
/forgot-password |
POST |
Mails a reset link, if the address has an account. |
/reset-password |
GET |
The new-password form, for a valid token. |
/reset-password |
POST |
Sets the new password and consumes the token. |
These are addresses rather than site-map destinations, because whether they are reachable depends on whether someone is logged in, which a static navbar entry cannot express. The navbar draws the account area itself: log in and register links when logged out, the username and a log out button when logged in.
The rules that matter:
- The plaintext password is never stored. Only a bcrypt hash is written, at bcrypt's default cost; verification reads the cost from the stored hash, so raising it later invalidates nothing. A malformed stored hash fails the login rather than the request.
- A username is a free-form handle. Any characters, any case; the only limits are that it is present and at most 64 characters. It is unique case-insensitively, and, with the email, one of the two login names.
- The email is the durable identifier. It is required, format-checked, stored lowercased and unique case-insensitively — it is what a password reset is sent to, and the only thing a reset will accept, so a username cannot be used to trigger mail to someone else's address.
- A failed login says nothing about which names exist. An unknown name and a wrong password produce the same message and the same work, a dummy bcrypt compare, so response timing cannot enumerate accounts.
- Sessions are rows, not flags. A session is a
user_sessionrow with an expiry and an unguessable UUID token in an HttpOnly, SameSite=Lax cookie; logging out deletes the row. The cookie is marked Secure whenever the connection was https, which behind the TLS-terminating proxy is visible only inX-Forwarded-Proto. - A reader is adopted, never taken. Registering or logging in claims the
browser's reader only while it has no owner, so an interview answered before
registering is not thrown away. A reader already owned by another account is
left alone and a fresh one is opened. Deleting an account detaches its reader
(
ON DELETE SET NULL) rather than destroying a child's history with the login. - An owned reader is not readable without its account. Once a session has expired, the reader cookie alone no longer acts as that account's reader; the browser falls back to a new anonymous reader, and logging back in restores the original one.
- The interview is gated, and the gate remembers where you were. A
logged-out request to any interview address — the questions, the shelf, a
reaction, a shelf note — is sent to
/login?next=…, and both the login and register forms carrynextthrough, so answering one lands back where the visitor was headed.nextis accepted only as a rooted path on this site, so it cannot be used to bounce a freshly logged-in visitor somewhere else; a POST resolves to the GET address of its interview, never to the write itself. - The Reminders is gated too, the gate explains itself, and the book comes with
you. A Remember makes a list a reader comes back to, so it belongs to an
account rather than to a browser (McKelle, 2026-10-28). A signed-out press of
the Remember control writes nothing and mints no reader: it is sent to the
registration form with
next, a reason and the book (/register?next=…&why=remember&keep=<slug>), which says why it appeared and puts the book on the list as soon as the account exists — the press is finished, not asked for again. The reader is then returned to the row they pressed on, where the control already reads Remembered: the thing itself is a better confirmation than a sentence about it, and it does not move a reader away from the book they were looking at. The one exception is a book the catalogue no longer offers — there is no row left to return to, so that press lands on the Reminders and says nothing could be added. The reason travels as a code and the sentence is chosen from it byaccountReasons, so nothing typed into an address reaches the page as prose; the slug is cleaned bykeptSlug, so a value that is not a slug never reaches the catalogue. Both the register and login forms carrykeepthrough as a hidden field, so a mistyped password and the wrong door both keep it. The write is the same row a press writes — statusinterested, no session — and it never fails the sign-up it follows: an account is created even if the carried book cannot be written. Removing a book from the list goes throughrequireLoginlike any other address about an account.
Admins
Some accounts administer the site. The flag is a single column,
app_user.is_admin, defaulting to false, so every account stays unprivileged
until it is deliberately changed:
$ psql -d books -c "UPDATE app_user SET is_admin = true WHERE email = 'you@example.com';"
Nothing in the site sets it. Granting privilege should leave a trail in an operator's shell rather than in a web form, and there is one privilege so far — a role table would be a schema in search of a second role.
An administrator sees an Admin entry in the account menu, which leads to
/admin. The page lists what tools exist and what is still to come. The first
one is the book editor.
Who may reach it is decided per request, not per session: is_admin is read when
each request's session is resolved, so granting or revoking it takes effect on
the next page load without the account logging in again. A visitor who is not
logged in is sent to the login form and returned to what they asked for; a
logged-in visitor who is not an administrator gets the site's ordinary 404
rather than a refusal, because a 403 would confirm that the address exists.
Editing a book
A book's page carries an Edit details button for an administrator, which
leads to /admin/books/{slug}: a form holding the same fields the page shows —
the catalog row, the reading measures, the ISBNs and the collection
memberships — filled with what is stored. Saving writes all of it.
The one thing it does not do is write the row and move on. Every save records what it replaced:
| Column | Holds |
|---|---|
book_id |
the book the edit was made to |
edited_by |
the account that made it, while that account exists |
edited_by_username |
the username as it was at the time |
edited_at |
timestamptz: an absolute instant, written and read in UTC |
before |
the book's whole editable state before the change, as JSONB |
The book row is the state after the latest edit, so the row plus its revisions is the whole history — no diff has to be reconstructed, and any past state can be read back as a record rather than inferred. Nothing surfaces those revisions in the UI yet; the table is the audit, and it is complete on its own.
Four things are worth knowing before changing it:
- The revision and the edit are one transaction. A change cannot exist without its revision, and a failed save cannot leave one behind.
- The row is locked before it is read. The state recorded as
beforehas to be the state the edit actually replaced, and two saves arriving together would otherwise both read the same one. - A save that changes nothing records nothing. The submitted state is compared against the stored one through the same JSON encoding the audit uses, so "nothing changed" means exactly that; the form says so instead of writing a revision whose before and after are identical.
- An edit is protected from the next import, once.
cmd/importleaves a book alone when an administrator edited it more recently than the last completed import, and names each such book in its log — sheet row, title, book id, when it was edited and by whom — so the operator can reconcile it against the sheet row by row. The sheet is still the source of truth for this data, so the protection lasts one cycle: an edit nobody reconciles is taken back by the following import, and the log says so. See Catalog import.
What the editor deliberately leaves alone: a book's tags (a many-to-many
vocabulary that no tool edits yet, and every revision would otherwise carry a
copy of), its slug (the address the page is served at, not a detail), its
emoji, and the Open Library identifiers. If a tag editor is ever added, the
before snapshot should grow to include them so a revision stays a complete
record of what that tool can change.
Password reset
A reset is a password_reset row: a random UUID, the account it belongs to, and
an expiry one hour out. Only the link that carries the token has it in the clear,
and the row is deleted the moment the password changes. Asking for a new link
deletes the account's outstanding tokens, so an abandoned link cannot be used
later by whoever holds the mailbox.
The flow is auth.go throughout:
/forgot-passwordtakes an email address. The answer is the same whether or not it matches an account, and whether or not the mail is delivered — a send failure is logged, not shown — so the form cannot be used to find out which addresses are registered.- The link goes to
/reset-password?token=…, which shows the new-password form only for a token that is still valid. - Submitting looks the token up, writes the new hash, deletes the token and ends every session the account holds, all in one transaction. Two submissions of the same link cannot both find it valid, and whoever was signed in on another device with the old password is signed out.
- The login page thanks the visitor and asks them to sign in with the new password.
BOOKS_BASE_URL is what makes the link absolute. It is configured rather than
taken from the request, because a link built from the Host header can be
pointed at an attacker's domain.
Mail configuration
Password-reset email is optional. With SMTP_ADDR unset the site still runs,
and the reset pages say mail is unavailable rather than pretending to send.
Setting it turns mail on, and then four things are required or defaulted:
| Variable | Meaning |
|---|---|
SMTP_ADDR |
Relay as host:port. Unset disables mail. |
SMTP_FROM |
Envelope and header sender. Required with SMTP_ADDR. |
SMTP_TLS |
starttls (default), tls for implicit TLS, or none. |
SMTP_USERNAME |
Relay user, for PLAIN auth. Optional. |
SMTP_PASSWORD |
Relay password. Optional. |
BOOKS_BASE_URL |
Public origin, e.g. https://books.example.com. Required with SMTP_ADDR. |
starttls is the default because it is what a submission port (587) expects.
none is for a local relay that offers neither; it still refuses to send in the
clear when the server advertises STARTTLS and the upgrade fails. An SMTP_ADDR
without SMTP_FROM or BOOKS_BASE_URL is a startup failure, like a missing
POSTGRES_DSN.
Search
search.go matches a query as a plain substring of a book's title or author, or
of an author's name, case-insensitively — no ranking, no stemming, no fuzzy
matching. Queries shorter than four characters are refused, and PostgreSQL's own
LIKE metacharacters are escaped first, so searching for 100% means those
four characters rather than "anything".
Matches come back in alphabetical order of the published title
(above), so The Hobbit appears among the T's and
an A-titled book among the A's; an author's page lists their books by year
and, within a year, by that same title. The article is not skipped when sorting.
Both lists are places a person chooses between titles, so each result carries the
book's reading measures — Lexile, ATOS and AR interest level — in the same block
the shelf cards use. BookSummary, the shape both lists return, carries the
three columns from book_reading_level, joined in bookSearch and
BooksByAuthor; a book with no measures of its own shows nothing.
| Surface | Shows |
|---|---|
/search/suggest |
Up to 6 books and 4 authors, each labelled Book or Author, as JSON. |
| type-ahead | The same list under the navbar's box: four characters, then arrow keys and Enter, or click a row. |
/search |
Up to 50 of each, in two sections. |
Selecting a suggestion goes straight to /books/{slug} or /authors/{id};
those two pages are addresses rather than destinations, so they are registered
in registerRoutes beside the site map rather than in it.
A book's page lists its catalog fields and description, its reading measures, the curation lists it appears on, its ISBNs, its URL and its award and curriculum tags, and links to its author. The reading measures are whatever the sheet carried, each shown as published and none of them invented:
| Shown | Comes from |
|---|---|
| Accelerated Reader level | book_reading_level.ar_level, as published |
| AR interest level | book_reading_level.ar_interest, code spelled out |
| Lexile | book_reading_level.lexile, written 600L |
| Lexile code | book_reading_level.lexile_code, code spelled out |
| Guided reading level | book_reading_level.grl, the letter A–Z |
| Suggested reading age | book_reading_level.age_from/age_to, in ages |
| Ambleside year | book_reading_level.ambleside_from/ambleside_to, a curriculum year |
| Collections | book_collection, with the source's value where it has one |
| URL | book.url |
| ISBN-13 / ISBN-10 | book_identifier |
Three of those labels are deliberate. Suggested reading age is the sheet's
From/To columns — a third-party retailer's US grade range, converted to ages
at ingestion because a grade names different ages in different countries. The
workbook does not record which retailer they came from, so the page does not
name one: it is a suggestion, and it says so. AR interest level is spelled
out in full (MG+ — upper middle grades, 6–8) because the code says who a book
was written for, which is not the same question as how hard it is to read.
Ambleside year is a year of the Ambleside Online programme, not an age and
not a calendar year: it maps roughly to a grade, year 0 is its first year, and
many books are used across several, which is why the sheet has a start and an
end. The scale runs 0 to 12, a check constraint says so, and a book whose year 0
is the whole story reads Year 0 onwards — the page notes beside the table that
the years are not ages, because "suggested reading age" sits directly above
them.
A measure that is missing is left out rather than shown as a dash: the section
itself is absent when a book carries no measures at all, which is the case for
436 of them. Where a Lexile code arrives without a measure — always true of
NP — the code stands on its own line.
Collections
A list's small print is whatever the source recorded beside the book: an age band for Mensa Kids, a grade for Deep Roots and The Good & The Beautiful, a set of years for St. John's College. Those are shown as they stand.
Beautiful Feet is the exception, because its column carries the vendor's study
codes rather than a band — HHtL, EAH46, MUSW912. Those are spelled out
from the vendor's own catalogue (HHtL — History of the Horse through Literature), and a row naming more than one study is split before each is
looked up: the sheet separates them with a comma or the word "and", and two
books read HWE48, EAHK3. The list lives in beautifulFeetStudies in
search.go; the codes are case-sensitive.
Two codes in the sheet are not on that list and are shown as themselves:
EAWH79 and GtL47, three books each. The vendor has renamed and retired
series, so guessing at them would put a plausible name on a study nobody
confirmed. Adding them is a one-line change once their names are known.
An author's page lists their books oldest first.
Styles
The site is themed with Bootstrap 5 compiled from source, not with a prebuilt Bootstrap stylesheet, so the palette can override Bootstrap's defaults.
| Path | Purpose |
|---|---|
web/scss/_variables.scss |
The colour palette and type scale. The only file to edit to re-theme. |
web/scss/main.scss |
Entry point: the palette, then Bootstrap. |
web/scss/vendor/bootstrap/ |
Bootstrap 5.3.8's scss/, vendored verbatim (MIT). |
web/assets/js/ |
Bootstrap's JavaScript bundle, vendored. |
web/assets/css/main.css |
Build output — embedded in the binary, not committed. |
Book pictures
A book's cover is a JPEG on this machine, one per book, named by the book's id
(covers/1200.jpg), and read at startup. It is not embedded in the binary:
the pictures are somebody else's photographs, they arrive in batches and are
re-resolved when one turns out to be the wrong printing, and a 100 MB picture
library inside the executable would be rebuilt on every change.
The reason they are kept at all, rather than linked from the source, is measured:
89% of the pictures the resolver finds are served from archive.org, a host that
school and library filters commonly block. A copy on our machine cannot be
blocked and cannot disappear because somebody else's archive reorganised.
| Setting | Meaning |
|---|---|
BOOKS_COVERS_DIR |
Where the pictures live. Default covers, beside the binary. |
A directory that is not there is a supported deployment, not an error: the
server logs how many pictures it read, and every card without one draws its
book's emoji in the same place, which is what the shelf did before there were
pictures at all. A book id the library does not hold answers 404 rather than
an empty image, and /covers/ itself is never a directory listing.
Filling it from what the resolver chose (see cmd/coverresolve):
run ./cmd/coverfetch # store/*.json -> covers/, skipping what is there
run ./cmd/coverfetch -force # re-download everything
coverfetch asks for a middling size where the source offers one — a shelf card
shows a cover 76 pixels wide, so the largest picture is five times the bytes for
something nobody sees at that size. It refuses anything under 130 pixels high
and any file that does not decode as an image, so a broken download never
reaches the library.
Design mockups
web/design/ holds whole-page mockups — drafts of screens that do not exist
yet — and the binary serves them at /design/, with web/design/index.html as
the shelf that links to each one. They are embedded the same way the stylesheet
is, so a mockup travels with the binary and can be looked at from a phone at the
real address rather than from a file on somebody's laptop.
Each mockup is a single self-contained HTML file: its stylesheet, its
JavaScript, and its fonts travel inside it, so it makes no network requests and
needs no build step. They are deliberately not entries in the site map, and
nothing in the navbar links to them — a draft of a page is not somewhere a
reader should be sent. They are reached by typing the address. Each carries
<meta name="robots" content="noindex"> so a draft cannot turn up in a search
result.
navigation-map.html is the one drawing of pages that already exist rather than
a draft of a page that does not: the site's own pages as boxes, drawn three
times — the menu's links, the reader's journey, and what each page stands on.
It is generated, not hand-written. go run ./cmd/navmap reads sitePages out of
pages.go, draws the first reading from it, adds the second and third from
routes declared (with their source) in the tool itself, and rewrites the file.
Its tests check the three things a drawing can get wrong: a box outside the
canvas, an arrow pointing at nothing, and a line drawn straight across a box
instead of round it.
make css compiles web/scss/main.scss into web/assets/css/main.css, which is
embedded into the binary and served at /assets/css/main.css. The Go build
cannot succeed without it, so make build and make run compile the stylesheet
first; a bare go build needs a make css beforehand. nix/package.nix runs
the same step in preBuild, so a deployed binary always carries a stylesheet
built from the sources in its own store path.
Database
Configured entirely by one environment variable:
POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable'
There is no default. If it is unset the process exits immediately rather than starting up unable to serve.
The process migrates itself on startup: it asks goose whether any embedded migration is outstanding and applies them before the listener opens, so a deployed binary brings its own schema. The DSN's role therefore needs to own the schema.
Migrations
db/migrations/NNNNN_snake_case.sql, goose-style, with -- +goose Up and
-- +goose Down. The sequence must not skip: a gap makes goose fail at
startup. 00003_catalog.sql moves the quiz's five placeholder books into the
real catalog model rather than dropping them; 00004_reading_levels.sql adds
book_reading_level, the measures the statistics page plots;
00005_reader_interview.sql drops the personality quiz's three tables and adds
the reader, session, response, interaction, signal and feedback tables;
00006_ar_interest.sql adds ar_interest, the one developmental signal, and
backfills it from staging. 00007_signal_supersession.sql collapses the
duplicate evidence repeated interviews had produced and adds the indexes that
stop it recurring; 00008_recommendation_review.sql adds the notes a reader
leaves on a shelf or one of its books; 00009_reading_dates.sql adds read_on,
which is when a book was read rather than when the row was written;
00010_signal_suppression.sql records the beliefs a reader's profile has been
corrected to exclude.
00011_age_ranges.sql renames grade_from/grade_to to age_from/age_to and
converts the values already stored, so the model's unit matches the one
ingestion now writes. 00012_developmental_suitability.sql adds
book_developmental, developmental_tag and book_developmental_tag, the
project's own judgement about a book — see Developmental
suitability. 00013_user_accounts.sql adds
app_user and user_session, and a nullable reader.user_id, so a reading
history can belong to an account — see Accounts.
00014_password_reset.sql adds password_reset, the single-use tokens a reset
link carries — see Password reset.
00015_admin_users.sql adds app_user.is_admin — see Admins.
00016_book_detail_fields.sql adds book_reading_level.ar_level and
lexile_code, book.url, and the collection / book_collection tables that
carry the sheet's curation lists — see Search. It backfills from
staging, which still holds the source columns, and cmd/import/transform.sql
writes them directly on a re-import.
00017_book_revision.sql adds book_revision, the audit the book editor writes
— see Editing a book.
00018_ambleside_year_scale.sql puts a 0-to-12 check on
book_reading_level.ambleside_from/ambleside_to, which are curriculum years
rather than ages — see Search.
00019_natural_title_order.sql rewrites the catalog's titles from library
order (Hobbit, The) into published order (The Hobbit), which is what every
list now sorts by; cmd/import/transform.sql writes the published form on a
re-import, so the two agree — see the ordering note under From answers to
signals.
Four of these depend on how the database was set up: 00007, 00009, 00011
and 00013 change or extend tables that 00005 created, so they require the
role named in POSTGRES_DSN to own those tables. 00016 extends tables from
00003 and 00004 as well, 00018 adds a constraint to one of them and 00019
rewrites rows in book, so they need the same ownership of the catalog. A role
that can create tables but not alter them will fail here, which is a deployment
problem rather than a migration problem — the DSN's role is expected to own the
schema.
Add a migration, then regenerate the query-builder code:
$ nix develop
(nix) $ go run ./db/jet # reads POSTGRES_DSN; applies nothing itself
db/jet needs a database that already has the migrations applied. The
generated files under db/gen/ are committed, so the Nix build never needs a
database.
Catalog import
The customer's data arrives as two CSV files in the repository root: the catalog itself, and a description-provenance log exported from the same workbook. Both file paths are flags, so the files can live anywhere:
$ nix develop # go, go-jet, goose and psql
(nix) $ export POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable'
(nix) $ go run ./cmd/import -dry-run # parse and report, touches nothing
(nix) $ go run ./cmd/import # loads the two default paths
(nix) $ go run ./cmd/import -tab1 /path/to/catalog.csv -tab2 /path/to/research.csv
(nix) $ go build -o books-import ./cmd/import # standalone binary
-dry-run needs no database at all. A real run reads $POSTGRES_DSN (or
-dsn), applies any outstanding migrations itself, and then, in one
transaction:
- copies both files verbatim into
staging_tab1_raw/staging_tab2_raw, repairing the character encoding on the way in; - derives the normalized tables from staging using the steps in
cmd/import/transform.sql; - writes anything it cannot place to
quarantine_row; - prints a reconciliation report and fails if a count disagrees.
Re-running
The import is idempotent. Books are recognised by their external identifiers
and by a (title, author, year) fingerprint, which is unique across all 3,579
source rows, so a second run updates rather than duplicates. Staging is
truncated per batch, and every batch is recorded in import_batch with the
source file's SHA-256.
A book an administrator has edited
An edit made in the book editor is newer information than the
export it was made against, so the import does not quietly revert it. The
transform's first step builds a protected_book list — books whose latest
revision is newer than the last completed import — and every step that writes
something about a book skips those: genre, publisher, author links, tags,
identifiers, URL, reading measures, collections and the description-research
row. The book is left exactly as the administrator left it.
Each skipped book is logged, one line at a time, because a skip the operator cannot see is only a discrepancy moved somewhere nobody is looking:
2026/09/23 17:48:44 import: row 7 "100 Cupboards" (book 6) was edited since the last import — 2026-09-23T17:48:38Z by smoke-editor, 1 revision(s); the sheet's values for it were left alone
2026/09/23 17:48:44 import: 1 book(s) protected; reconcile each against the sheet, because the next import will apply it
The reference is the last completed import, not "ever edited". The sheet stays the source of truth for this data, and a book frozen forever would drift away from it, so the protection lasts exactly one cycle: an edit nobody reconciles is taken back by the following import. Reconcile by updating the sheet to match the edit — then the import writes the same values and nothing changes — or by correcting the book in the editor. Books the sheet does not mention are not protected, because nothing would write to them anyway.
The list is a temp table built inside the import's transaction, and the log is emitted only after that transaction commits, so a failed import cannot report skips that never happened.
What the source data requires
These are the reason the transform looks the way it does; all are enforced by tests or by the reconciliation report.
- Encoding. The export read UTF-8 bytes as Windows-1252, so some characters
are double-encoded (
’for’) while others in the same file are intact. The repair is applied per run of characters, never to the whole file, because a whole-file transcode corrupts 499 characters of good text. ~15U+FFFDare already in the source; those bytes are unrecoverable and are quarantined. --is the null sentinel in every column, and ISBN fields carry a leading apostrophe from the spreadsheet's text marker.- Davidson Age has been corrupted by the spreadsheet. Some values are dates
(
3/8/2026) where an age or grade belongs, so they are quarantined rather than carried forward. Nothing reads the column; it stays in staging with the other research columns. Meta Tagssplits on;only. Commas occur inside tag names (Recommended Reading-California Recommended Lit., English, K-2), so splitting on comma would shred 261 rows.tab2.ROWaddresses the book's row in tab1, not tab2's own row, and four of its 1,436 values no longer agree with tab1 — a re-export shifted those rows. Rows are therefore matched on(title, author), which resolves 1,435 of 1,436; ROW is recorded, and disagreements are quarantined.tab2is provenance, not a patch set. EveryDESCRIPTION_PATCHis byte-identical to the description already on the book, so it is imported asdescription_research(where the text came from, and which 33 books have no defensible description) rather than as an overwrite.
Staging is not the final model
The reading measures are promoted into book_reading_level by the
reading_levels step: Lexile, the two suggested ages and AR Interest Level as
themselves, Guided Reading Level as the letter it actually is. Anything that
cannot be read as a measure — the -- sentinel, a grade of 0 0 1 — is left
NULL rather than guessed at.
The source's From/To are US grade labels bought from a third-party retailer
— Scholastic or another large one; the workbook did not record which — so they
are a suggested reading range rather than anything this project worked out. They
are stored as ages, because a grade does not name the same thing in every
country, and the conversion happens here, at ingestion, where the data's US
origin is known: US grade N is age N + 5, so kindergarten is 5 and
pre-kindergarten is 4. The raw labels stay in staging_tab1_raw untouched, so
staging holds grades and the model holds ages; readerGrade runs the same
conversion backwards, which is what keeps a reader and a book comparable.
AR Interest Level is promoted because it is not a difficulty measure at all: it says who a book was written for, and the recommendation pipeline needs that to screen developmentally without falling back on reading difficulty, which is a different question. The AR level (ATOS) is promoted beside it, on both the book's card and the reader's evidence, and it is the one difficulty measure the screen reads — and then only to compare a book against the reader, never to rank two books against each other, which is still the Lexile's job.
The 17 award and curriculum columns stay in staging_tab1_raw un-promoted. They
are deliberately left as text: they mix booleans (y, X), grades (7),
ranges (K-3), program codes (EAH46) and comma-separated multi-values
(1,2), and promoting them properly needs a book_curation shape that nothing
asks for yet. Nothing is lost — staging holds every column of every row, and
import_batch records which file it came from.
Licensing
Descriptions and ratings were sourced from Goodreads, AR BookFinder, Open
Library and Google Books. Every imported description keeps its origin in
description_research.source_stage / source_url so the provenance survives
while the redistribution question is settled.
Developmental suitability
Two age ranges now live in the catalog, and they answer different questions.
book_reading_level.age_from/age_to is a retailer's suggested range: a third
party's grade label converted to ages at import. book_developmental is the
project's own judgement — from what age a book is a worthwhile developmental
experience, and how far its value runs. Those are different questions asked by
different people. A retailer's range can say a book is pitched at eight-year-olds;
it cannot say whether the book is worth an eight-year-old's time, or that a
twelve-year-old would still get something out of it. Holding both in one place
would invite reading one as the other, which is why they are two tables rather
than two pairs of columns.
A judgement is from_age + to_age + a support value + an occasional note +
developmental tags. Neither age is a prohibition: to_age is the top of the
book's especially valuable sweet spot, not the point at which a reader has
outgrown it, and from_age is the youngest age at which the book is worth
recommending rather than the youngest age able to decode it. support records
how much adult help makes the book work — independent, awareness,
conversation or guided — because support is what unlocks satire, grief,
history and deliberate ambiguity, not only a shield from content.
The developmental tags are a controlled vocabulary that is deliberately not
enumerated. developmental_tag starts empty and grows as books are judged,
because the model says the terms have to come out of the evidence; seeding it
would invent a taxonomy before there is anything to categorise. A tag attaches
to a judgement rather than to a book, so a book with no developmental row has no
demands to describe yet.
Nothing writes these tables and nothing reads them. The model is designed and
the numbers have to come from evidence that has not been gathered, so this is
storage waiting for the tool that will fill it. The evidence itself belongs
somewhere else when that work starts, and there is nowhere for it yet: the model
is explicit that a source's age judgement stays the source's, so from_age is a
conclusion drawn from evidence rather than a copy of it, and the two must not be
collapsed into one column.
The reader side of the same question
SCENARIOS.md holds ten real readers worked through by hand before the engine
existed, with what they were offered and what was wrong with it. It is the reader
counterpart to book_developmental: evidence of what a shelf should look like,
gathered from readers rather than invented, and — like those tables — mostly
describing judgements the code cannot make yet. scenarios_test.go asserts the
part of it the developmental screen already decides, so a change to that screen
has to answer to the six-year-old and the tenth grader at Lexile 1770 rather than
to the code alone. Read it before widening or narrowing the screen, and before
concluding that a shelf is right because nothing refused it.
Compression
Every text response is gzipped for clients that ask for it, in compress.go,
which wraps the whole mux in main.go. It is worth knowing about because the
pages here are large and the reason is deliberate: a chart is inline SVG, a plot
is one element per book, and every dot carries the title it belongs to so that
hovering one can name it. The explorer's page is around 670 KB of HTML and
around 105 KB gzipped; the stylesheet is 236 KB and 33 KB.
The rules it keeps: only text/* and the types listed in compressibleTypes
are compressed (a picture is already compressed); a response to a range request
is left alone, because a piece of a file cannot be deflated on its own; the
uncompressed Content-Length is dropped whenever the body is compressed; and
Vary: Accept-Encoding is set whether or not the answer was compressed.
compress_test.go tests each of those, and needs no database.
Local run
$ POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable' nix run .#
books: listening on 127.0.0.1:10210
Then open http://127.0.0.1:10210/. The interview needs an account, so register first; to walk it without a browser, keep the cookie and follow the form:
$ curl -s -c jar -b jar -L -d 'username=me&email=me@example.com&confirm=correct+horse&password=correct+horse' \
localhost:10210/register >/dev/null
$ curl -s -c jar -b jar localhost:10210/reading | grep -o 'name="session" value="[^"]*"'
$ curl -s -c jar -b jar -L -d 'session=<uuid>&question=request&want=fantasy&want=adventure' \
localhost:10210/reading | grep -o 'name="question" value="[^"]*"'
A question's fields are named in interview.go; a choice field takes one
field=value pair per selection.
Working on the stylesheet needs the dev shell, which carries dart-sass:
$ nix develop
(nix) $ make css # rebuild web/assets/css/main.css
(nix) $ make run # css, then the server
Deploying
The flake exposes nixosModules.default, which installs the binary as a
hardened systemd service. It binds to loopback by default, so the reverse proxy
must run on the same host:
{
inputs.books.url = "path:/home/eliribble/src/books"; # or a git URL
services.books.enable = true;
services.books.environmentFile = "/var/run/secrets/books-env"; # POSTGRES_DSN=..., SMTP_ADDR=..., SMTP_FROM=..., BOOKS_BASE_URL=...
# services.books.port = 10210; # default
# services.books.host = "127.0.0.1"; # default
# services.books.openFirewall = false;
services.caddy.virtualHosts."books.example.com".extraConfig = ''
reverse_proxy 127.0.0.1:10210
'';
}
environmentFile is a path rather than a plain option so the database password
never lands in the world-readable Nix store. Enabling the service without it
produces a warning, because the app cannot start without POSTGRES_DSN.
services.books.package is overridable if the server should come from
somewhere other than this flake.
Because the service uses DynamicUser and keeps no local state, it needs no
directories created and no secrets configured beyond the DSN.