Phase 4 gave the Library one source of truth on a server. Phase 5 upgrades WHERE that
truth lives: from a JSON file to a database β a program built for exactly one job,
keeping data safe, fast, and queryable. Your bookId foreign-key idea becomes literal here.
The Phase 4 endpoint saves books.json to disk. It works β until it doesn't. Each of
these breaks it, and each is a database's reason to exist:
| Problem | JSON file | Database |
|---|---|---|
| Two requests write at once | second write overwrites the first β data lost silently | writes are queued & isolated (transactions) |
| Find one book among 1 million | read the WHOLE file, filter in JS | indexed lookup β microseconds |
| Update 1 field of 1 book | rewrite the entire file | update exactly that row |
| Crash mid-write | half-written file = corrupted everything | transaction rolls back, data intact |
| Rules ("copies never negative") | hope every code path remembers | the database itself refuses bad data |
You already designed the Library's tables without knowing it. An interface describes one thing's shape; a table stores MANY things of that shape β one row per thing, one column per field:
-- your interface Book, spoken in SQL
CREATE TABLE books (
id SERIAL PRIMARY KEY, -- SERIAL = auto-numbered (your Math.max trick, built in!)
title TEXT NOT NULL,
author TEXT NOT NULL,
category TEXT NOT NULL,
copies INTEGER NOT NULL
);
| TypeScript | PostgreSQL | Note |
|---|---|---|
string | TEXT | |
number | INTEGER / DECIMAL | SQL separates whole vs fractional β money wants DECIMAL |
boolean | BOOLEAN | |
string (ISO date) | TIMESTAMP / DATE | a real date type at last β the DB does date math natively |
id: number + Math.max trick | SERIAL PRIMARY KEY | the DB hands out ids β your empty-array guard retires |
books?
NOT NULL is that same guard, enforced by the database itself. Code can have bugs;
the table's rules hold anyway. Defense in depth: validate in the app for nice error messages,
constrain in the DB as the last wall.
Every query below is an array method you already own. Same intent, new grammar:
-- books (the whole array)
SELECT * FROM books;
-- books.filter(b => b.copies <= 2) (your low-stock filter)
SELECT * FROM books WHERE copies <= 2;
-- books.find(b => b.id === 3) (find by id)
SELECT * FROM books WHERE id = 3;
-- title search (your search box)
SELECT * FROM books WHERE title ILIKE '%harry%';
-- books.sort((a,b) => a.copies - b.copies) (your sortByCopies)
SELECT * FROM books ORDER BY copies;
-- count without fetching
SELECT COUNT(*) FROM books;
= here is CORRECT β don't panic
SQL uses a single = for comparison (WHERE id = 3) because SQL has a
different assignment syntax entirely. Your JS/TS rule ("single = in a condition is always wrong")
stays true in JS/TS β just don't drag it across the language border in either direction.
ILIKE = case-insensitive match; % = "anything here", like your
.includes() + .toLowerCase() combo.
filter hauls the whole array into your code, then discards most of it.
WHERE asks the database to send only matches β the discard happens where the data
lives. With a million rows, that's the difference between seconds and microseconds. Rule:
push the question to the data, don't pull the data to the question.
-- books.push({...}) (addBook)
INSERT INTO books (title, author, category, copies)
VALUES ('Harry Potter', 'J.K. Rowling', 'Fantasy', 5);
-- book.copies = book.copies - 1 (borrowBook)
UPDATE books SET copies = copies - 1 WHERE id = 3;
-- books.splice(index, 1) (deleteBook)
DELETE FROM books WHERE id = 3;
UPDATE books SET copies = 0; β no WHERE β sets EVERY row's copies to 0.
DELETE FROM books; empties the table. No confirmation, no undo outside a transaction.
Habit to build now: write the WHERE clause first, then walk back and type the
UPDATE/DELETE in front of it. (Phase 10's backups exist for the day someone doesn't.)
bookId was SQL all alongCapstone design decision, notes Β§13: a Loan stores bookId, never a copy of the book.
SQL not only agrees β it enforces it:
CREATE TABLE loans (
id SERIAL PRIMARY KEY,
book_id INTEGER NOT NULL REFERENCES books(id), -- β the foreign key
member_name TEXT NOT NULL,
borrowed_date TIMESTAMP NOT NULL DEFAULT now(),
due_date TIMESTAMP NOT NULL,
returned BOOLEAN NOT NULL DEFAULT false
);
bookId: 999 pointing at nothing β that's
why renderLoans needed the "(deleted book)" guard. With REFERENCES books(id),
Postgres refuses a loan whose book doesn't exist, and refuses to delete a book that
still has loans (unless told how to handle them). The dangling-pointer bug becomes impossible
instead of merely guarded. One book β many loans = a one-to-many relation β the
core shape of almost all business data.
renderLoans does this per loan: books.find(b => b.id === loan.bookId) to show a title.
That idβrow lookup across two collections has a SQL name β JOIN:
-- the loans table, but with each loan's book title attached
SELECT loans.id, books.title, loans.member_name, loans.due_date, loans.returned
FROM loans
JOIN books ON books.id = loans.book_id;
Read ON as your find-condition: "match rows where books.id equals
loans.book_id." The database stitches the rows together and sends the finished
combination β no loop in your code at all.
SELECT * FROM loans WHERE returned = false AND
due_date < now(); β the database compares real timestamps natively. The βΉ/day math still
belongs in your code (store facts, derive opinions β capstone rule unchanged).
You describe tables once, in schema.prisma β compare it to your interfaces:
model Book {
id Int @id @default(autoincrement())
title String
author String
category String
copies Int
loans Loan[] // β the "many" side, navigable
}
model Loan {
id Int @id @default(autoincrement())
book Book @relation(fields: [bookId], references: [id])
bookId Int
memberName String
borrowedDate DateTime @default(now())
dueDate DateTime
returned Boolean @default(false)
}
Then npx prisma migrate dev turns the schema into real tables (and tracks every change
as a migration β versioned history of your database's shape). Queries become typed method
calls:
// SELECT * FROM books WHERE copies <= 2
const lowStock = await prisma.book.findMany({ where: { copies: { lte: 2 } } });
// find by id β returns Book | null (your narrowing guard lives on!)
const book = await prisma.book.findUnique({ where: { id: 3 } });
// the JOIN, as a nested include
const loansWithBooks = await prisma.loan.findMany({ include: { book: true } });
// each loan now carries loan.book.title β renderLoans' find, pre-done
await β because the database is a phone call, not a variableawait
books.find(...) was instant β the array sat in memory. A database query leaves your
process, crosses to Postgres, and comes back: thousands of times slower than memory. JS refuses to
freeze while waiting; instead the call returns a Promise ("result, later") and
await pauses just this function until it arrives β the server keeps handling
other requests meanwhile. Functions that await are marked async. Fastify handlers can
be async, so this slots straight in.
await and nothing "errors": const book = prisma.book.findUnique(...)
just makes book a Promise object β every book.title is undefined. Silent
wrong values, JavaScript's favorite move. TS helps (Promise<Book> type mismatch), and the smell
to learn: a DB value used without an await nearby is always suspect.
| Step | What changes |
|---|---|
| 1. Install Postgres locally | a second always-running server on your machine (port 5432) |
| 2. Raw SQL in psql | create books/loans tables, practice SELECT/INSERT/UPDATE/JOIN by hand β feel the language before hiding it |
| 3. Prisma schema + migrate | schema.prisma describes the tables; migration creates them |
| 4. Fastify routes go async | each route swaps its array method for the matching prisma call |
| 5. The milestone | stop the server, restart it β the loans are still there. Data has outlived the process for the first time |
| Idea | One line to remember |
|---|---|
| Database | a server for data β port 5432, speaks SQL, survives restarts |
| Table / row / column | interface / one object / one field |
| WHERE / ORDER BY | filter / sort β executed AT the data, not after hauling it |
| Primary key | the row's id β the DB hands it out (SERIAL), your Math.max retires |
| Foreign key | your bookId, enforced β dangling pointers become impossible |
| JOIN | renderLoans' find-per-loan, done by the DB in one query |
| Prisma | schema once β real tables + TS types; queries are compile-checked |
| await | the data is a phone call away β mark the wait, don't freeze the server |