[Full-stack]
BookLab
A full-stack book marketplace for buying and renting books, built with a production-grade monorepo architecture.
BookLab is a full-stack book marketplace for buying and renting books. The backend is the main focus — a production-ready Express API inside a pnpm monorepo with a hand-written PostgreSQL schema, Redis caching, dual-token JWT auth, trigram fuzzy search, environment validation, Docker, and a full Swagger spec. The Next.js frontend is ~40% complete and currently serves as a testing and integration surface for the API.
Monorepo Structure
The project is a pnpm workspace with four packages:
/
├── apps/
│ ├── web/ # Next.js 15 (App Router)
│ └── server/ # Express.js REST API
└── packages/
├── db/ # Shared PostgreSQL queries + Redis utilities
└── types/ # Shared TypeScript interfaces
@repo/types is the backbone. The same Book and BookWithDetails interfaces describe the
database row, the API response shape, and the frontend component props — one source of truth
with zero drift between layers:
// packages/types/src/types.ts
export type type Book = {
book_id: string;
title: string;
for_sale: boolean;
for_rent: boolean;
price_sale: string;
price_rent_daily: string | null;
price_rent_weekly: string | null;
price_rent_monthly: string | null;
stock_quantity: number;
reserved_quantity: number;
average_rating: string | null;
deleted_at: string | null;
}
Book = {
book_id: stringbook_id: string;
title: stringtitle: string;
for_sale: booleanfor_sale: boolean;
for_rent: booleanfor_rent: boolean;
price_sale: stringprice_sale: string; // Postgres NUMERIC returns as string
price_rent_daily: string | nullprice_rent_daily: string | null;
price_rent_weekly: string | nullprice_rent_weekly: string | null;
price_rent_monthly: string | nullprice_rent_monthly: string | null;
stock_quantity: numberstock_quantity: number;
reserved_quantity: numberreserved_quantity: number;
average_rating: string | nullaverage_rating: string | null;
deleted_at: string | nulldeleted_at: string | null; // soft-delete field
// ...35 fields total
};
// Extends Book with data from joined tables
export interface BookWithDetails extends type Book = {
book_id: string;
title: string;
for_sale: boolean;
for_rent: boolean;
price_sale: string;
price_rent_daily: string | null;
price_rent_weekly: string | null;
price_rent_monthly: string | null;
stock_quantity: number;
reserved_quantity: number;
average_rating: string | null;
deleted_at: string | null;
}
Book {
BookWithDetails.authors?: BookAuthor[] | undefinedauthors?: type BookAuthor = /*unresolved*/ anyBookAuthor[];
BookWithDetails.primary_category?: anyprimary_category?: type BookCategory = /*unresolved*/ anyBookCategory;
BookWithDetails.categories?: BookCategory[] | undefinedcategories?: type BookCategory = /*unresolved*/ anyBookCategory[];
BookWithDetails.genres?: BookGenre[] | undefinedgenres?: type BookGenre = /*unresolved*/ anyBookGenre[];
BookWithDetails.publisher?: anypublisher?: type BookPublisher = /*unresolved*/ anyBookPublisher;
}
Server Bootstrap & Security Headers
The Express server is configured with a strict middleware stack before any route runs.
helmet sets secure HTTP headers out of the box, CORS is locked to the two known origins,
and cookies + JSON bodies are parsed before reaching any handler:
// apps/server/src/server.ts
const const app: anyapp = express();
const app: anyapp.set('trust proxy', 1); // required when sitting behind Vercel/DO proxy
const const corsOptions: CorsOptionscorsOptions: type CorsOptions = /*unresolved*/ anyCorsOptions = {
origin: (origin: any, callback: any) => voidorigin: (origin: anyorigin, callback: anycallback) => {
const const allowed: string[]allowed = ['https://book-lab-web-f7jd.vercel.app', 'https://booklab.ddns.net'];
if (!origin: anyorigin || const allowed: string[]allowed.Array<string>.includes(searchElement: string, fromIndex?: number): booleanDetermines whether an array includes a certain element, returning true or false as appropriate.includes(origin: anyorigin)) callback: anycallback(null, true);
else callback: anycallback(new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('Not allowed by CORS'));
},
credentials: booleancredentials: true, // required to send cookies cross-origin
methods: stringmethods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
};
const app: anyapp.use(cors(const corsOptions: CorsOptionscorsOptions));
const app: anyapp.use(cookieParser());
const app: anyapp.use(helmet()); // X-Frame-Options, CSP, HSTS, etc.
const app: anyapp.use(express.json());
const app: anyapp.use(express.urlencoded({ extended: booleanextended: true }));
const app: anyapp.use(
session({
secret: stringsecret: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedSESSION_SECRET!,
resave: booleanresave: false,
saveUninitialized: booleansaveUninitialized: false,
cookie: {
secure: boolean;
httpOnly: boolean;
sameSite: string;
}
cookie: {
secure: booleansecure: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNODE_ENV === 'production',
httpOnly: booleanhttpOnly: true,
sameSite: stringsameSite: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNODE_ENV === 'production' ? 'none' : 'lax',
},
}),
);
Environment Validation at Startup
Before the server binds to a port it validates every required environment variable.
In production, a missing variable causes an immediate process.exit(1) — the server
refuses to start in a broken state rather than silently misbehaving at runtime:
// apps/server/src/utils/validateEnv.ts
export function function validateEnvironment(): voidvalidateEnvironment(): void {
const const required: string[]required = [
'CORS_ORIGIN',
'SESSION_SECRET',
'JWT_SECRET',
'JWT_REFRESH_SECRET',
'ACCESS_TOKEN_COOKIE_NAME',
'REFRESH_TOKEN_COOKIE_NAME',
'ACCESS_TOKEN_COOKIE_MAX_AGE',
'REFRESH_TOKEN_COOKIE_MAX_AGE',
];
const const missing: string[]missing = const required: string[]required.Array<string>.filter(predicate: (value: string, index: number, array: string[]) => unknown, thisArg?: any): string[] (+1 overload)Returns the elements of an array that meet the condition specified in a callback function.filter((v: stringv) => !var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env[v: stringv]);
if (const missing: string[]missing.Array<string>.length: numberGets or sets the length of the array. This is a number one higher than the highest index in the array.length > 0) {
var console: Consoleconsole.Console.error(...data: any[]): voidThe **`console.error()`** static method outputs a message to the console at the 'error' log level.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)error(`Missing required env vars: ${const missing: string[]missing.Array<string>.join(separator?: string): stringAdds all the elements of an array into a string, separated by the specified separator string.join(', ')}`);
if (var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNODE_ENV === 'production') var process: NodeJS.Processprocess.NodeJS.Process.exit(code?: number | string | null): neverThe `process.exit()` method instructs Node.js to terminate the process
synchronously with an exit status of `code`. If `code` is omitted, exit uses
either the 'success' code `0` or the value of `process.exitCode` if it has been
set. Node.js will not terminate until all the `'exit'` event listeners are
called.
To exit with a 'failure' code:
```js
import { exit } from 'node:process';
exit(1);
```
The shell that executed Node.js should see the exit code as `1`.
Calling `process.exit()` will force the process to exit as quickly as possible
even if there are still asynchronous operations pending that have not yet
completed fully, including I/O operations to `process.stdout` and `process.stderr`.
In most situations, it is not actually necessary to call `process.exit()` explicitly. The Node.js process will exit on its own _if there is no additional_
_work pending_ in the event loop. The `process.exitCode` property can be set to
tell the process which exit code to use when the process exits gracefully.
For instance, the following example illustrates a _misuse_ of the `process.exit()` method that could lead to data printed to stdout being
truncated and lost:
```js
import { exit } from 'node:process';
// This is an example of what *not* to do:
if (someConditionNotMet()) {
printUsageToStdout();
exit(1);
}
```
The reason this is problematic is because writes to `process.stdout` in Node.js
are sometimes _asynchronous_ and may occur over multiple ticks of the Node.js
event loop. Calling `process.exit()`, however, forces the process to exit _before_ those additional writes to `stdout` can be performed.
Rather than calling `process.exit()` directly, the code _should_ set the `process.exitCode` and allow the process to exit naturally by avoiding
scheduling any additional work for the event loop:
```js
import process from 'node:process';
// How to properly set the exit code while letting
// the process exit gracefully.
if (someConditionNotMet()) {
printUsageToStdout();
process.exitCode = 1;
}
```
If it is necessary to terminate the Node.js process due to an error condition,
throwing an _uncaught_ error and allowing the process to terminate accordingly
is safer than calling `process.exit()`.
In `Worker` threads, this function stops the current thread rather
than the current process.exit(1);
}
// Warn if the session secret is too short to be secure
if (var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedSESSION_SECRET && var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.stringSESSION_SECRET.String.length: numberReturns the length of a String object.length < 32) {
var console: Consoleconsole.Console.warn(...data: any[]): voidThe **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static)warn('SESSION_SECRET should be at least 32 characters');
}
const const port: string | undefinedport = var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedPORT;
if (const port: string | undefinedport && (function isNaN(number: number): booleanReturns a Boolean value that indicates whether a value is the reserved value NaN (not a number).isNaN(var Number: NumberConstructor
(value?: any) => number
An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.Number(const port: stringport)) || var Number: NumberConstructor
(value?: any) => number
An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.Number(const port: stringport) < 1 || var Number: NumberConstructor
(value?: any) => number
An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.Number(const port: stringport) > 65535)) {
var console: Consoleconsole.Console.error(...data: any[]): voidThe **`console.error()`** static method outputs a message to the console at the 'error' log level.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)error('PORT must be a valid number between 1 and 65535');
var process: NodeJS.Processprocess.NodeJS.Process.exit(code?: number | string | null): neverThe `process.exit()` method instructs Node.js to terminate the process
synchronously with an exit status of `code`. If `code` is omitted, exit uses
either the 'success' code `0` or the value of `process.exitCode` if it has been
set. Node.js will not terminate until all the `'exit'` event listeners are
called.
To exit with a 'failure' code:
```js
import { exit } from 'node:process';
exit(1);
```
The shell that executed Node.js should see the exit code as `1`.
Calling `process.exit()` will force the process to exit as quickly as possible
even if there are still asynchronous operations pending that have not yet
completed fully, including I/O operations to `process.stdout` and `process.stderr`.
In most situations, it is not actually necessary to call `process.exit()` explicitly. The Node.js process will exit on its own _if there is no additional_
_work pending_ in the event loop. The `process.exitCode` property can be set to
tell the process which exit code to use when the process exits gracefully.
For instance, the following example illustrates a _misuse_ of the `process.exit()` method that could lead to data printed to stdout being
truncated and lost:
```js
import { exit } from 'node:process';
// This is an example of what *not* to do:
if (someConditionNotMet()) {
printUsageToStdout();
exit(1);
}
```
The reason this is problematic is because writes to `process.stdout` in Node.js
are sometimes _asynchronous_ and may occur over multiple ticks of the Node.js
event loop. Calling `process.exit()`, however, forces the process to exit _before_ those additional writes to `stdout` can be performed.
Rather than calling `process.exit()` directly, the code _should_ set the `process.exitCode` and allow the process to exit naturally by avoiding
scheduling any additional work for the event loop:
```js
import process from 'node:process';
// How to properly set the exit code while letting
// the process exit gracefully.
if (someConditionNotMet()) {
printUsageToStdout();
process.exitCode = 1;
}
```
If it is necessary to terminate the Node.js process due to an error condition,
throwing an _uncaught_ error and allowing the process to terminate accordingly
is safer than calling `process.exit()`.
In `Worker` threads, this function stops the current thread rather
than the current process.exit(1);
}
}
Consistent API Responses
Every response — success or error — goes through a single responseHandler module.
Controllers never call res.status(200).json(...) directly. This guarantees that every
response in the entire API has the same shape, which makes the frontend and Swagger spec
much easier to reason about:
// apps/server/src/utils/responseHandler.ts
interface interface ApiResponse<T = unknown>ApiResponse<function (type parameter) T in ApiResponse<T = unknown>T = unknown> {
ApiResponse<T = unknown>.success: booleansuccess: boolean;
ApiResponse<T = unknown>.message?: string | undefinedmessage?: string;
ApiResponse<T = unknown>.data?: T | undefineddata?: function (type parameter) T in ApiResponse<T = unknown>T;
ApiResponse<T = unknown>.error?: string | undefinederror?: string;
ApiResponse<T = unknown>.code?: string | undefinedcode?: string;
ApiResponse<T = unknown>.meta?: Record<string, unknown> | undefinedmeta?: type Record<K extends keyof any, T> = { [P in K]: T; }Construct a type with a set of properties K of type TRecord<string, unknown>;
}
// Success: { success: true, data: ..., message?: ..., meta?: ... }
export const const sendSuccess: <T>(res: Response, data: T, message?: string, statusCode?: number, meta?: Record<string, unknown>) => ResponsesendSuccess = <function (type parameter) T in <T>(res: Response, data: T, message?: string, statusCode?: number, meta?: Record<string, unknown>): ResponseT>(
res: Responseres: Response,
data: Tdata: function (type parameter) T in <T>(res: Response, data: T, message?: string, statusCode?: number, meta?: Record<string, unknown>): ResponseT,
message: string | undefinedmessage?: string,
statusCode: numberstatusCode = 200,
meta: Record<string, unknown> | undefinedmeta?: type Record<K extends keyof any, T> = { [P in K]: T; }Construct a type with a set of properties K of type TRecord<string, unknown>,
): Response => res: Responseres.Response.status: numberThe **`status`** read-only property of the Response interface contains the HTTP status codes of the response.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status)status(statusCode: numberstatusCode).json({ success: booleansuccess: true, message: string | undefinedmessage, data: Tdata, meta: Record<string, unknown> | undefinedmeta });
// Error: { success: false, error: ..., code: ..., message?: ... }
export const const sendError: (res: Response, error: string, code: string, statusCode?: number, message?: string) => ResponsesendError = (
res: Responseres: Response,
error: stringerror: string,
code: stringcode: string,
statusCode: numberstatusCode = 500,
message: string | undefinedmessage?: string,
): Response => res: Responseres.Response.status: numberThe **`status`** read-only property of the Response interface contains the HTTP status codes of the response.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status)status(statusCode: numberstatusCode).json({ success: booleansuccess: false, error: stringerror, code: stringcode, message: string | undefinedmessage });
// 201 shorthand for resource creation
export const const sendCreated: <T>(res: Response, data: T, message?: string) => ResponsesendCreated = <function (type parameter) T in <T>(res: Response, data: T, message?: string): ResponseT>(res: Responseres: Response, data: Tdata: function (type parameter) T in <T>(res: Response, data: T, message?: string): ResponseT, message: stringmessage = 'Resource created successfully') =>
const sendSuccess: <T>(res: Response, data: T, message?: string, statusCode?: number, meta?: Record<string, unknown>) => ResponsesendSuccess(res: Responseres, data: Tdata, message: stringmessage, 201);
A controller that uses this pattern is completely consistent — no ad-hoc status codes:
// apps/server/src/features/book/controllers/bookControllers.ts
const const getBookById: (req: Request, res: Response) => Promise<Response>getBookById = async (req: Requestreq: Request, res: Responseres: Response): interface Promise<T>Represents the completion of an asynchronous operationPromise<Response> => {
try {
const { const params: anyparams } = getBookByIdSchema.parse({ params: anyparams: req: Requestreq.params });
const const book: anybook = await bookService.getBookById(const params: anyparams.id);
if (!const book: anybook) {
return sendError(res: Responseres, `Book '${const params: anyparams.id}' not found`, 'BOOK_NOT_FOUND', 404);
}
return sendSuccess(res: Responseres, { book: anybook });
} catch (function (local var) error: unknownerror) {
if (function (local var) error: unknownerror instanceof ZodError) {
return sendError(res: Responseres, 'Validation failed', 'VALIDATION_ERROR', 400);
}
return sendError(res: Responseres, 'Failed to get book', 'INTERNAL_SERVER_ERROR', 500);
}
};
Feature Module Architecture
The server is split into feature modules — auth, book, user, newsletter, wishlist.
Each module owns its own controllers/, routes/, middleware/, and validation/ folders.
No feature knows about another feature's internals.
apps/server/src/features/
├── auth/
│ ├── controllers/
│ │ ├── loginController.ts
│ │ ├── signupController.ts
│ │ ├── meController.ts
│ │ ├── logoutController.ts
│ │ └── refreshController.ts
│ ├── middlewares/
│ │ ├── authenticate.ts
│ │ ├── authRateLimit.ts
│ │ └── validate.ts
│ ├── routes/
│ │ └── index.ts
│ └── validation/
│ ├── logInValidation.ts
│ └── signUpValidation.ts
├── book/
│ ├── controllers/
│ │ ├── bookControllers.ts # single-book endpoints
│ │ ├── booksListControllers.ts # list/filter endpoints
│ │ └── searchControllers.ts # search endpoints
│ ├── services/
│ │ └── bookService.ts # cache-aside logic lives here
│ ├── middleware/
│ │ └── validate.ts
│ ├── routes/
│ │ └── index.ts
│ └── validation/
│ └── booksControllerValidations.ts
└── user/ newsletter/ wishlist/ ...
Controllers Stay Thin
Controllers do exactly three things: validate input, call a service or query, return a
response. Business logic and data access are pushed down into services and the @repo/db
package. Here is the full signup controller — it delegates hashing and DB writes to helpers
and only handles the HTTP layer:
// apps/server/src/features/auth/controllers/signupController.ts
export const const signUpController: (req: Request, res: Response) => Promise<void>signUpController = async (req: Requestreq: Request, res: Responseres: Response): interface Promise<T>Represents the completion of an asynchronous operationPromise<void> => {
try {
const { const email: SignupRequestemail, const username: SignupRequestusername, const password: SignupRequestpassword }: type SignupRequest = /*unresolved*/ anySignupRequest['body'] = req: Requestreq.Body.body: ReadableStream<Uint8Array<ArrayBuffer>> | null[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body)body;
// Validation already ran in middleware — body is guaranteed clean here
const const hashedPassword: anyhashedPassword = hashPassword(const password: SignupRequestpassword); // bcrypt, sync with salt
// Domain logic isolated to a helper — checks duplicates, throws typed errors
const const newUser: anynewUser = await createUser(const username: SignupRequestusername, const email: SignupRequestemail, const hashedPassword: anyhashedPassword);
const { const accessToken: anyaccessToken, const refreshToken: anyrefreshToken } = generateToken({
id: anyid: const newUser: anynewUser.user_id,
email: anyemail: const newUser: anynewUser.email,
username: anyusername: const newUser: anynewUser.username!,
});
setAuthCookies(res: Responseres, const accessToken: anyaccessToken, const refreshToken: anyrefreshToken);
sendCreated(
res: Responseres,
{
user: {
id: any;
username: any;
email: any;
}
user: { id: anyid: const newUser: anynewUser.user_id, username: anyusername: const newUser: anynewUser.username, email: anyemail: const newUser: anynewUser.email },
accessToken: anyaccessToken,
},
'User created successfully',
);
} catch (function (local var) error: unknownerror) {
if (function (local var) error: unknownerror instanceof ZodError)
return sendError(res: Responseres, 'Validation failed', 'VALIDATION_ERROR', 400);
if (function (local var) error: unknownerror instanceof SignUpError)
return sendError(res: Responseres, function (local var) error: unknownerror.message, function (local var) error: unknownerror.code!, function (local var) error: unknownerror.statusCode);
sendError(res: Responseres, 'Internal server error', 'INTERNAL_ERROR', 500);
}
};
Typed Domain Errors
Controllers throw typed error classes instead of plain Error objects. This means the
catch block can pattern-match precisely and return the right HTTP status without
if (error.message.includes("...")) checks:
// apps/server/src/features/auth/controllers/signupController.ts
class class SignUpErrorSignUpError extends var Error: ErrorConstructorError {
constructor(
public SignUpError.statusCode: numberstatusCode: number,
message: stringmessage: string,
public SignUpError.code?: string | undefinedcode?: string,
) {
super(message: stringmessage);
this.Error.name: stringname = 'SignUpError';
}
}
const const createUser: (username: string, email: string, hashedPassword: string) => Promise<any>createUser = async (username: stringusername: string, email: stringemail: string, hashedPassword: stringhashedPassword: string) => {
const const existingEmail: anyexistingEmail = await userQueries.findByEmail(email: stringemail);
if (const existingEmail: anyexistingEmail)
throw new constructor SignUpError(statusCode: number, message: string, code?: string | undefined): SignUpErrorSignUpError(409, 'User with this email already exists', 'EMAIL_EXISTS');
const const existingUsername: anyexistingUsername = await userQueries.findByUsername(username: stringusername);
if (const existingUsername: anyexistingUsername)
throw new constructor SignUpError(statusCode: number, message: string, code?: string | undefined): SignUpErrorSignUpError(409, 'User with this username already exists', 'USERNAME_EXISTS');
return await userQueries.create({ username: stringusername, email: stringemail, hashedPassword: stringhashedPassword });
};
The same pattern appears in the login controller with its own LoginError:
// apps/server/src/features/auth/controllers/loginController.ts
class class LoginErrorLoginError extends var Error: ErrorConstructorError {
constructor(
public LoginError.statusCode: numberstatusCode: number,
message: stringmessage: string,
public LoginError.code?: string | undefinedcode?: string,
) {
super(message: stringmessage);
}
}
const const authenticateUser: (username: string, password: string) => Promise<{
id: any;
username: any;
email: any;
}>
authenticateUser = async (username: stringusername: string, password: stringpassword: string) => {
const const user: anyuser = await userQueries.findByUsername(username: stringusername);
if (!const user: anyuser) throw new constructor LoginError(statusCode: number, message: string, code?: string | undefined): LoginErrorLoginError(400, 'User not found!', 'USER_NOT_FOUND');
if (!comparerPassword(password: stringpassword, const user: anyuser.hashed_password)) {
throw new constructor LoginError(statusCode: number, message: string, code?: string | undefined): LoginErrorLoginError(401, 'Password is incorrect', 'INVALID_PASSWORD');
}
return { id: anyid: const user: anyuser.user_id, username: anyusername: const user: anyuser.username, email: anyemail: const user: anyuser.email };
};
Route Composition
Routes compose middleware in an explicit pipeline — the order is intentional: rate limit → validate → controller. A request that fails rate limiting never reaches validation. A request that fails validation never reaches the controller:
// apps/server/src/features/auth/routes/index.ts
const const authRouter: anyauthRouter = express.Router();
const authRouter: anyauthRouter.post(
'/signup',
authRateLimits.signup, // 1. 3 attempts per 15 min
validate(signupSchema), // 2. Zod validation
signUpController, // 3. Controller (only runs if both above pass)
);
const authRouter: anyauthRouter.post('/login', validate(loginSchema), loginController);
const authRouter: anyauthRouter.get('/me', authenticate, meController);
const authRouter: anyauthRouter.post('/logout', authenticate, logoutController);
const authRouter: anyauthRouter.post('/refresh', refreshController);
Security
Password Hashing
Passwords are hashed with bcrypt using 10 salt rounds. The synchronous variants are used intentionally here — the async version offers no real benefit in this context and the sync API is simpler to reason about:
// apps/server/src/utils/hashPassword.ts
const const SALT_ROUNDS: 10SALT_ROUNDS = 10;
export const const hashPassword: (password: string) => stringhashPassword = (password: stringpassword: string): string => {
const const salt: anysalt = bcrypt.genSaltSync(const SALT_ROUNDS: 10SALT_ROUNDS);
return bcrypt.hashSync(password: stringpassword, const salt: anysalt);
};
export const const comparerPassword: (password: string, hash: string) => booleancomparerPassword = (password: stringpassword: string, hash: stringhash: string): boolean =>
bcrypt.compareSync(password: stringpassword, hash: stringhash);
Role-Based Access Control
The authorizeAdmin middleware is composed on top of authenticate. Routes that need
admin access chain both — a request must be authenticated and carry the admin role:
// apps/server/src/features/auth/middlewares/authenticate.ts
export const const authorizeAdmin: (req: Request, res: Response, next: NextFunction) => voidauthorizeAdmin = (req: Requestreq: Request, res: Responseres: Response, next: NextFunctionnext: type NextFunction = /*unresolved*/ anyNextFunction): void => {
if (!req: Requestreq.user) {
return sendError(res: Responseres, 'Unauthorized', 'UNAUTHORIZED', 401);
}
if (req: Requestreq.user.role !== 'admin') {
return sendError(res: Responseres, 'Insufficient permissions', 'INSUFFICIENT_PERMISSIONS', 403);
}
next: NextFunctionnext();
};
// Usage in user routes:
userRouter.get('/', authenticate, const authorizeAdmin: (req: Request, res: Response, next: NextFunction) => voidauthorizeAdmin, listUsers);
userRouter.put('/:id', authenticate, const authorizeAdmin: (req: Request, res: Response, next: NextFunction) => voidauthorizeAdmin, adminUpdateUser);
userRouter.delete('/:id', authenticate, const authorizeAdmin: (req: Request, res: Response, next: NextFunction) => voidauthorizeAdmin, adminDeleteUser);
Sensitive Data Never Leaves the Server
The hashed_password field is stripped before any user object is sent in a response.
This is handled explicitly in every controller that returns user data — it's never
left to chance or serializer config:
// apps/server/src/features/user/controllers/userController.ts
export const const getUserById: (req: Request, res: Response) => Promise<any>getUserById = async (req: Requestreq: Request, res: Responseres: Response) => {
const const user: anyuser = await userQueries.findById(req: Requestreq.params.id);
if (!const user: anyuser) return sendError(res: Responseres, 'User not found', 'USER_NOT_FOUND', 404);
// Destructure hashed_password out — it is never included in the response
const { const hashed_password: anyhashed_password, const email: anyemail, ...const publicUser: anypublicUser } = const user: anyuser;
return sendSuccess(res: Responseres, { user: anyuser: const publicUser: anypublicUser });
};
Token Refresh
The refresh controller verifies the refresh token, looks up the user to confirm they still exist, then issues a fresh access/refresh token pair. The old refresh token is replaced — sliding window expiry:
// apps/server/src/features/auth/controllers/refreshController.ts
const const refreshController: (req: Request, res: Response) => Promise<any>refreshController = async (req: Requestreq: Request, res: Responseres: Response) => {
try {
const const refreshToken: anyrefreshToken = req: Requestreq.cookies[var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedREFRESH_TOKEN_COOKIE_NAME!];
if (!const refreshToken: anyrefreshToken)
return sendError(res: Responseres, 'Refresh token is missing!', 'MISSING_REFRESH_TOKEN', 400);
const const decoded: anydecoded: any = jwt.verify(const refreshToken: anyrefreshToken, var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedJWT_REFRESH_SECRET!);
const const user: anyuser = await userQueries.findById(const decoded: anydecoded.id);
if (!const user: anyuser) return sendError(res: Responseres, 'User not found', 'USER_NOT_FOUND', 401);
const const tokenPayload: {
id: any;
email: any;
username: any;
}
tokenPayload = { id: anyid: const user: anyuser.user_id, email: anyemail: const user: anyuser.email, username: anyusername: const user: anyuser.username };
// Issue a fresh pair — old refresh token is effectively invalidated by replacement
const const access_token: anyaccess_token = jwt.sign(const tokenPayload: {
id: any;
email: any;
username: any;
}
tokenPayload, var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedJWT_SECRET!, { expiresIn: stringexpiresIn: '15m' });
const const refresh_token: anyrefresh_token = jwt.sign({ id: anyid: const user: anyuser.user_id }, var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedJWT_REFRESH_SECRET!, {
expiresIn: stringexpiresIn: '7d',
});
res: Responseres.cookie(var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedREFRESH_TOKEN_COOKIE_NAME!, const refresh_token: anyrefresh_token, {
secure: booleansecure: true,
httpOnly: booleanhttpOnly: true,
sameSite: stringsameSite: 'none',
});
res: Responseres.cookie(var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedACCESS_TOKEN_COOKIE_NAME!, const access_token: anyaccess_token, {
secure: booleansecure: true,
httpOnly: booleanhttpOnly: true,
sameSite: stringsameSite: 'none',
});
return sendSuccess(res: Responseres, { access_token: anyaccess_token, user: {
id: any;
email: any;
username: any;
}
user: const tokenPayload: {
id: any;
email: any;
username: any;
}
tokenPayload });
} catch (function (local var) error: unknownerror) {
if (function (local var) error: unknownerror instanceof jwt.TokenExpiredError)
return sendError(res: Responseres, 'Refresh token expired', 'TOKEN_EXPIRED', 401);
if (function (local var) error: unknownerror instanceof jwt.JsonWebTokenError)
return sendError(res: Responseres, 'Invalid refresh token', 'INVALID_TOKEN', 401);
return sendError(res: Responseres, 'Internal server error', 'INTERNAL_ERROR', 500);
}
};
Request Validation — Zod Schemas
Input validation is entirely schema-driven. Every route has a matching Zod schema that
validates body, query, and params together. Book query parameters use z.coerce
to convert query string values (which are always strings) into the correct types:
// apps/server/src/features/book/validation/booksControllerValidations.ts
export const const getBookByIdSchema: anygetBookByIdSchema = z.object({
params: anyparams: z.object({
id: anyid: z.uuid(), // rejects any non-UUID immediately
}),
});
export const const getTopRatedBooksSchema: anygetTopRatedBooksSchema = z.object({
query: anyquery: z.object({
// z.coerce.number() converts "10" → 10 from the query string
limit: anylimit: z.coerce.number().min(1).max(100).default(50).optional(),
minRating: anyminRating: z.coerce.number().min(0).max(5).default(4.0).optional(),
}),
});
export const const searchBooksByNameSchema: anysearchBooksByNameSchema = z.object({
query: anyquery: z.object({
q: anyq: z.string().min(1, { message: stringmessage: 'Search query is required' }),
}),
});
The validate middleware applies the schema and extracts a human-readable error message
from Zod's issue list before responding:
// apps/server/src/features/auth/middlewares/validate.ts
export const const validate: (schema: ZodType<any>) => (req: Request, res: Response, next: NextFunction) => Promise<any>validate =
(schema: ZodType<any>schema: type ZodType = /*unresolved*/ anyZodType<any>) => async (req: Requestreq: Request, res: Responseres: Response, next: NextFunctionnext: type NextFunction = /*unresolved*/ anyNextFunction) => {
try {
await schema: ZodType<any>schema.parseAsync({ body: ReadableStream<Uint8Array<ArrayBuffer>> | nullbody: req: Requestreq.Body.body: ReadableStream<Uint8Array<ArrayBuffer>> | null[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body)body, query: anyquery: req: Requestreq.query, params: anyparams: req: Requestreq.params });
next: NextFunctionnext();
} catch (function (local var) error: unknownerror) {
if (function (local var) error: unknownerror instanceof ZodError) {
const const message: anymessage = function (local var) error: unknownerror.issues.map((i: anyi) => i: anyi.message).join(', ');
return sendError(res: Responseres, 'Validation error', 'VALIDATION_ERROR', 400, const message: anymessage);
}
return sendError(res: Responseres, 'Validation error', 'VALIDATION_ERROR', 400);
}
};
Database — 18 Migrations, No ORM
The schema is built from 18 sequential hand-written .sql files. Every constraint,
index, and trigger is explicit and version-controlled.
Business Rules at the Database Layer
The books table enforces a domain rule at the constraint level — if a book is
marked for_rent, it must have at least one rental price. This is guaranteed
regardless of which code path wrote the row:
-- packages/db/src/postgres/migrations/008_create_books_table.sql
CREATE TABLE IF NOT EXISTS books (
book_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(500) NOT NULL,
for_sale BOOLEAN DEFAULT true,
for_rent BOOLEAN DEFAULT false,
price_sale NUMERIC(10,2) NOT NULL,
price_rent_daily NUMERIC(8,2),
price_rent_weekly NUMERIC(8,2),
price_rent_monthly NUMERIC(8,2),
stock_quantity INTEGER DEFAULT 0,
reserved_quantity INTEGER DEFAULT 0,
average_rating NUMERIC(3,2),
deleted_at TIMESTAMP NULL, -- soft delete
CONSTRAINT chk_books_rental_pricing CHECK (
(for_rent = false) OR
(for_rent = true AND (
price_rent_daily IS NOT NULL OR
price_rent_weekly IS NOT NULL OR
price_rent_monthly IS NOT NULL
))
),
CONSTRAINT chk_books_rating_range CHECK (
average_rating >= 0 AND average_rating <= 5
)
);
The book_authors join table tracks co-author order and role, so a book can have
a primary author, a translator, and an illustrator as separate credited entries:
-- 009_create_book_authors_table.sql
CREATE TABLE IF NOT EXISTS book_authors (
book_id UUID NOT NULL,
author_id UUID NOT NULL,
role VARCHAR(50) DEFAULT 'author', -- 'editor', 'translator', 'illustrator'
order_index INTEGER DEFAULT 1, -- display order on the cover
CONSTRAINT pk_book_authors PRIMARY KEY (book_id, author_id),
CONSTRAINT fk_book_authors_book FOREIGN KEY (book_id) REFERENCES books(book_id) ON DELETE CASCADE,
CONSTRAINT fk_book_authors_author FOREIGN KEY (author_id) REFERENCES authors(author_id) ON DELETE CASCADE,
CONSTRAINT chk_order_positive CHECK (order_index > 0)
);
A single trigger function defined in migration 001 handles updated_at across
all tables — one definition, applied everywhere:
-- 001_db_setup.sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Applied in each subsequent migration:
CREATE TRIGGER update_books_updated_at
BEFORE UPDATE ON books
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Single Query Across Six Tables — No N+1
The book detail endpoint needs data from books, publishers, authors,
book_authors, categories, book_categories, genres, and book_genres.
Rather than fetching each in a separate query, everything is resolved in one
using json_agg with DISTINCT and FILTER:
// packages/db/src/postgres/queries/books.ts — findById (condensed)
const const result: anyresult = await db.query(
`SELECT
b.*,
-- Authors ordered by credited position on the cover
COALESCE(
json_agg(
jsonb_build_object(
'author_id', a.author_id,
'first_name', a.first_name,
'last_name', a.last_name,
'role', ba.role,
'order_index', ba.order_index
) ORDER BY ba.order_index
) FILTER (WHERE a.author_id IS NOT NULL),
'[]'::json
) AS authors,
-- DISTINCT prevents duplicate rows from the categories cross-join
COALESCE(
json_agg(
DISTINCT jsonb_build_object(
'category_id', c.category_id,
'category_name', c.category_name
)
) FILTER (WHERE c.category_id IS NOT NULL),
'[]'::json
) AS categories,
COALESCE(
json_agg(
DISTINCT jsonb_build_object(
'genre_id', g.genre_id,
'genre_name', g.genre_name
)
) FILTER (WHERE g.genre_id IS NOT NULL),
'[]'::json
) AS genres,
p.publisher_name, p.founded_year, p.country AS publisher_country
FROM books b
LEFT JOIN publishers p ON b.publisher_id = p.publisher_id
LEFT JOIN book_authors ba ON b.book_id = ba.book_id
LEFT JOIN authors a ON ba.author_id = a.author_id
LEFT JOIN book_categories bc ON b.book_id = bc.book_id
LEFT JOIN categories c ON bc.category_id = c.category_id AND c.is_active = true
LEFT JOIN book_genres bg ON b.book_id = bg.book_id
LEFT JOIN genres g ON bg.genre_id = g.genre_id AND g.is_active = true
WHERE b.book_id = $1
GROUP BY b.book_id, p.publisher_id`,
[bookId],
);
After the query the flat result row is remapped into nested objects before
being returned as the typed BookWithDetails shape:
const const book: anybook = result.rows[0];
const const primary_category: {
category_id: any;
category_name: any;
} | undefined
primary_category = const book: anybook.primary_category_id
? {
category_id: anycategory_id: const book: anybook.primary_category_id,
category_name: anycategory_name: const book: anybook.primary_category_name,
}
: var undefinedundefined;
const const publisher: {
publisher_id: any;
publisher_name: any;
country: any;
founded_year: any;
} | undefined
publisher = const book: anybook.publisher_id
? {
publisher_id: anypublisher_id: const book: anybook.publisher_id,
publisher_name: anypublisher_name: const book: anybook.publisher_name,
country: anycountry: const book: anybook.publisher_country,
founded_year: anyfounded_year: const book: anybook.founded_year,
}
: var undefinedundefined;
return {
...const book: anybook,
primary_category: {
category_id: any;
category_name: any;
} | undefined
primary_category,
publisher: {
publisher_id: any;
publisher_name: any;
country: any;
founded_year: any;
} | undefined
publisher,
authors: anyauthors: const book: anybook.authors || [],
categories: anycategories: const book: anybook.categories || [],
genres: anygenres: const book: anybook.genres || [],
} as type BookWithDetails = /*unresolved*/ anyBookWithDetails;
Fuzzy Search — pg_trgm, No External Service
Search runs entirely inside PostgreSQL using the pg_trgm extension. Migration 014
creates GIN trigram indexes on title, subtitle, and a combined expression index
that handles queries spanning both fields:
-- 014_add_fuzzy_search_indexes_books.sql
CREATE INDEX idx_books_title_gin_trgm
ON books USING gin (title gin_trgm_ops);
-- Expression index: "Order Phoenix" can match "Harry Potter and the Order of the Phoenix"
CREATE INDEX idx_books_title_subtitle_gin_trgm
ON books USING gin (
(COALESCE(title, '') || ' ' || COALESCE(subtitle, '')) gin_trgm_ops
);
-- Author search shares the same infrastructure
CREATE INDEX idx_authors_full_name_gin_trgm
ON authors USING gin (
(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')) gin_trgm_ops
);
The search query runs a scored CTE. Each candidate gets a score from GREATEST(...)
across four strategies — exact match always outranks a fuzzy hit:
// packages/db/src/postgres/queries/books.ts — findBooksByName (condensed)
`WITH scored_books AS (
SELECT
b.*,
GREATEST(
-- 1. Exact title/subtitle match
CASE WHEN LOWER(b.title) = $3 THEN 1.0
WHEN LOWER(b.subtitle) = $3 THEN 0.95 ELSE 0 END,
-- 2. Prefix match (user typed the beginning)
CASE WHEN LOWER(b.title) LIKE $3 || '%' THEN 0.85
WHEN LOWER(b.subtitle) LIKE $3 || '%' THEN 0.8 ELSE 0 END,
-- 3. Substring match
CASE WHEN LOWER(b.title) LIKE '%' || $3 || '%' THEN 0.75
WHEN LOWER(b.subtitle) LIKE '%' || $3 || '%' THEN 0.7 ELSE 0 END,
-- 4. Trigram similarity — tolerates typos like "gret gatsbby"
COALESCE(similarity(LOWER(b.title), $3), 0) * 0.8
) AS similarity_score,
-- Shorter titles get a small boost: "1984" more likely exact than a long subtitle
CASE WHEN LENGTH(b.title) <= 50 THEN 0.05 ELSE 0 END AS length_bonus
FROM books b
WHERE b.is_active = true
AND (
LOWER(b.title) LIKE '%' || $3 || '%'
OR LOWER(b.title) % $3 -- pg_trgm % operator
OR similarity(LOWER(b.title), $3) > 0.2
)
GROUP BY b.book_id, ...
)
SELECT * FROM scored_books
WHERE (similarity_score + length_bonus) >= 0.08
ORDER BY (similarity_score + length_bonus) DESC, LENGTH(title) ASC
LIMIT $2`;
Redis Caching — Cache-Aside Pattern
Every book list endpoint goes through a cache check in the service layer before
hitting PostgreSQL. Cache keys encode query parameters so different configurations
are stored independently — books:top-rated:10:4.0 and books:top-rated:50:3.5
never share a cache entry:
// packages/db/src/postgres/queries/books.ts — bookService
const const CACHE_TTL: 60CACHE_TTL = 60; // seconds
async getTopRatedBooks(limit: number, minRating: number): var Promise: PromiseConstructorRepresents the completion of an asynchronous operationPromise<BookWithDetails[]> {
const: stringconst cacheKey = `books:top-rated:${limit}:${minRating}`;
const: stringconst redis = getRedisClient();
// 1. Cache hit → return immediately
const: stringconst cached = await getCache<type BookWithDetails = /*unresolved*/ anyBookWithDetails[]>(redis, cacheKey, true);
function if(cached: any): anyif (cached: anycached) return: anyreturn cached;
// 2. Cache miss → query Postgres
const: stringconst books = await bookQueries.findTopRatedBooks(limit, minRating);
// 3. Fire-and-forget cache write — don't block the response on a Redis write
function setCache(redis: any, cacheKey: any, books: any, CACHE_TTL: any): anysetCache(redis: anyredis, cacheKey: anycacheKey, books: anybooks, type CACHE_TTL: anyCACHE_TTLfunction (Missing): any).catch(var console: Consoleconsole.Console.warn(...data: any[]): voidThe **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static)warn);
return: anyreturn books;
},
The controller includes a cached flag in the response meta field. This is visible
in the JSON response and makes it easy to confirm caching behaviour without inspecting
Redis directly:
// apps/server/src/features/book/controllers/booksListControllers.ts
const const cached: anycached = await getCache<type BookWithDetails = /*unresolved*/ anyBookWithDetails[]>(redis, cacheKey, true);
return sendSuccess(res, { books: anybooks }, var undefinedundefined, 200, {
cached: booleancached: !!const cached: anycached,
// Response: { "meta": { "cached": true } }
});
Redis connects on startup and registers process signal handlers for graceful shutdown:
// packages/db/src/redis/connection.ts
export const const registerRedisShutdownHandlers: () => voidregisterRedisShutdownHandlers = () => {
const const shutdown: (signal: string) => Promise<never>shutdown = async (signal: stringsignal: string) => {
var console: Consoleconsole.Console.log(...data: any[]): voidThe **`console.log()`** static method outputs a message to the console.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`Received ${signal: stringsignal}, shutting down Redis...`);
await disconnectRedis();
var process: NodeJS.Processprocess.NodeJS.Process.exit(code?: number | string | null): neverThe `process.exit()` method instructs Node.js to terminate the process
synchronously with an exit status of `code`. If `code` is omitted, exit uses
either the 'success' code `0` or the value of `process.exitCode` if it has been
set. Node.js will not terminate until all the `'exit'` event listeners are
called.
To exit with a 'failure' code:
```js
import { exit } from 'node:process';
exit(1);
```
The shell that executed Node.js should see the exit code as `1`.
Calling `process.exit()` will force the process to exit as quickly as possible
even if there are still asynchronous operations pending that have not yet
completed fully, including I/O operations to `process.stdout` and `process.stderr`.
In most situations, it is not actually necessary to call `process.exit()` explicitly. The Node.js process will exit on its own _if there is no additional_
_work pending_ in the event loop. The `process.exitCode` property can be set to
tell the process which exit code to use when the process exits gracefully.
For instance, the following example illustrates a _misuse_ of the `process.exit()` method that could lead to data printed to stdout being
truncated and lost:
```js
import { exit } from 'node:process';
// This is an example of what *not* to do:
if (someConditionNotMet()) {
printUsageToStdout();
exit(1);
}
```
The reason this is problematic is because writes to `process.stdout` in Node.js
are sometimes _asynchronous_ and may occur over multiple ticks of the Node.js
event loop. Calling `process.exit()`, however, forces the process to exit _before_ those additional writes to `stdout` can be performed.
Rather than calling `process.exit()` directly, the code _should_ set the `process.exitCode` and allow the process to exit naturally by avoiding
scheduling any additional work for the event loop:
```js
import process from 'node:process';
// How to properly set the exit code while letting
// the process exit gracefully.
if (someConditionNotMet()) {
printUsageToStdout();
process.exitCode = 1;
}
```
If it is necessary to terminate the Node.js process due to an error condition,
throwing an _uncaught_ error and allowing the process to terminate accordingly
is safer than calling `process.exit()`.
In `Worker` threads, this function stops the current thread rather
than the current process.exit(0);
};
var process: NodeJS.Processprocess.NodeJS.Process.on<"SIGINT">(eventName: "SIGINT", listener: (signal: "SIGINT") => void): NodeJS.Process (+1 overload)Adds the `listener` function to the end of the listeners array for the
event named `eventName`. No checks are made to see if the `listener` has
already been added. Multiple calls passing the same combination of `eventName`
and `listener` will result in the `listener` being added, and called, multiple
times.
```js
server.on('connection', (stream) => {
console.log('someone connected!');
});
```
Returns a reference to the `EventEmitter`, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
`emitter.prependListener()` method can be used as an alternative to add the
event listener to the beginning of the listeners array.
```js
import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a
```on('SIGINT', () => const shutdown: (signal: string) => Promise<never>shutdown('SIGINT'));
var process: NodeJS.Processprocess.NodeJS.Process.on<"SIGTERM">(eventName: "SIGTERM", listener: (signal: "SIGTERM") => void): NodeJS.Process (+1 overload)Adds the `listener` function to the end of the listeners array for the
event named `eventName`. No checks are made to see if the `listener` has
already been added. Multiple calls passing the same combination of `eventName`
and `listener` will result in the `listener` being added, and called, multiple
times.
```js
server.on('connection', (stream) => {
console.log('someone connected!');
});
```
Returns a reference to the `EventEmitter`, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
`emitter.prependListener()` method can be used as an alternative to add the
event listener to the beginning of the listeners array.
```js
import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a
```on('SIGTERM', () => const shutdown: (signal: string) => Promise<never>shutdown('SIGTERM'));
};
Testing
Tests are written with Vitest and Supertest — Supertest mounts the Express app
in-process so tests make real HTTP requests without a running server. The test config
runs in the node environment with V8 coverage:
// apps/server/vitest.config.ts
export default defineConfig({
test: {
globals: boolean;
environment: string;
coverage: {
provider: string;
reporter: string[];
exclude: string[];
};
}
test: {
globals: booleanglobals: true,
environment: stringenvironment: 'node',
coverage: {
provider: string;
reporter: string[];
exclude: string[];
}
coverage: {
provider: stringprovider: 'v8',
reporter: string[]reporter: ['text', 'json', 'html'],
exclude: string[]exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
},
},
});
A health check integration test hits the real /api/health/quick endpoint through
Supertest and asserts the response shape. The test file runs pnpm test as part of CI:
// Example integration test (health route)
import { import describedescribe, import itit, import expectexpect } from 'vitest';
import import requestrequest from 'supertest';
import import appapp from '../src/server';
import describedescribe('GET /api/health/quick', () => {
import itit('returns healthy status with postgres and redis fields', async () => {
const const res: anyres = await import requestrequest(import appapp).get('/api/health/quick');
import expectexpect(const res: anyres.status).toBe(200);
import expectexpect(const res: anyres.body).toMatchObject({
status: stringstatus: 'healthy',
services: {
postgres: any;
redis: any;
}
services: {
postgres: anypostgres: import expectexpect.any(var Boolean: BooleanConstructorBoolean),
redis: anyredis: import expectexpect.any(var Boolean: BooleanConstructorBoolean),
},
});
});
});
The scripts block in package.json wires tests and type-checking into the dev workflow:
// apps/server/package.json
{
"scripts": {
"test": "vitest run",
"check-types": "tsc --noEmit",
"dev": "nodemon --watch src --exec tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
TypeScript is configured with strict, noUnusedLocals, and noUnusedParameters — the
compiler rejects dead code and unused imports at build time:
// apps/server/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
Health Checks
/api/health pings PostgreSQL and Redis in parallel and returns structured status
with response times per service. The overall status is degraded when only one
service is down — not unhealthy — so alerts can distinguish partial from total
outages:
// packages/db/src/health/index.ts
export const const checkSystemHealth: () => Promise<HealthStatus>checkSystemHealth = async (): interface Promise<T>Represents the completion of an asynchronous operationPromise<type HealthStatus = /*unresolved*/ anyHealthStatus> => {
const [const postgresHealth: anypostgresHealth, const redisHealth: anyredisHealth] = await var Promise: PromiseConstructorRepresents the completion of an asynchronous operationPromise.PromiseConstructor.all<[any, any]>(values: [any, any]): Promise<any> (+1 overload)Creates a Promise that is resolved with an array of results when all of the provided Promises
resolve, or rejected when any Promise is rejected.all([
checkPostgresHealth(),
checkRedisHealth(),
]);
const const statuses: any[]statuses = [const postgresHealth: anypostgresHealth.status, const redisHealth: anyredisHealth.status];
const const overall: "healthy" | "unhealthy" | "degraded"overall = const statuses: any[]statuses.Array<any>.every(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): boolean (+1 overload)Determines whether all the members of an array satisfy the specified test.every((s: anys) => s: anys === 'healthy')
? 'healthy'
: const statuses: any[]statuses.Array<any>.every(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): boolean (+1 overload)Determines whether all the members of an array satisfy the specified test.every((s: anys) => s: anys === 'unhealthy')
? 'unhealthy'
: 'degraded';
return {
status: stringstatus: const overall: "healthy" | "unhealthy" | "degraded"overall,
uptime: numberuptime: var process: NodeJS.Processprocess.NodeJS.Process.uptime(): numberThe `process.uptime()` method returns the number of seconds the current Node.js
process has been running.
The return value includes fractions of a second. Use `Math.floor()` to get whole
seconds.uptime(),
services: {
postgres: any;
redis: any;
}
services: { postgres: anypostgres: const postgresHealth: anypostgresHealth, redis: anyredis: const redisHealth: anyredisHealth },
overall: {
status: string;
message: string;
}
overall: { status: stringstatus: const overall: "healthy" | "unhealthy" | "degraded"overall, message: stringmessage: '...' },
};
};
Example response when Redis is unreachable but Postgres is healthy:
{
"overall": {
"status": "degraded",
"message": "Some services are experiencing issues"
},
"services": {
"postgres": { "status": "healthy", "responseTime": 4 },
"redis": { "status": "unhealthy", "responseTime": 3001, "error": "connect ECONNREFUSED" }
},
"uptime": 3842.5
}
API Documentation — Swagger / OpenAPI 3.0
The full API is documented in a swagger.json spec served live at /api/docs via
Swagger UI and hosted publicly on SwaggerHub. Every endpoint documents its request
body schema, query parameters, possible response codes, and authentication requirement.
Here is the auth security scheme and a sample endpoint spec:
// apps/server/src/docs/swagger.json (excerpts)
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"/auth/login": {
"post": {
"tags": ["Authentication"],
"summary": "Authenticate a user",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LoginRequest" }
}
}
},
"responses": {
"200": { "description": "Login successful" },
"400": { "description": "Invalid credentials" },
"401": { "description": "Authentication failed" },
"500": { "description": "Internal server error" }
}
}
}
The Swagger UI at /api/docs gives an interactive testing interface — you can
authenticate, get a token, and call protected endpoints directly from the browser
without any external tool.
Docker
The server ships with a Dockerfile and a docker-compose.yml that wires up the
API, PostgreSQL, and Redis together for local development and deployment.
The Dockerfile builds from the monorepo root — it copies all workspace packages, installs dependencies with pnpm, builds everything, then prunes dev dependencies before the final image:
# apps/server/Dockerfile
FROM node:18-alpine
RUN npm install -g pnpm
WORKDIR /app
# Copy workspace manifests and all packages
COPY package.json pnpm-workspace.yaml ./
COPY packages/ ./packages/
COPY apps/ ./apps/
RUN pnpm install
RUN pnpm run build
RUN pnpm install --prod # prune devDependencies for production image
EXPOSE 4000
ENV PORT=4000
ENV NODE_ENV=production
# Run as non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 -G nodejs
USER nextjs
WORKDIR /app/apps/server
CMD ["pnpm", "start"]
The compose file brings up all three services with a single command:
# apps/server/docker-compose.yml
services:
server:
build:
context: ../..
dockerfile: apps/server/Dockerfile
ports: ['4000:4000']
depends_on: [redis, postgres]
volumes:
- ./.env:/usr/src/app/apps/server/.env
redis:
image: redis:7-alpine
ports: ['6379:6379']
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: booklab
ports: ['5432:5432']
volumes:
- postgres_data:/var/lib/postgresql/data
Frontend — SSR + TanStack Query Hydration
Book detail pages prefetch data on the server so the browser receives a fully
populated HTML page with no loading states. HydrationBoundary passes the cache
to client components so they never fire a duplicate network request:
// apps/web/app/book/[id]/page.tsx
export default async function function BookDetailPage({ params }: {
params: any;
}): Promise<boolean>
BookDetailPage({ params: anyparams }) {
const { const id: anyid } = await params: anyparams;
const const queryClient: anyqueryClient = new QueryClient();
await const queryClient: anyqueryClient.prefetchQuery({
queryKey: any[]queryKey: ['book', const id: anyid],
queryFn: () => anyqueryFn: () => bookApi.getBookById(const id: anyid),
});
return (
<type HydrationBoundary = /*unresolved*/ anyHydrationBoundary state={function dehydrate(queryClient: any): anydehydrate(queryClient: anyqueryClient)}>
<type BookInfo = /*unresolved*/ anyBookInfo bookId={const id: anyid} />
</HydrationBoundary>
);
}
The BookInfo client component reads from the dehydrated state on first render:
// apps/web/components/books/BookInfo.tsx
const { data: const book: anybook } = useQuery({
queryKey: any[]queryKey: ['book', bookId], // matches the server prefetch key exactly
queryFn: () => anyqueryFn: () => bookApi.getBookById(bookId),
});
What's Next
- Complete the frontend (~40% done — backend is production-ready)
- Order and rental management with a full checkout flow
- Real review submission with live average-rating recalculation triggered on insert
- Email verification and password reset via token links
- Admin analytics dashboard with book and user metrics