bookkeep

Open-source library management

bookkeep

A library system that cannot lend the same copy twice — because the row cannot exist, not because someone remembered to check.

Catalogue, copies, members and loans, on one machine and in one file. A Next.js application over SQLite through Node’s own driver: no database server to run, no native module to compile, no accounts to administer.

Read the source How to run it

Screens
9 routes
Test cases
69, on a real file
Under lib/db/
node: builtins only
Licence
Apache-2.0

The one rule

A copy that is out cannot go out again

Two librarians scan the same barcode in the same second. Select the open loans for this copy; if there are none, insert reads zero on both desks, inserts twice, and the shelf now owes two people the same object. The window is milliseconds wide and it opens on exactly the busy afternoon when two people are working the desk — which is why testing does not catch it.

So the rule is not in the application.

CREATE UNIQUE INDEX IF NOT EXISTS one_open_loan_per_copy
  ON loan (copy_id) WHERE returned_at IS NULL;
lib/db/schema.sql

The second INSERT fails. lend() in lib/db/loans.ts never asks whether the copy is free: it inserts, catches SQLite’s UNIQUE constraint failed: loan.copy_id, and throws CopyAlreadyOut. Try, then translate — never check, then act.

WHERE returned_at IS NULL is what makes it usable. A closed loan leaves the index, so a copy that comes back goes out again, and every loan it has ever been on stays in the table. The constraint is on open loans, not on history.

One scan is one statement

Nobody at a desk has ever seen a row id; the scanner reads a sticker. So the barcode is resolved inside the write — INSERT … SELECT … WHERE barcode = ?. An unknown barcode selects no rows and nothing is inserted; a known one whose copy is out still meets the index and is refused. Both answers come from the database, with no gap between them.

“Late” is a string comparison

Every timestamp is written by SQLite’s datetime(): YYYY-MM-DD HH:MM:SS, UTC, fixed width. In that format lexicographic order is chronological order, so due_at < datetime('now') is exact and needs no parsing. There is no is_overdue column and no midnight job — a loan goes late because the world moved.

The test is the proof

tests/loans.test.mjs lends, lends the same copy again, and asserts that exactly one open loan exists whatever the caller tried. The 69 cases run against a real SQLite file in a temporary directory, because a mock of the database would be a mock of the one thing under test.

What it does today

Nine routes

Every one of these answers right now. The list below is written out by hand, so treat npm run build as the version that cannot go stale: it prints the routes that actually exist.

/
Catalogue. Search by title or author; partial words match, so a fragment you half-remember is usually enough. Every row carries how many of its copies are on the shelf right now.
/books/:id
Book record. The bibliographic record, then every physical copy with its status, who has it and when it is due. Lend or return from the row.
/books/new
Add a book. Title, author, year, and an ISBN that is allowed to be missing — the ISBN was born in 1970 and a library that refuses older books refuses its own rare shelf.
/books/:id/copies/new
Add a copy. One barcode, unique across the whole library, because that is what the scanner reads and it has to resolve to exactly one object.
/desk
The desk. Choose who is borrowing, scan the sticker, Enter. Returning needs no member at all — the copy knows whose loan is open.
/members
Members. Everyone who holds a card, and what each of them has out.
/members/:id
Member history. Every loan, open or closed, open ones first — the screen for “have they read this already?” and for “who had this copy before it came back damaged?”
/members/new
Issue a card. A name and an e-mail address.
/overdue
Overdue. Past the due date, longest first, with a return button on every row. The first screen at opening time.
The catalogue screen: a search field, then a list of fifteen books, each row showing the author, the year, and how many copies are available.
/ — the catalogue. Availability is computed per row; nothing stores it.
The record for Pride and Prejudice: author, year, a missing ISBN, then three copies — one overdue, one on the shelf with a Lend button, one on loan and due back in fourteen days.
/books/:id — the whole vocabulary of the product in one column: On the shelf, On loan, Overdue. Only the first has a Lend button, and the index is what makes that true rather than the button.
The desk screen in dark mode: a Lend and Return toggle, a member picker, and a barcode field with the hint that a scanner types the barcode and presses Enter.
/desk — a scanner types the barcode and presses Enter, which submits the form. There is nothing to click.

Stated as plainly as the features

What it will not do

Three of these are load-bearing decisions rather than gaps waiting to be filled. Knowing them before you clone is the point of printing them here.

  1. There is no authentication

    None. No accounts, no roles, no record of which librarian lent what. Anyone who can reach the port is the librarian. Run it on a machine where you control who can reach the port, and do not put it on the open internet.

  2. node:sqlite is experimental

    Node 24 ships SQLite 3.50.4 in its standard library, which is what buys the empty dependency list under lib/db/. The price is printed on every boot:

    ExperimentalWarning: SQLite is an experimental feature
    and might change at any time

    Its API can move between Node minor releases. Every call into it lives behind lib/db/connection.ts, so trading it for better-sqlite3 means rewriting one file. The bet is cheap to lose.

  3. It cannot be hosted on a static host

    Including this one. bookkeep answers requests and writes to a file; GitHub Pages gives it neither a process nor a writable disk, and no amount of configuration changes that. One machine with Node on it is the deployment.

On your own machine

Run it

The shortest sequence that actually works.

git clone https://github.com/Navesz/bookkeep
cd bookkeep
npm ci
npm run seed   # 15 books, 28 copies, 5 members, 5 loans already out
npm run dev    # http://localhost:3000
Node 24 — the version is pinned in .nvmrc.

The database creates itself in the project root on first boot, from lib/db/schema.sql, and .gitignore keeps it out of the history: it holds member names and e-mail addresses, and the repository is public. The schema file is idempotent, so booting twice is safe.

The gate is one command, and CI on Ubuntu and Windows runs exactly it, so green on your machine means something:

npm run verify   # lint → typecheck → test → build
npm run secrets  # scan everything Git tracks for credentials
npm run hooks    # once per clone: arms pre-commit and commit-msg