πŸ“š Sanjay's Study Notes Home Roadmap HTML CSS JavaScript TypeScript Node SQL & Prisma React Tailwind Auth Apps Git & Deploy

06 Β· SQL, PostgreSQL & Prisma β€” data gets a real home

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.

Where the names come from SQL = Structured Query Language (1974, IBM) β€” a language for asking questions of data ("query" = question). PostgreSQL ("post-Ingres SQL", say "postgres") is a database program from 1996 β€” old, boring, bulletproof; it runs a huge share of the internet, including your Restaurants app. Prisma is the modern TypeScript bridge to it.

1. Why a JSON file isn't enough

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:

ProblemJSON fileDatabase
Two requests write at oncesecond write overwrites the first β€” data lost silentlywrites are queued & isolated (transactions)
Find one book among 1 millionread the WHOLE file, filter in JSindexed lookup β€” microseconds
Update 1 field of 1 bookrewrite the entire fileupdate exactly that row
Crash mid-writehalf-written file = corrupted everythingtransaction rolls back, data intact
Rules ("copies never negative")hope every code path remembersthe database itself refuses bad data
A database is a server, too Same shape as Β§5 of the Node notes: Postgres is a program that runs forever and answers requests β€” just on port 5432 instead of 3000, speaking SQL instead of HTTP. Your stack becomes a relay: browser β†’ (HTTP) β†’ Fastify β†’ (SQL) β†’ Postgres β€” and the answer travels back the same road. Every real app you use is this chain.

2. Tables β€” your interfaces, made of rows

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
);
TypeScriptPostgreSQLNote
stringTEXT
numberINTEGER / DECIMALSQL separates whole vs fractional β€” money wants DECIMAL
booleanBOOLEAN
string (ISO date)TIMESTAMP / DATEa real date type at last β€” the DB does date math natively
id: number + Math.max trickSERIAL PRIMARY KEYthe DB hands out ids β€” your empty-array guard retires
Why NOT NULL everywhere Remember your addBook validation β€” trim/empty checks so junk can't enter 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.

3. SELECT β€” filter, find and sort, spoken in SQL

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;
One = 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.
The deep difference: WHERE happens AT the data 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.

4. INSERT, UPDATE, DELETE β€” your CRUD, spoken in SQL

-- 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;
The most feared keystroke in the industry: forgetting WHERE 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.)

5. Foreign keys β€” your bookId was SQL all along

Capstone 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
);
What REFERENCES buys you In your TS app, nothing stopped a loan with 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.

6. JOIN β€” your renderLoans lookup, done by the database

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.

Overdue fines as a query Your calculateFine logic, data-side: 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).

7. Prisma β€” the TypeScript bridge

Why not write raw SQL strings in Fastify? You could β€” but SQL inside JS strings is invisible to TypeScript: typo a column and you find out at runtime, the exact darkness TS rescued you from in Phase 3. Prisma generates TypeScript types from your database schema β€” queries autocomplete, wrong fields are compile errors. This is the "Prisma-generated types" item from your gap list, and what your real projects use.

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

8. await β€” because the database is a phone call, not a variable

Why every Prisma call has await 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.
The classic first async bug β€” you'll write it, everyone does Forget 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.

9. The Phase 5 arc

StepWhat changes
1. Install Postgres locallya second always-running server on your machine (port 5432)
2. Raw SQL in psqlcreate books/loans tables, practice SELECT/INSERT/UPDATE/JOIN by hand β€” feel the language before hiding it
3. Prisma schema + migrateschema.prisma describes the tables; migration creates them
4. Fastify routes go asynceach route swaps its array method for the matching prisma call
5. The milestonestop the server, restart it β€” the loans are still there. Data has outlived the process for the first time

10. The mental model to keep

IdeaOne line to remember
Databasea server for data β€” port 5432, speaks SQL, survives restarts
Table / row / columninterface / one object / one field
WHERE / ORDER BYfilter / sort β€” executed AT the data, not after hauling it
Primary keythe row's id β€” the DB hands it out (SERIAL), your Math.max retires
Foreign keyyour bookId, enforced β€” dangling pointers become impossible
JOINrenderLoans' find-per-loan, done by the DB in one query
Prismaschema once β†’ real tables + TS types; queries are compile-checked
awaitthe data is a phone call away β€” mark the wait, don't freeze the server

← Prev: Node.js  Β·  Back to contents  Β·  Next: React β†’