McKelle's books project
  • HTML 85.7%
  • Go 12.6%
  • Shell 0.8%
  • JavaScript 0.3%
  • Awk 0.3%
  • Other 0.3%
Find a file
2026-09-28 15:06:22 +00:00
.agents/agents Record rule 10 and the invariants: an observer ranks, never relates 2026-09-28 03:40:59 +00:00
cmd/import Take Drama off "Do Fish Sleep?" 2026-09-28 14:49:13 +00:00
db Take Drama off "Do Fish Sleep?" 2026-09-28 14:49:13 +00:00
design Record the settled night ground: the warm teal 2026-09-28 04:20:12 +00:00
nix Rework frontend to be for the correct audience. 2026-09-22 04:42:52 +00:00
scripts Make paseo-migrate.sh fail when the migration fails 2026-09-26 18:08:37 +00:00
templates Fold the roads' own shelves the same way as the source lists 2026-09-28 03:22:57 +00:00
web Place the second shelf batch, taking the shelf from 45 to 180 books 2026-09-28 04:06:59 +00:00
.gitignore Add round three of the design mockup, and a gate for invisible text 2026-09-26 05:20:39 +00:00
admin.go Offer the slip card where the readers are listed 2026-09-27 01:58:51 +00:00
admin_test.go Offer the slip card where the readers are listed 2026-09-27 01:58:51 +00:00
agedrift_test.go Screen a book on the age the catalogue gives it, not only its AR level 2026-09-26 23:36:17 +00:00
AGENTS.md Updated naming instructions 2026-09-28 15:06:22 +00:00
auth.go Use the reader the browser is on, not the account's first one 2026-09-26 04:01:46 +00:00
auth_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
best.go summits: the shelves the roads arrive at, in the database 2026-09-27 05:39:48 +00:00
best_test.go summits: the shelves the roads arrive at, in the database 2026-09-27 05:39:48 +00:00
bookedit.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
bookedit_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
config.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
config_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
flake.lock Add Go hello-world server and NixOS flake 2026-09-22 00:19:06 +00:00
flake.nix Rework frontend to be for the correct audience. 2026-09-22 04:42:52 +00:00
forget.go Empty a profile only for the owner, and explain an expired code 2026-09-26 23:36:58 +00:00
forget_test.go Empty a profile only for the owner, and explain an expired code 2026-09-26 23:36:58 +00:00
go.mod Put a real QR square on the claim slip 2026-09-26 02:43:38 +00:00
go.sum Put a real QR square on the claim slip 2026-09-26 02:43:38 +00:00
interview.go Return the reader to the card that took the answered card's place 2026-09-26 23:36:22 +00:00
interview_test.go Open the middle grades to a reader already reading there 2026-09-27 00:52:51 +00:00
lexiledate_test.go Assume a date for books nobody dated: three years for one they enjoyed, two for one they did not 2026-09-26 23:42:50 +00:00
likepage.go Length is a non-consideration, so take it out of the score 2026-09-27 00:04:11 +00:00
mail.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
mail_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
main.go Rank a book's neighbours, and print them without a page 2026-09-26 20:41:25 +00:00
Makefile Rework frontend to be for the correct audience. 2026-09-22 04:42:52 +00:00
pages.go Make Best books ever lighter, and count the list it is showing 2026-09-27 03:23:13 +00:00
paseo.json Add agent guide and Paseo scripts for the dev loop 2026-09-25 21:14:08 +00:00
profile.go search: let a reader ask us to add a book 2026-09-27 05:07:40 +00:00
QUESTIONS-FOR-ELI.md Ask Eli who pushes the working copy, and record the home screen as the first port slice 2026-09-28 04:44:31 +00:00
readers.go search: let a reader ask us to add a book 2026-09-27 05:07:40 +00:00
readers_card_test.go Offer the slip card where the readers are listed 2026-09-27 01:58:51 +00:00
readers_test.go search: let a reader ask us to add a book 2026-09-27 05:07:40 +00:00
README.md Fold the roads' own shelves the same way as the source lists 2026-09-28 03:22:57 +00:00
reason_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
recency_test.go Assume a date for books nobody dated: three years for one they enjoyed, two for one they did not 2026-09-26 23:42:50 +00:00
recommend.go Ask the reader a question on a card, not for a fault report 2026-09-27 02:11:52 +00:00
recommend_test.go Return the reader to the card that took the answered card's place 2026-09-26 23:36:22 +00:00
review.go Ask the reader a question on a card, not for a fault report 2026-09-27 02:11:52 +00:00
scatter.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
scatter_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
SCENARIOS.md Name the reader: Wonder was Lindsay's, not the neighbour's 2026-09-27 02:03:55 +00:00
scenarios_test.go Record what a real reader said about Wonder 2026-09-27 02:00:42 +00:00
search.go search: let a reader ask us to add a book 2026-09-27 05:07:40 +00:00
search_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
shelf_print_test.go Print the shelf as a plain list: title, author, Lexile 2026-09-27 00:36:09 +00:00
similar.go Length is a non-consideration, so take it out of the score 2026-09-27 00:04:11 +00:00
similar_test.go Take the year out of the map, and file Earthsea where it belongs 2026-09-26 23:59:39 +00:00
stats.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
stats_test.go Add simple statistics page 2026-09-22 04:53:53 +00:00
summary_test.go Ask the reader a question on a card, not for a fault report 2026-09-27 02:11:52 +00:00
suppression_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
wayfinding.go summits: the shelves the roads arrive at, in the database 2026-09-27 05:39:48 +00:00

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.
search.go Search, its suggestions, and the two detail pages.
scatter.go The statistics page's scatter plot: axes and layout.
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. Everything except the interview and the statistics page is placeholder copy for now.

Route Method Behaviour
/ GET Home.
/about/history GET About → History.
/about/goals GET About → Goals.
/about/sources GET About → Sources.
/use/how-to GET Use → How To.
/use/most-beneficial GET Use → Most Beneficial.
/discover/best GET The catalog's award and curriculum lists, at the age being shopped for.
/discover/interview GET The next interview question, or the shelf. Requires an account.
/discover/interview POST Stores one answer, then redirects. Requires an account.
/discover/interview/reaction POST Records a reaction to a recommended book. Requires an account.
/discover/interview/review POST Records what a reader thought of a recommended book, or of the shelf as a whole. Requires an account.
/discover/reader GET What the system believes about this reader, and where each belief came from.
/discover/reader POST Saves the reader's age, grade, reading level or name.
/discover/reader/signal POST Records that an inferred belief is wrong.
/discover/reader/read-on POST Records (or clears) the date a book was read.
/discover/statistics GET Counts of the catalog by genre and by decade.
/discover/survey GET Discover → Survey.
/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.
/admin GET The admin page. Administrators only.
/admin/books/{slug} GET The book editor, filled with what is stored. Administrators only.
/admin/books/{slug} POST Saves an edit and records the state it replaced. Administrators only.
/search GET Everything matching a query, by title or author.
/search/suggest GET The same matches as JSON, for the type-ahead.
/books/{slug} GET One book.
/authors/{id} GET One author and their books.
/assets/ GET Embedded stylesheet and JavaScript.
/design/ GET The design mockups (see Design mockups).

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.

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.

The reader interview

/discover/interview 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.

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 length and density signals — never a negative on fantasy, on Tolkien, or on quests. A note signal 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. scope distinguishes 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.

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:

  1. 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.

  2. 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.

  3. 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 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

/discover/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".

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. signalTokens covers 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.

Statistics

/discover/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, 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.

Three 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.

Best books ever

/discover/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 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.

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_session row 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 in X-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 carry next through, so answering one lands back where the visitor was headed. next is 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.

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 before has 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/import leaves 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:

  1. /forgot-password takes 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.
  2. The link goes to /reset-password?token=…, which shows the new-password form only for a token that is still valid.
  3. 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.
  4. 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.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.

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.

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:

  1. copies both files verbatim into staging_tab1_raw / staging_tab2_raw, repairing the character encoding on the way in;
  2. derives the normalized tables from staging using the steps in cmd/import/transform.sql;
  3. writes anything it cannot place to quarantine_row;
  4. 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. ~15 U+FFFD are 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 Tags splits on ; only. Commas occur inside tag names (Recommended Reading-California Recommended Lit., English, K-2), so splitting on comma would shred 261 rows.
  • tab2.ROW addresses 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.
  • tab2 is provenance, not a patch set. Every DESCRIPTION_PATCH is byte-identical to the description already on the book, so it is imported as description_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.

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/discover/interview | grep -o 'name="session" value="[^"]*"'
$ curl -s -c jar -b jar -L -d 'session=<uuid>&question=request&want=fantasy&want=adventure' \
    localhost:10210/discover/interview | 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.