First Hono project, exploring the basic CRUD features: Postgres, Drizzle, Zod, Vitest, and pnpm. The app is a small API for products and carts — add a product to a cart, and it either finds your existing cart by session ID or creates one on the spot.

Source Code (GitHub): https://github.com/bbornino/hono_product_catalog


Tools & Tech Stack

  • Framework: Hono (Node.js runtime)
  • Language: TypeScript
  • Database: PostgreSQL 16 (Docker)
  • ORM: Drizzle ORM + drizzle-kit for migrations
  • Validation: Zod
  • Testing: Vitest — 6 tests, run against a live server and a live database
  • Package manager: pnpm

What It Does

$body = @{ sessionId = "session-abc"; productId = 1; quantity = 2 } | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:3000/cart/items -Method Post -Body $body -ContentType "application/json"
id cartId productId quantity
-- ------ --------- --------
 1      1         1        2

Call it again with the same sessionId and a different product, and the response reuses cartId: 1 instead of creating a second cart. Pull the cart back with GET /cart/session-abc and every item comes back joined with its live product name, price, and currency:

id quantity productId productName priceCents currency
-- -------- --------- ----------- ---------- --------
 1        2         1 Test Widget       1999      USD
 2        1         2 Widget Pro        2999      USD

Foreign Keys as a Real Safety Net, Not Just a Diagram

cart_items has two foreign keys — cart_id and product_id — and they’re not decorative. Try to insert a cart item pointing at a product that doesn’t exist, and Postgres itself refuses the write:

DrizzleQueryError: Failed query: insert into "cart_items" (...) values (...)
cause: PostgresError: insert or update on table "cart_items" violates foreign key
constraint "cart_items_product_id_products_id_fk"
detail: 'Key (product_id)=(9999) is not present in table "products".'
code: '23503'

That was a genuine, unhandled 500 the first time it happened — the wrong failure mode for an API to hand a client. The fix was an explicit existence check before the insert, so a bad productId now returns a clean 404 with a readable message instead of a raw database stack trace. The foreign key constraint still exists as the real safety net; the app-level check exists purely for a better error shape.

The two foreign keys also don’t behave the same way on delete, deliberately. Deleting a cart cascades to delete its cart items — a cart item makes no sense without its cart. Deleting a product does not cascade to any cart referencing it, since silently vanishing a product from someone’s cart history is a worse failure mode than a delete being blocked.


Money as Integer Cents, Never Floats

Every price is stored as an integer (priceCents) with an explicit currency column, not a float or double. Floating-point binary storage can’t represent most decimal fractions exactly — 19.99 doesn’t round-trip cleanly through IEEE 754 the way 1999 (an integer) always does. Formatting to "$19.99" and any future currency conversion are both left entirely to the presentation layer; the API only ever returns raw numbers.


Challenges

pnpm’s newer build-script approval gate. Fresh install, and the generator’s own dependency install step failed silently with [ERR_PNPM_IGNORED_BUILDS]. Not a broken install — pnpm v10+ blocks a package’s install-time build scripts (esbuild‘s native binary download, in this case) by default as a supply-chain safety measure. The fix, pnpm approve-builds, is a one-time step per machine.

A deleted migration file corrupted the next migration generation. After changing a foreign key’s onDelete behavior mid-build, an unapplied migration file got deleted by hand to regenerate it cleanly — but Drizzle-kit tracks migration state in its own drizzle/meta/ snapshot and journal files, independent of which .sql files exist on disk. Deleting the file without also cleaning up its snapshot entry left phantom state behind, and the next generate came back as a confusing ALTER TABLE ... DROP CONSTRAINT instead of a clean CREATE TABLE. The fix required manually editing _journal.json and deleting the orphaned snapshot file to get Drizzle’s bookkeeping back in sync with what had actually been applied to Postgres (nothing, yet).

A missing pair of parentheses broke type inference three call-frames away. z.number().int().positive (no ()) doesn’t call the method — it references the function itself, silently breaking the Zod chain. The visible symptom was a productId: unknown type error on a completely unrelated line, several statements later, where the value actually got used.

Seeding a foreign-keyed schema requires deleting in dependency order. The first version of the seed script did db.delete(products) before re-inserting fresh rows — and failed immediately, since existing cart_items rows still referenced those products. Fixed by deleting child tables first (cart_items, then carts, then products).

A “successful” script that never exited. The database pre-flight check script would run its query, print a success message, and then just… hang forever. The postgres driver keeps a connection pool open in the background — fine for a long-running server, fatal for a short-lived script, since Node’s event loop never goes idle waiting on a connection nothing will ever use again. Fixed with an explicit process.exit(0) on the success path, matching the process.exit(1) already present on the failure path.


Lessons Learned

  • An ORM’s migration bookkeeping is as real as the SQL it generates — deleting a migration file without also cleaning up its snapshot entry produces genuinely broken output on the next generation, not just a cosmetic inconsistency.
  • A database constraint and a good API error are two different jobs. Postgres enforcing a foreign key is necessary but not sufficient — a client still needs a clean 404, not a 500 with a stack trace.
  • Any short-lived script using a connection-pooling database driver needs an explicit exit on every code path, success included.

Project Structure

hono_product_catalog/
├── src/
│   ├── db/
│   │   ├── index.ts       ← Drizzle client instance
│   │   ├── schema.ts      ← products, carts, cart_items table definitions
│   │   └── seed.ts        ← idempotent 22-product seed script
│   ├── scripts/
│   │   ├── check-server.ts    ← pretest hook: confirms dev server is up
│   │   └── check-database.ts  ← preseed/pretest hook: confirms Postgres is reachable
│   ├── index.ts           ← all routes
│   └── index.test.ts      ← Vitest suite, 6 tests
├── drizzle/                ← generated SQL migrations + drizzle-kit bookkeeping
├── drizzle.config.ts
├── package.json
└── README.md

Test Suite

✓ src/index.test.ts (6 tests) 190ms
  ✓ Products API (3)
    ✓ GET /products returns an array
    ✓ POST /products creates a product with valid data
    ✓ POST /products rejects an empty name
  ✓ Cart API (3)
    ✓ POST /cart/items creates a cart and adds an item
    ✓ POST /cart/items rejects a nonexistent product
    ✓ GET /cart/:sessionId returns items with product details joined

Tests run against a real, running server and a real Postgres instance — no mocks — with automatic cleanup after every run via tracked IDs and cascading deletes.


Setup & Installation

git clone https://github.com/bbornino/hono_product_catalog.git
cd hono_product_catalog
pnpm install
pnpm approve-builds   # select esbuild
pnpm install

docker run --name pg16-hono -e POSTGRES_PASSWORD=postgres -p 5433:5432 -d postgres:16
docker exec -it pg16-hono psql -U postgres -c "CREATE DATABASE product_catalog;"

Create a .env file:

DATABASE_URL=postgres://postgres:postgres@localhost:5433/product_catalog
TEST_BASE_URL=http://localhost:3000
pnpm drizzle-kit migrate
pnpm seed
pnpm dev

Server runs at http://localhost:3000. Run pnpm test in a second terminal (the dev server needs to already be running).