Skip to main content

BookLab

Home/projects/BookLab

[Full-stack]

BookLab

A full-stack book marketplace for buying and renting books, built with a production-grade monorepo architecture.

Next.jsExpressTypeScriptPostgreSQLRedisDockerTanStack QueryTailwind CSSShadcn/UIpnpm Workspaces
View repositoryVisit demo

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): boolean
Determines whether an array includes a certain element, returning true or false as appropriate.
@paramsearchElement The element to search for.@paramfromIndex The position in this array at which to begin searching for searchElement.
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedNODE_ENV === 'production',
httpOnly: booleanhttpOnly: true, sameSite: stringsameSite: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.
@parampredicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
filter
((v: stringv) => !var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
[v: stringv]);
if (const missing: string[]missing.Array<string>.length: number
Gets 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[]): void
The **`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): string
Adds all the elements of an array into a string, separated by the specified separator string.
@paramseparator A string used to separate one element of the array from the next in the resulting string. If omitted, the array elements are separated with a comma.
join
(', ')}`);
if (var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedNODE_ENV === 'production') var process: NodeJS.Processprocess.NodeJS.Process.exit(code?: number | string | null): never
The `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.
@sincev0.1.13@paramcode The exit code. For string type, only integer strings (e.g.,'1') are allowed.
exit
(1);
} // Warn if the session secret is too short to be secure if (var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedSESSION_SECRET && var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.stringSESSION_SECRET.String.length: number
Returns the length of a String object.
length
< 32) {
var console: Consoleconsole.Console.warn(...data: any[]): void
The **`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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedPORT;
if (const port: string | undefinedport && (function isNaN(number: number): boolean
Returns a Boolean value that indicates whether a value is the reserved value NaN (not a number).
@paramnumber A numeric value.
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[]): void
The **`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): never
The `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.
@sincev0.1.13@paramcode The exit code. For string type, only integer strings (e.g.,'1') are allowed.
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 T
Record
<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 T
Record
<string, unknown>,
): Response => res: Responseres.Response.status: number
The **`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: number
The **`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 operation
Promise
<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 operation
Promise
<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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedJWT_REFRESH_SECRET!, {
expiresIn: stringexpiresIn: '7d', }); res: Responseres.cookie(var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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: PromiseConstructor
Represents the completion of an asynchronous operation
Promise
<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[]): void
The **`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[]): void
The **`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): never
The `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.
@sincev0.1.13@paramcode The exit code. For string type, only integer strings (e.g.,'1') are allowed.
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 ```
@sincev0.1.101@parameventName The name of the event.@paramlistener The callback function
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 ```
@sincev0.1.101@parameventName The name of the event.@paramlistener The callback function
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 operation
Promise
<type HealthStatus = /*unresolved*/ anyHealthStatus> => {
const [const postgresHealth: anypostgresHealth, const redisHealth: anyredisHealth] = await var Promise: PromiseConstructor
Represents the completion of an asynchronous operation
Promise
.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.
@paramvalues An array of Promises.@returnsA new Promise.
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.
@parampredicate A function that accepts up to three arguments. The every method calls the predicate function for each element in the array until the predicate returns a value which is coercible to the Boolean value false, or until the end of the array.@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
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.
@parampredicate A function that accepts up to three arguments. The every method calls the predicate function for each element in the array until the predicate returns a value which is coercible to the Boolean value false, or until the end of the array.@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
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(): number
The `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.
@sincev0.5.0
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