Part 2 — Core Engineering

10 Stack Specifics: Node, Postgres, and Almost Nothing Else

Nine chapters taught you ideas: ledgers, isolation, idempotency, reconciliation, tenancy, sync, imports, caching, testing. This chapter puts them onto real tools, and draws one line harder than any other: your business rules live in a framework-free package that a test can run without a database. Everything else is plumbing you can replace.

In this chapter10 sections · about 50 min
  1. What you need to know first
  2. The shape of the repository
  3. The framework-free core
  4. Wiring the core into the server: the router and its handlers
  5. tRPC, server actions, or REST?
  6. Postgres in a container: what you own, and where it bites
  7. Contracts: one definition of "valid," shared by both sides
  8. Timezone discipline: instants versus calendar dates
  9. Decimal arithmetic: never, ever floats
  10. The build order: what to learn, in what sequence

What you need to know first

JavaScript is a programming language, invented to make web pages interactive: click a button, something moves. Every browser on earth runs it, and that universality is why it escaped the browser and now runs servers and build tools too.

Node.js is a program that runs JavaScript outside a browser. JavaScript is the language. Node is a speaker of that language who also holds keys to the building: it can open files, listen on network ports, and connect to Postgres, none of which a browser tab may do.

TypeScript is JavaScript plus labels. In plain JavaScript you can write order.quanitty (misspelled) and nothing complains until a customer's order breaks at 2am. TypeScript lets you declare that an order has a field quantity which is a number, and a checker finds the typo in a second. A type is a written-down promise about the shape of a value. TypeScript is JavaScript with those promises added, stripped away before anything runs. For an ERP, where a wrong number is money, types are the cheapest safety net there is.

Frameworks, libraries, and the server/client line

React is a library for building user interfaces from small reusable pieces called components. A component is a function returning a description of what should appear on screen ("a table with these rows"), and React works out the minimum change needed to make the screen match. You never hand-write "find that cell and change its text."

A framework (Next.js is the famous one) is a pre-built skeleton of an application. Foundation, load-bearing walls, wiring, plumbing: you furnish the rooms. It decides how web addresses map to pages, how code reaches browsers, and how data is fetched before a page renders. A library (React, decimal.js) is a tool you call. A framework is a house you move into, and it calls you — which is why this book uses libraries sparingly and frameworks not at all: an ERP outlives every house on that street.

Server-side code runs on your machine in the data center and can hold database passwords. Client-side code runs in the customer's browser, where they can read every line and change any value before sending it. Hence the most important habit in web work: never trust anything the client sends, and never put a secret in client-side code. A merchandiser can open developer tools and set a hidden price field to zero, so your server must re-check every number it is handed.

Packages, monorepos, and schemas

npm is the package registry for JavaScript, a giant public library of reusable code. A package is a folder of code published under a name, like postgres or decimal.js. You list what you want in package.json and an install command fetches it. A package manager is the tool that does the fetching: npm, which ships with Node and is all this book needs. Your own code can be shaped like a package too, which is the trick this chapter leans on.

A monorepo is one folder (one Git repository) holding several related projects side by side so they can share code without publishing anything: one repository with three directories instead of three repositories. Task orchestrators (Turborepo is the known one) exist to run builds in dependency order and cache them — a service this stack declines, having no builds to order. The convention it popularized is still worth keeping: apps/ for things you deploy and packages/ for the libraries they share.

A schema is the written-down shape of data. In Postgres it is your tables, columns and types: order_lines has a whole-number quantity and a unit_price that is a decimal with four places. In your contracts package it is the shape you expect an incoming request to have. Same idea, two places: this is valid. Anything else is rejected.

Tenants, idempotency, and deploying

Two words from earlier chapters run through this one. A tenant is one customer company on your system, and its data must never mix with another company's (chapter 5). An operation is idempotent when running it twice has the same effect as running it once, so a retry cannot double-count anything (chapter 3). An idempotency key is the unique string you attach to a request so the server can recognize the repeat.

Deploying means making your code run on a computer the public can reach: you push to GitHub, a service builds it and starts the application at a real web address. "It works on my machine" and "it is deployed" are different achievements, and most of this chapter's pain (connection pooling especially) appears only on the second.

The shape of the repository

Start with the folder layout. It encodes the whole argument.

apparel-erp/
├── package.json           # scripts, workspaces, and the short dep list
├── compose.yaml           # the cell: postgres, web, worker, caddy
│
├── apps/
│   ├── admin/             # the internal ERP UI: a Node server + buildless client
│   ├── worker/            # plain Node process: outbox drain, Shopify sync
│   └── api/               # (later) versioned public REST API for customers
│
└── packages/
    ├── core/              # ← THE DOMAIN. No framework. No database driver.
    │   └── src/
    │       ├── inventory/ats.ts          # pure functions
    │       ├── inventory/ports.ts        # repository interfaces
    │       ├── orders/reserve-order.ts   # use cases
    │       └── money.ts                  # decimal helpers
    ├── db/                # Postgres implementations of core's interfaces
    ├── contracts/         # validators shared by client and server
    └── tsconfig/          # shared TypeScript settings

There is no build orchestrator in that tree, because there is nothing to orchestrate: the server runs its source with node, the browser loads its source as ES modules, and the only "build" anywhere is tsc --noEmit checking types without emitting a thing. apps/ holds what you deploy: admin is the server staff log into, worker a plain Node program draining the outbox.

(A transaction is a group of database changes that all take effect together or not at all. The outbox is chapter 4's pattern: instead of calling Shopify in the middle of a transaction, you write the message you want to send into a table inside that same transaction, and a background worker sends it afterwards.)

packages/ holds shared code that is never published:

  • core (the domain)
  • db (Postgres, no business rules)
  • contracts (validators both sides import)

Reading the root package.json

// package.json  (repository root)
// Versions are current as of July 2026; check for newer ones.
{
  "name": "apparel-erp",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "dependencies": {
    "postgres": "^3.4.7"
  },
  "devDependencies": {
    "typescript": "^7.0.2"
  },
  "scripts": {
    "dev":  "node --watch apps/admin/server.mjs",
    "test": "node --test",
    "typecheck": "tsc --noEmit"
  }
}

Read that file top to bottom:

  • "name" is what the repository calls itself.
  • "private": true means "never publish this to npm."
  • "workspaces" is what turns a folder of folders into a monorepo: every directory beneath those paths with its own package.json becomes a project, so when apps/admin asks for @erp/core it is wired to the local folder rather than downloaded.
  • "dependencies" is one line long, and that is the loudest statement in the file. postgres (postgres.js) is here because Node's standard library does not speak the Postgres wire protocol. Everything else in this book runs on what Node ships: node:http for the server, node:test for the tests, node:crypto for password hashing. Every future line added to this object is a small treaty — audit what it pulls in (npm ls), what it costs to replace, and who maintains it.
  • "devDependencies" lists tools needed only while you work; they never ship anywhere.
  • The ^ in "^3.4.7" is a version range: install 3.4.7 or any later 3.x release, but never 4.x, because a change in the first number is allowed to break things.
  • "scripts" gives short names to long commands, so everyone starts, tests and typechecks the same way.

Those pinned versions were the current releases in July 2026. Look up the current ones before you copy these into a repository of your own. And note what is absent: no bundler, no test framework, no ORM, no task runner — each absence is a decision this chapter defends as it goes.

The framework-free core

Core principle

Domain logic (what available-to-sell means, when an order may be reserved, how an invoice total is computed) lives in packages/core as plain TypeScript. It imports no framework, no database driver, no HTTP library, and it does not know HTTP exists. Everything else in your stack is a delivery mechanism wrapped around it.

Two reasons. Testability: a rule that touches no database runs in a fraction of a millisecond, so a whole suite of such tests finishes while you are still typing. Portability: the same reservation rule must run in the admin UI, in the worker, and in a bulk importer. Weld it into a server action and the worker grows a second, slightly different copy, which is how a company ends up with two answers to "how much can we sell?"

Pure functions

A pure function depends only on its inputs and changes nothing outside itself: no database writes, no clock, no randomness, no logging. Same arguments, same answer, every time.

// packages/core/src/inventory/ats.ts
import Decimal from "decimal.js";

export type Sku = string;

/** A point-in-time inventory position for one SKU in one warehouse. */
export interface AtsPosition {
  sku: Sku;
  onHand: Decimal;    // physically counted in the building
  allocated: Decimal; // already promised to open orders
  inbound: Decimal;   // on a confirmed purchase order, not yet received
}

export interface OrderLine  { lineId: string; sku: Sku; quantity: Decimal }
export interface LineAllocation {
  lineId: string; sku: Sku; allocated: Decimal; shortfall: Decimal;
}

/** Sellable right now. Inbound stock is deliberately excluded. */
export function availableToSell(p: AtsPosition): Decimal {
  const ats = p.onHand.minus(p.allocated);
  return ats.isNegative() ? new Decimal(0) : ats;
}

/**
 * Decide how much of each line we can promise, given today's positions.
 * Pure: no I/O, no clock, no randomness. Lines are processed in the order
 * given, so the caller controls fairness (e.g. sort by order date first).
 */
export function planAllocation(
  lines: readonly OrderLine[],
  positions: readonly AtsPosition[],
  opts: { allowPartial: boolean },
): LineAllocation[] {
  const remaining = new Map<Sku, Decimal>(
    positions.map((p) => [p.sku, availableToSell(p)]),
  );

  return lines.map((line) => {
    const pool = remaining.get(line.sku) ?? new Decimal(0);
    const takeable = Decimal.min(pool, line.quantity);
    const take = opts.allowPartial
      ? takeable
      : takeable.equals(line.quantity) ? line.quantity : new Decimal(0);

    remaining.set(line.sku, pool.minus(take));
    return {
      lineId: line.lineId,
      sku: line.sku,
      allocated: take,
      shortfall: line.quantity.minus(take),
    };
  });
}

A SKU (stock keeping unit) is the code for one exact sellable thing: this style, in this color, in this size. TEE-BLK-M is the black tee in medium. Decimal is exact arithmetic, a number that does not drift. An interface like AtsPosition is a contract about shape. It exists only at type-check time and produces no JavaScript.

availableToSell is chapter 8's definition of available-to-sell (ATS, the quantity you may still promise a buyer) in three lines, floored at zero so a glitch cannot yield a negative figure a later step reads as extra stock. inbound is excluded, because mixing future receipts into availability is how firms oversell.

In planAllocation the Map tracks what is still unclaimed within this run, since two lines may want the same SKU and the second must see what the first consumed. When allowPartial is false we give everything or nothing, because wholesale buyers often refuse partial shipments. That is a business policy, so it becomes an explicit input. Nothing here touches the outside world: no await, no SQL, no tenant lookup. The only thing this code can get wrong is the business rule, which is exactly the thing a test can check.

Repository interfaces: the ports out of the core

Real work needs data. The core must ask for positions and record movements, but it must not know how. The repository pattern solves this: the core declares an interface describing the operations it needs, and another package supplies a Postgres-backed implementation. The core depends on the promise and stays ignorant of the plumbing.

// packages/core/src/inventory/ports.ts
import type { AtsPosition, Sku } from "./ats";
import type Decimal from "decimal.js";

export type TenantId = string;

/** One append-only row in the inventory ledger (chapter 1). */
export interface StockMovement {
  sku: Sku;
  warehouseId: string;
  delta: Decimal;           // +receipt, -shipment, +/-adjustment
  reason: "receipt" | "shipment" | "reservation" | "release" | "adjustment";
  referenceType: "order" | "purchase_order" | "count";
  referenceId: string;
}

export interface InventoryRepository {
  loadPositions(
    tenantId: TenantId,
    skus: readonly Sku[],
  ): Promise<AtsPosition[]>;

  /**
   * Append movements atomically. Must be idempotent on idempotencyKey:
   * calling twice with the same key applies the movements once (chapter 3).
   */
  appendMovements(
    tenantId: TenantId,
    movements: readonly StockMovement[],
    idempotencyKey: string,
  ): Promise<{ applied: boolean }>;
}

export interface OutboxRepository {
  enqueue(
    tenantId: TenantId,
    event: { type: string; payload: unknown; idempotencyKey: string },
  ): Promise<void>;
}

/** The clock is handed in, so a test can freeze it. */
export interface Clock { now(): Date }

import type vanishes at runtime, so no real dependency sneaks in. StockMovement is chapter 1's ledger row as a type (a signed delta, a reason, a pointer to whatever caused it) with no quantityAfter field, because in a ledger the current level is derived by summing the movements. (Promise is JavaScript's word for a value that is not ready yet, and await waits for one.)

The comment on appendMovements makes idempotency part of the contract. Clock exists because calling new Date() inside domain logic makes "what happens on the 1st" untestable. You cannot rewind the real clock, but you can pass in a fake one.

Dependency injection and a use case

Dependency injection is a grand name for a small habit: the tools a function needs are handed to it as arguments rather than grabbed. A plumber who brings their own wrench can work in any house.

// packages/core/src/orders/reserve-order.ts
import { planAllocation, type OrderLine } from "../inventory/ats";
import type {
  Clock, InventoryRepository, OutboxRepository, StockMovement, TenantId,
} from "../inventory/ports";

export interface ReserveOrderDeps {
  inventory: InventoryRepository;
  outbox: OutboxRepository;
  clock: Clock;
}

export interface ReserveOrderCommand {
  tenantId: TenantId;
  orderId: string;
  warehouseId: string;
  lines: readonly OrderLine[];
  allowPartial: boolean;
  idempotencyKey: string;
}

export type ReserveOrderResult =
  | { status: "reserved"; shortfalls: { sku: string; qty: string }[] }
  | { status: "rejected"; reason: "insufficient_stock" };

export async function reserveOrder(
  deps: ReserveOrderDeps,
  cmd: ReserveOrderCommand,
): Promise<ReserveOrderResult> {
  const skus = [...new Set(cmd.lines.map((l) => l.sku))];
  const positions = await deps.inventory.loadPositions(cmd.tenantId, skus);
  const plan = planAllocation(cmd.lines, positions, {
    allowPartial: cmd.allowPartial,
  });

  if (plan.every((p) => p.allocated.isZero())) {
    return { status: "rejected", reason: "insufficient_stock" };
  }

  const movements: StockMovement[] = plan
    .filter((p) => p.allocated.greaterThan(0))
    .map((p) => ({
      sku: p.sku,
      warehouseId: cmd.warehouseId,
      delta: p.allocated.negated(),   // reservation removes sellable stock
      reason: "reservation" as const,
      referenceType: "order" as const,
      referenceId: cmd.orderId,
    }));

  await deps.inventory.appendMovements(
    cmd.tenantId, movements, cmd.idempotencyKey,
  );

  await deps.outbox.enqueue(cmd.tenantId, {
    type: "order.reserved",
    payload: { orderId: cmd.orderId, at: deps.clock.now().toISOString() },
    idempotencyKey: `${cmd.idempotencyKey}:outbox`,
  });

  return {
    status: "reserved",
    shortfalls: plan
      .filter((p) => p.shortfall.greaterThan(0))
      .map((p) => ({ sku: p.sku, qty: p.shortfall.toString() })),
  };
}

The first argument is deps, the wrench bag. The second is cmd, everything about this request. Separating them makes the function reusable: website and worker pass different deps and identical cmd shapes. ReserveOrderResult is a union, which in TypeScript means "one of these shapes": a success shape or a rejection shape. TypeScript then forces callers to handle both, and "we forgot the failure path" becomes a compile error.

negated() writes a reservation as a negative delta, because it removes sellable stock exactly as a shipment does. Only the reason differs, so a release can reverse it. The outbox enqueue derives its key from the command's, so a retry cannot double-publish, and reserveOrder never learns whether it runs in a web request or a nightly batch.

A unit test with no database at all

A unit test runs one piece of code with known inputs and checks the output. The last three blocks bought you this test: no Postgres, no Docker, no network, no fixtures. (Docker is a tool for running services such as a database in a sealed box on your laptop. Fixtures are sample rows you load before a test so it has something to read.)

// packages/core/src/orders/reserve-order.test.ts
import { describe, it, expect } from "vitest";
import Decimal from "decimal.js";
import { reserveOrder } from "./reserve-order";
import type { InventoryRepository, OutboxRepository, StockMovement }
  from "../inventory/ports";

/** A fake repository: an in-memory stand-in that honours the interface. */
function fakeInventory(stock: Record<string, [number, number]>) {
  const appended: StockMovement[] = [];
  const seenKeys = new Set<string>();

  const repo: InventoryRepository = {
    async loadPositions(_tenantId, skus) {
      return skus.map((sku) => {
        const [onHand, allocated] = stock[sku] ?? [0, 0];
        return {
          sku,
          onHand: new Decimal(onHand),
          allocated: new Decimal(allocated),
          inbound: new Decimal(0),
        };
      });
    },
    async appendMovements(_tenantId, movements, idempotencyKey) {
      if (seenKeys.has(idempotencyKey)) return { applied: false };
      seenKeys.add(idempotencyKey);
      appended.push(...movements);
      return { applied: true };
    },
  };
  return { repo, appended };
}

const fakeOutbox = (): OutboxRepository => ({ async enqueue() {} });
const frozenClock = { now: () => new Date("2026-03-02T09:00:00Z") };

describe("reserveOrder", () => {
  it("allocates in line order and reports the shortfall", async () => {
    // 40 sellable of the M, 0 of the L.
    const { repo, appended } = fakeInventory({
      "TEE-BLK-M": [50, 10],
      "TEE-BLK-L": [5, 5],
    });

    const result = await reserveOrder(
      { inventory: repo, outbox: fakeOutbox(), clock: frozenClock },
      {
        tenantId: "t_northwind",
        orderId: "ord_1001",
        warehouseId: "wh_main",
        lines: [
          { lineId: "l1", sku: "TEE-BLK-M", quantity: new Decimal(30) },
          { lineId: "l2", sku: "TEE-BLK-M", quantity: new Decimal(25) },
          { lineId: "l3", sku: "TEE-BLK-L", quantity: new Decimal(12) },
        ],
        allowPartial: true,
        idempotencyKey: "ord_1001:reserve:v1",
      },
    );

    expect(result.status).toBe("reserved");
    expect(appended).toHaveLength(2);                  // l3 got nothing
    expect(appended[0].delta.toString()).toBe("-30");
    expect(appended[1].delta.toString()).toBe("-10");  // 40 - 30 left
    expect(result).toMatchObject({
      shortfalls: [
        { sku: "TEE-BLK-M", qty: "15" },
        { sku: "TEE-BLK-L", qty: "12" },
      ],
    });
  });
});

vitest is the test runner: describe groups, it declares a test, expect states what must be true. fakeInventory is the trick: an object satisfying InventoryRepository with a plain JavaScript object as its database. TypeScript checks it against the interface, so the fake cannot drift: change the interface and this file stops compiling. It honors the idempotency clause too, so you can call reserveOrder twice and assert stock moved once.

Follow the numbers by hand and the test reads as a story. The M has 50 on hand with 10 already promised, so 40 are sellable. The L has 5 on hand and 5 promised, so none are.

Line 1 wants 30 and gets 30, leaving 10. Line 2 wants 25, gets the last 10, and records a shortfall of 15. Line 3 wants 12 of the L and gets nothing, so no movement is written for it. Hence two movements, not three. (Those numbers are the real output: run the code and you get deltas of −30 and −10, and shortfalls of 15 and 12.) A test like this finishes in a few milliseconds.

Wiring the core into the server: the router and its handlers

With no framework, the seam between HTTP and your domain is code you can read in one sitting. Two pieces: a router, which maps method-plus-path to a function, and the handlers, the functions themselves. The router is about thirty lines and you will write it once:

// apps/admin/src/router.ts
import type { IncomingMessage, ServerResponse } from 'node:http';

type Handler = (req: IncomingMessage, res: ServerResponse,
                params: Record<string, string>) => Promise<void>;

const routes: { method: string; pattern: RegExp; keys: string[];
                handler: Handler }[] = [];

export function route(method: string, path: string, handler: Handler) {
  // '/v1/orders/:id' -> /^\/v1\/orders\/([^/]+)$/ with keys ['id']
  const keys: string[] = [];
  const pattern = new RegExp('^' + path.replace(/:([^/]+)/g, (_, k) => {
    keys.push(k); return '([^/]+)';
  }) + '$');
  routes.push({ method, pattern, keys, handler });
}

export async function dispatch(req: IncomingMessage, res: ServerResponse) {
  const url = new URL(req.url ?? '/', 'http://x');
  for (const r of routes) {
    if (r.method !== req.method) continue;
    const m = url.pathname.match(r.pattern);
    if (!m) continue;
    const params = Object.fromEntries(r.keys.map((k, i) => [k, m[i + 1]]));
    return r.handler(req, res, params);
  }
  res.writeHead(404, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ error: 'not_found' }));
}

That is the entire dispatch machinery. No middleware stack you did not write, no file-system routing conventions to memorize, no framework release notes to track. When something behaves oddly at 2 a.m., you read your own thirty lines.

A handler, step by step

Every mutating handler runs the same five-step choreography, and the order is the discipline:

// apps/admin/src/handlers/reserve-order.ts
import Decimal from "decimal.js";
import { reserveOrder } from "@erp/core/orders/reserve-order";
import { parseReserveOrderInput } from "@erp/contracts/orders";
import { getSession } from "../auth/session";
import { readJson, respond } from "../http";
import { makeDeps } from "../deps";

export async function reserveOrderHandler(req, res) {
  // 1. AUTHENTICATE — every route is a public HTTP endpoint.
  const session = await getSession(req);
  if (!session) return respond(res, 401, { error: "not_signed_in" });

  // 2. VALIDATE at the boundary. Never trust the browser.
  const body = await readJson(req);            // capped, raw-first
  const parsed = parseReserveOrderInput(body);
  if (!parsed.ok) return respond(res, 400, { issues: parsed.issues });

  // 3. CONVERT wire types into domain types (decimal strings -> Decimal).
  const lines = parsed.value.lines.map((l) => ({
    ...l, quantity: new Decimal(l.quantity),
    unitPrice: new Decimal(l.unitPrice),
  }));

  // 4. CALL THE CORE — the only line with business meaning.
  const result = await reserveOrder(makeDeps(session), {
    ...parsed.value, lines,
  });

  // 5. RESPOND with the domain's answer, mapped to HTTP.
  if (!result.ok) return respond(res, 409, { error: result.reason });
  respond(res, 200, result.value);
}

Authenticate, validate, convert, call, respond. The core never sees an IncomingMessage; the handler never contains an if about inventory. That boundary is what lets chapter 9's tests hit the core without HTTP, and what would let a different transport — a CLI, a queue consumer, a future public API — reuse the same domain verb untouched.

(A webhook is an HTTP request another company's system makes to a URL you own, to tell you something happened, such as Shopify calling you the moment an order is placed. It enters through this same router, as just another route with an HMAC check where step 1's session check would be.)

One rule inherited from chapter 4: readJson reads the raw bytes first, keeps them, then parses — because signature verification needs the exact bytes, and because a body you never captured is a debugging session you cannot have.

Verifying a Shopify webhook

Shopify has no session cookie and cannot call anything clever. It needs a URL, and that URL is one more route() call.

// apps/admin/src/routes/webhooks.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import { route } from '../router';
import { recordExternalEvent }
  from '@erp/core/integrations/record-external-event';
import { makeDeps } from '../deps';

// Read the RAW body. The signature covers the exact bytes sent, so
// nothing may parse or re-serialise them first. No body-parser
// middleware runs ahead of you, because there is no middleware stack.
function readRaw(req): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    let size = 0;
    req.on('data', (c: Buffer) => {
      size += c.length;
      if (size > 1_000_000) {          // cap it; an unbounded read is a DoS
        reject(new Error('body too large')); req.destroy();
      } else chunks.push(c);
    });
    req.on('end', () => resolve(Buffer.concat(chunks)));
    req.on('error', reject);
  });
}

route('POST', '/webhooks/shopify', async (req, res) => {
  const raw = await readRaw(req);

  const sent = Buffer.from(
    String(req.headers['x-shopify-hmac-sha256'] ?? ''), 'utf8');
  const expected = Buffer.from(
    createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!)
      .update(raw)                     // the bytes, not a re-encoded string
      .digest('base64'), 'utf8');

  const ok = sent.length === expected.length &&
             timingSafeEqual(sent, expected);
  if (!ok) { res.writeHead(401); return res.end('bad signature'); }

  // Store and acknowledge fast. Do the real work in apps/worker.
  await recordExternalEvent(makeDeps(), {
    source: 'shopify',
    externalAccount: String(req.headers['x-shopify-shop-domain'] ?? ''),
    // Dedupe on the DELIVERY id, per Shopify's docs.
    idempotencyKey: String(req.headers['x-shopify-webhook-id'] ?? ''),
    // The EVENT id groups deliveries from one merchant action.
    eventId: String(req.headers['x-shopify-event-id'] ?? ''),
    topic: String(req.headers['x-shopify-topic'] ?? 'unknown'),
    payload: raw.toString('utf8'),
  });

  res.writeHead(200); res.end();
});

Reading the raw body is the whole trick, and it is worth seeing why the plain-Node version is easier to get right than the framework one. Verification depends on the exact bytes Shopify sent: turning the text into objects and then back into text can move a space or reorder a field, which breaks the check on every legitimate webhook. Frameworks parse bodies for you by default, so the standard fix is to find the escape hatch that turns the convenience off for this one route. Here nothing has parsed anything, because nothing runs that you did not write; you read the stream, and the bytes are the bytes.

An HMAC is a fingerprint of a message computed with a shared secret: Shopify computes one over the body, you compute your own, and if they match then the message came from Shopify and nobody altered it. Shopify sends it in the X-Shopify-Hmac-SHA256 header, written in base64, a way of writing raw bytes as ordinary letters and digits so they survive being sent as text. timingSafeEqual compares in constant time, so an attacker cannot learn the signature by timing responses; it throws on unequal lengths, hence the length check first. All of it is node:crypto — the same standard library that hashes passwords in chapter P, installed by nothing.

Then the chapter-4 pattern: record the event and return quickly. Shopify's own docs set the budget (a one-second connection timeout and a five-second timeout for the whole request) and expect a 200 OK to acknowledge receipt. Anything slower is retried, and a retry arriving mid-work is how duplicate movements are born. Note which header becomes the idempotency key: Shopify says to use X-Shopify-Webhook-Id to deduplicate individual deliveries, while X-Shopify-Event-Id correlates deliveries that came from the same merchant action. Dedupe on the delivery id. Keep the event id for tracing.

Every route is a public endpoint

A route is reachable by anyone who can reach your site, with any arguments they like. Every handler must authenticate, validate its inputs, and take the tenant from the session rather than from the payload. This is obvious when the handler is visibly an HTTP endpoint — which is an underrated argument for writing it as one. Frameworks that let you export a function and call it from the browser hide the endpoint behind an import, and a hidden endpoint is one you forget to guard.

tRPC, server actions, or REST?

You will meet all three names, so here is the map. RPC is Remote Procedure Call: calling a function on another computer should look like calling one here. tRPC is a TypeScript-first RPC toolkit; server actions are a framework feature (Next.js) that turns an imported function into a hidden HTTP call; REST is the conventional HTTP style: nouns as URLs (/v1/orders/1234), verbs as methods, JSON in and out. OpenAPI is a standard file format describing a REST API machine-readably, from which you generate docs and clients in any language.

With no framework, the decision mostly makes itself, and it is worth seeing why:

ConcernPlain JSON routes (this book)tRPCServer actions
Who calls itYour UI today; anyone, in any language, the day you version itTypeScript clients onlyYour own framework's UI only
Type safetyShared types + validators from packages/contracts — one import on both sidesEnd to end, no codegenEnd to end, within one app
What it depends onnode:http and your thirty-line routerThe tRPC packages and their conventionsThe framework, forever
Stable URLs an outsider can depend onYes — that is the pointNot for outside consumersNo; identifiers change between builds
Debuggabilitycurl works; the request is what it looks likeA batching envelope to learnOpaque wire format

The honest accounting: tRPC's real offer is end-to-end types without discipline, and you are already paying for types with discipline — the contracts package is one import away from both sides, because the repository is one workspace. Server actions' real offer is skipping the fetch layer, and your fetch layer is fifteen lines you own. Neither offer clears the bar this book sets for a dependency; both bind your API to a toolchain, and an ERP outlives every toolchain it meets.

What you do take from REST-with-versioning, the day an outsider first calls you: the ceremony is real (versioned paths, keys, rate limits, deprecation windows, a published OpenAPI file), so do not spend it early. Until then your JSON routes are an internal API: stable enough for your own two clients, free to change with a same-day deploy of both sides.

When to add the public REST surface

Your JSON routes are already REST-shaped, so "adding REST" does not mean new machinery. It means promoting some of those routes to a public contract, and that is a promise, not a feature. Make it when someone outside your codebase needs programmatic access: a buyer polling stock, a marketplace pulling your catalog, a 3PL posting shipment confirmations. You then accept three obligations:

  • Versioning: the URL carries /v1/ and its shape never breaks, whatever your database does. Internal routes may change shape the same afternoon both sides deploy; a /v1/ route may not.
  • A published contract: an OpenAPI document generated from the same contracts schemas that validate the requests, so the document cannot drift from the code. Generated, never hand-maintained — a hand-written spec is a lie with a timestamp.
  • A deprecation policy: how long /v1/ keeps working after /v2/ ships, written down before you need it. Six months is a common floor for a partner integration.

Plus the operational tax that comes with strangers: API keys scoped per partner, rate limits, and an audit trail of who called what. All of it is real work, which is exactly why you do not spend it early. When you do, the handlers stay what they already are — a thin translation over @erp/core. Three surfaces, one brain.

Postgres in a container: what you own, and where it bites

Your database is the official postgres:17 image, one service in compose.yaml, its data on a named volume. This is a good arrangement for one reason above all: it is real Postgres, all of it. Everything from chapters 1 to 8 works exactly as documented, every extension is installable because the image is yours to extend, and pg_dump moves you anywhere — including, someday, to a managed vendor, if the ops errands ever outgrow you (that decision is recorded as D9 in chapter 12).

What you own, concretely:

  • The config. postgresql.conf ships sane-ish defaults for a machine from 2010. The handful worth setting on day one: shared_buffers (a quarter of the box's RAM), shared_preload_libraries = 'pg_stat_statements' (chapter 8's measuring tool), and archive_command/archive_timeout (chapter 9's backup regime). Each is one line in a conf file the image mounts.
  • Row-level security, chapter 5's second wall — pure Postgres, nothing to attach. Your server sets app.tenant_id per transaction and the policies read it.
  • Point-in-time recovery, via WAL-G archiving the write-ahead log (the running record Postgres writes before it changes any data) to your object storage. Two-minute worst-case RPO, retention bounded by your storage bill, restore drills you can fully script (chapter 9).
  • Logical replication, the live feed of row changes — which is all PowerSync needs to drive chapter 6's offline sync. A publication and a replication role; no platform in between.

Where it bites — and each bite is a duty, not a surprise:

  • You are the DBA now. Disk-full is yours to alarm on (the classic self-hosted outage: WAL grows until the volume fills, then everything stops). Monitor free disk and archive health before anything fancier.
  • Upgrades are an errand. A major-version bump is dump-and-restore or pg_upgrade, rehearsed on a copy first. Minor versions are an image tag and a restart. Put both on the calendar; nobody will do them for you.
  • The two performance gotchas travel with you. Write (SELECT current_setting('app.tenant_id')) in policies rather than the bare call — the sub-select runs once per statement instead of once per row. And lead every hot index with tenant_id, because the policy bolts it onto every query's WHERE clause.
  • Connection arithmetic is yours (the runbook, chapter S): a handful of long-lived processes with small pools, sized against the box's RAM, and prepare: false the day a transaction-mode pooler ever enters the picture.

Connection pooling, and the arithmetic you now own

A connection pool is a set of already-open database connections kept ready for reuse. Opening one is expensive — a handshake, authentication, and on Postgres an entire server-side process — and the server accepts only a limited number at once: the PostgreSQL manual puts the default max_connections at "typically 100".

On a serverless platform this is a genuinely hard problem. Your app runs as many short-lived instances the platform starts on demand and discards, each wanting its own connection, and a hundred instances at five connections each is five hundred connections against a server that will accept a hundred. That is what external poolers like PgBouncer and Supabase's Supavisor exist to solve: they sit in front of the database and share a small number of real connections among thousands of client ones.

You do not have that problem, and it is worth being precise about why: it is not that you solved it, it is that you never created it. A long-lived process holds its pool for its whole life. Two app processes and a worker, each with a modest pool, is a fixed and knowable number of connections — not a number that varies with traffic.

# The arithmetic, done once, on paper.
#
#   2 app processes  x  10 connections  = 20
#   1 worker         x   5 connections  =  5
#   migrations (brief, one at a time)   =  2
#   your psql session, monitoring, WAL-G =  8   <-- always leave headroom
#                                        ----
#                                          35   connections, steady state
#
# postgresql.conf
max_connections = 100          # default; 35 fits with room to spare
shared_buffers  = 2GB          # ~25% of RAM on an 8GB box
work_mem        = 16MB         # PER SORT, not per connection --
                               # 35 conns x 3 sorts x 16MB = 1.7GB worst case

Two numbers deserve a comment. shared_buffers at roughly a quarter of RAM is the long-standing starting point, leaving the rest for the operating system's own page cache, which is also caching your data. work_mem is the one people misread: it is allocated per sort or hash operation, not per connection, so a query with three sorts can take three times the figure, and your worst case is that multiplied by concurrent queries. Set it modestly and raise it for a specific reporting session with SET LOCAL work_mem inside the transaction that needs it.

Size the app pool to the work, not to the traffic. Ten connections per process does not mean ten simultaneous users — it means ten simultaneous queries, and a request holds its connection only for the milliseconds it is actually querying. A pool that is too large converts a database slowdown into a database collapse, because every process piles on more concurrent work exactly when the server has least capacity. If the pool is regularly exhausted, the fix is almost never a bigger pool; it is a slow query, or a transaction held open across an HTTP call.

When a pooler does become the answer

Add PgBouncer when the connection count genuinely stops being a small fixed number: dozens of app processes across several machines, or a per-tenant-connection design. It runs as one more container in the same compose file. Put it in transaction mode, point the app at it, and — this is the part that bites — keep migrations and pg_dump pointed at the database directly, because both need session state a transaction pooler will not preserve. Read the next section before you do.

The prepared-statement gotcha

A prepared statement is a query sent to Postgres once to be parsed and planned, then executed repeatedly with different values. It is faster, and the values travel separately from the query text, which is the standard defense against SQL injection — the attack where someone types '; drop table orders; -- into a form field and your code pastes it straight into a query.

Postgres stores the plan on the connection, under a name like s1. That is the whole gotcha in one sentence, and it explains a bug you will otherwise lose an afternoon to. Through a transaction-mode pooler, your driver prepares s1 on connection A; the transaction ends; A returns to the pool; your next call lands on connection B, asks for s1, and Postgres answers "prepared statement s1 does not exist." It appears only in production and only under load, because only under load does the pooler hand you a different connection than the one you prepared on.

// packages/db/src/client.ts
import postgres from 'postgres';

/**
 * Direct to Postgres: a long-lived process holding its own connections.
 * Prepared statements are not just safe here, they are the point --
 * the same handful of queries run millions of times on the same
 * connections, and Postgres plans each of them once.
 */
export const sql = postgres(process.env.DATABASE_URL!, {
  max: 10,                   // see the arithmetic above
  idle_timeout: 30,          // seconds before an idle conn is closed
  connect_timeout: 5,
  // prepare defaults to true. Leave it true. You have no pooler.
});

/** Migrations: one connection, direct, never through a pooler. */
export const migrationSql =
  postgres(process.env.DATABASE_URL!, { max: 1 });

// If PgBouncer ever appears in front of the database, this ONE line
// changes -- and it must, or you get "prepared statement s1 does not
// exist" in production at the worst possible moment:
//   postgres(process.env.POOLER_URL!, { max: 10, prepare: false })

The reason to teach a bug you do not currently have is that the shape recurs. Anything that reassigns your connection between statements breaks anything that stored state on the connection — and prepared statements are only the most common example. Session-level SET, temporary tables, advisory locks and LISTEN/NOTIFY all live on a connection and all break the same way. It is the same reason chapter O's tenant context uses set_config(..., true): the true scopes it to the transaction, so it cannot outlive the connection's stay in your hands and leak into the next request that borrows it.

Migrations are the other case worth naming now. They issue session-level commands such as CREATE INDEX CONCURRENTLY — which builds an index without blocking reads and writes, and which Postgres refuses to run inside a transaction — and they take advisory locks so a second copy of the migration waits rather than racing. Both assume the same connection across several statements. Run them on a direct connection, always, whatever else you add later.

The two-hour bug you can skip

"prepared statement s1 already exists," or "does not exist," appearing only in production and only under load, always means one thing: prepared statements running through a transaction-mode pooler. Set prepare: false on the pooled connection and keep migrations on the direct one. Write this down now; you will meet it years from now, on someone else's system, and recognise it in ten seconds instead of two hours.

Contracts: one definition of "valid," shared by both sides

Write the shape of your data twice — once as a hand-written type and once as a hand-written validator — and they will eventually disagree: the validator will bless something the type calls impossible. The cure is one module that is both. Schema libraries (Zod is the popular one) sell exactly this; this book builds it in about sixty lines instead, because a validator is not hard, and because the boundary of your system is the worst possible place for someone else's breaking changes.

// packages/contracts/src/orders.ts   — imported by browser AND server
import { obj, str, uuid, int, bool, arr, isoDate, email,
         decimalString, type Parsed } from "./validate";

/** A quantity: a decimal string, not a JS number. Explained below. */
const quantity = decimalString({ maxScale: 3 });

/** Money: a decimal string, max 4 places, matching NUMERIC(14,4). */
const money = decimalString({ maxScale: 4 });

export const orderLineInput = obj({
  lineId: uuid(),
  sku: str({ min: 1, max: 64, pattern: /^[A-Z0-9-]+$/,
             error: "SKUs are uppercase letters, digits and hyphens" }),
  quantity,
  unitPrice: money,
});

export const reserveOrderInput = obj({
  orderId: uuid(),
  revision: int({ min: 0 }),
  warehouseId: uuid(),
  allowPartial: bool({ default: false }),
  cancelDate: isoDate({ nullable: true }),   // a DATE, not a timestamp
  lines: arr(orderLineInput, { min: 1, max: 500 }),
  customerEmail: email(),
});

// The type is DERIVED. There is no second source of truth.
export type ReserveOrderInput = Parsed<typeof reserveOrderInput>;
export const parseReserveOrderInput = reserveOrderInput.parse;
// parse(unknown) -> { ok: true, value: ReserveOrderInput }
//                 | { ok: false, issues: Issue[] }

The combinators in ./validate are the sixty lines you own: each returns a small object holding a parse function and carrying its output type, obj composes them field by field, and Parsed<> extracts the composed type with one conditional type. Writing it is this chapter's exercise, and it is the best TypeScript lesson in the book: after it, "how do schema libraries work" is a question you can answer with "like mine, plus features I did not need."

Read decimalString's pattern as: an optional minus sign, one or more digits, then optionally a dot and up to maxScale more digits, and nothing else. The money and quantity fields accept strings for a concrete reason: JSON's one numeric type is the same lossy float as JavaScript's, so {"unitPrice": 24.35} is already approximate before your code sees it. And isoDate accepts YYYY-MM-DD and nothing else — it rejects 2026-03-15T00:00:00Z outright, which alone prevents the next section's bug. (ISO 8601 is the standard for writing dates as text: 2026-03-15 for a date, 2026-03-15T14:30:00Z for an instant, where Z means UTC.)

Parsed<typeof reserveOrderInput> is the payoff: adding a field updates the type, and every construction site missing it stops compiling. The discipline that follows is validate at boundaries only. Parse untrusted data where it enters: route handler, webhook, CSV importer, sync payload. Past that line it is typed and trusted, and packages/core never re-validates. Doing so creates a second definition of "valid" that will drift.

Timezone discipline: instants versus calendar dates

An ERP holds two completely different kinds of temporal value, and conflating them causes one of the most embarrassing bugs in wholesale software: orders that cancel themselves a day early.

An instant is a moment on the world's timeline: when this order was created, when this shipment scanned out. It is the same moment everywhere. Only its rendering differs. Store instants as timestamptz, in UTC (Coordinated Universal Time: effectively Greenwich with no daylight saving), and render them in local time at the last moment.

A civil date is a calendar day with no time and no zone: a cancel date, a season start, an invoice due date. A buyer who writes "cancel 15 March" means the day, wherever they stand. Store civil dates as DATE.

-- packages/db/migrations/0012_orders_dates.sql

create table wholesale_orders (
  id              uuid primary key default gen_random_uuid(),
  tenant_id       uuid not null references tenants(id),
  order_number    text not null,

  -- INSTANTS: moments on the timeline. Always timestamptz, stored UTC.
  created_at      timestamptz not null default now(),
  submitted_at    timestamptz,

  -- CIVIL DATES: calendar days agreed with the buyer. No time. No zone.
  ship_start_date date not null,
  cancel_date     date not null,

  constraint cancel_after_start check (cancel_date >= ship_start_date),
  unique (tenant_id, order_number)
);

-- The tenant's own wall-clock zone, for rendering instants.
alter table tenants add column time_zone text not null default 'UTC';

create index wholesale_orders_cancel_idx
  on wholesale_orders (tenant_id, cancel_date);

-- "Which orders cancel today?" -- evaluated in the TENANT's zone.
select o.id, o.order_number, o.cancel_date
from wholesale_orders o
join tenants t on t.id = o.tenant_id
where o.tenant_id = $1
  and o.cancel_date <= (now() at time zone t.time_zone)::date;

A few Postgres words first. uuid is a long random identifier, and gen_random_uuid() makes a fresh one for each row, so two machines can create rows without ever colliding. references tenants(id) tells the database this column must point at a real tenant. A check constraint is a rule the database enforces on every write. Here it says a cancel date can never fall before the ship-start date. unique (tenant_id, order_number) lets two different tenants both use order number 1001 while stopping one tenant from using it twice.

The name timestamptz is misleading. The column holds one absolute instant, normalized to UTC, and no zone travels with it. date stores year, month and day with no time and no zone: 15 March is 15 March, full stop. The final query asks "which orders have canceled?" correctly: now() gives the instant, at time zone t.time_zone converts it to the tenant's wall clock, ::date takes the calendar day, and you compare date to date. Two tenants get different answers at the same instant, which is right.

"The order canceled a day early"

Store cancel_date as timestamptz and the failure runs like this. Someone in New York enters 15 March. The browser sends 2026-03-15T00:00:00, and on that date New York is four hours behind UTC, so the stored instant is 2026-03-15T04:00:00Z. A buyer in Los Angeles, seven hours behind UTC, sees that instant rendered as 2026-03-14, 21:00 the previous evening. The screen says the order cancels on the 14th, and a nightly job comparing that instant to local midnight kills live orders a day early. A civil date has no time, so give it no time to lose.

The driver-level trap

Even with a correct DATE column, your driver can reintroduce the bug on the way out. A driver is the library your code uses to talk to the database. Here it is pg, also called node-postgres.

As of July 2026, pg 8.22.0 depends on pg-types 2.2.0, which registers a parser for Postgres type 1082 (date) and turns '2026-03-15' into a JavaScript Date. That parser comes from the small postgres-date package, whose source comment is explicit: "Force YYYY-MM-DD dates to be parsed as local time".

node-postgres's own documentation confirms the consequence: it "converts DATE and TIMESTAMP columns into the local time of the node process set at process.env.TZ". Run that on a server set to Europe/Berlin and '2026-03-15' arrives as the instant 2026-03-14T23:00:00Z: call toISOString() on it, or render it in UTC, and the screen says the 14th.

A server set to America/Los_Angeles has the mirror-image fault: the same column value becomes 2026-03-15T07:00:00Z, which still reads as the 15th in most of the world but shows as the 14th, 6pm, to a buyer in Honolulu.

// packages/db/src/types.ts  — import this ONCE, before any query runs.
import pg from "pg";

const DATE_OID = 1082;      // Postgres `date`
const NUMERIC_OID = 1700;   // Postgres `numeric` / `decimal`

// Keep DATE as the plain string Postgres sent. No Date object, no zone.
pg.types.setTypeParser(DATE_OID, (value: string) => value);

// NUMERIC is already a string by default (pg registers no parser for
// 1700). Set it explicitly so an upgrade cannot silently change that.
pg.types.setTypeParser(NUMERIC_OID, (value: string) => value);

// ---------------------------------------------------------------
// Date-only arithmetic, with no local time anywhere near it.
export type CivilDate = string;   // strictly "YYYY-MM-DD"

const toUtcMs = (s: CivilDate) => {
  const [y, m, d] = s.split("-").map(Number);
  return Date.UTC(y, m - 1, d);   // pure calendar math, no local zone
};

export function addDays(date: CivilDate, days: number): CivilDate {
  const ms = toUtcMs(date) + days * 86_400_000;
  return new Date(ms).toISOString().slice(0, 10);
}

export function daysBetween(a: CivilDate, b: CivilDate): number {
  return Math.round((toUtcMs(b) - toUtcMs(a)) / 86_400_000);
}

/** Render an instant in the tenant's zone. Formatting is a UI concern. */
export function formatInstant(iso: string, timeZone: string): string {
  return new Intl.DateTimeFormat("en-US", {
    timeZone, dateStyle: "medium", timeStyle: "short",
  }).format(new Date(iso));
}

setTypeParser registers a function for one Postgres type identified by its OID, the numeric id Postgres gives every data type. Returning the value untouched means "hand me the raw text," so a DATE arrives as "2026-03-15", immune to every timezone in the world. The NUMERIC line is belt-and-braces, since pg-types registers no parser for 1700 today.

addDays works entirely in UTC, where every day is exactly 86,400,000 milliseconds long, so no daylight-saving change can shift it. The naive version — build a Date, call setDate(getDate() + n), then toISOString() — quietly loses a day whenever the span crosses a clock change: in a process set to Europe/London, 27 March 2026 plus three days returns 29 March instead of 30 March, because the clocks go forward on the 29th. Store UTC, compute in UTC, format at the edge.

The Temporal API: safe on the server, early in browsers

Temporal is JavaScript's proper replacement for Date, and it draws exactly this chapter's distinction: Temporal.PlainDate for a calendar day, Temporal.Instant for a moment, Temporal.ZonedDateTime for both. As of July 2026 it ships in Chrome and Edge 144 and later and Firefox 139 and later. No released version of Safari has it (Safari Technology Preview keeps it behind a flag), and neither do Opera or Samsung Internet, which is why caniuse puts global support at about 67% of browsers. On the server you can use it today through a polyfill (a library that supplies a missing built-in feature): temporal-polyfill 1.0.1, or the standards champions' own @js-temporal/polyfill. Keep dates as strings at the boundary and adopting Temporal later stays a contained change.

Decimal arithmetic: never, ever floats

A floating-point number is how computers store fractions: binary digits plus an exponent, like scientific notation in base 2. JavaScript has exactly one number type and it is a 64-bit float. Binary cannot represent most decimal fractions exactly, for the same reason base 10 cannot write one third. In binary, 0.1 repeats forever and gets truncated to fit.

// Every line below is real output, re-run in Node.js, July 2026.

0.1 + 0.2;                       // 0.30000000000000004
0.1 + 0.2 === 0.3;               // false

let total = 0;
for (let i = 0; i < 10; i++) total += 0.1;
total;                           // 0.9999999999999999   (not 1)

0.07 * 100;                      // 7.000000000000001

// --- Now with real apparel money. 12 units at $24.35, seven lines. ---
12 * 24.35;                      // 292.20000000000005
let invoice = 0;
for (let i = 0; i < 7; i++) invoice += 12 * 24.35;
invoice;                         // 2045.4000000000003  (should be 2045.40)

// --- And here is where a penny actually disappears. ---
1.005 * 100;                     // 100.49999999999999
Math.round(1.005 * 100) / 100;   // 1        ← expected 1.01
(1.005).toFixed(2);              // "1.00"   ← toFixed does NOT save you
(0.615).toFixed(2);              // "0.61"   ← nor here

Number.MAX_SAFE_INTEGER;         // 9007199254740991  ← exactness ends

Read the middle block carefully, because it is the one that costs money. One line of 12 units at $24.35 should be exactly $292.20. The float says 292.20000000000005, and seven such lines compound to 2045.4000000000003. Alone that rounds away harmlessly, but it will not stay alone: it gets multiplied by a tax rate, split across a partial shipment, compared against a payment, and eventually a chapter-4 reconciliation job reports a one-cent discrepancy nobody can explain.

The last block is worse because it is silent. 1.005 cannot be stored exactly and the nearest float sits fractionally below it, so multiplying by 100 gives 100.49999999999999, rounding gives 100, and your $1.005 line becomes $1.00. toFixed(2) does not rescue you: it also yields "1.00", and turns 0.615 into "0.61". MAX_SAFE_INTEGER is why raw integers are no free escape either: past about nine quadrillion, whole numbers stop being exact.

Core principle

No money value and no quantity value is ever a JavaScript number: not in a column, not in a JSON payload, not in a variable, not for a moment. The path is: NUMERIC in Postgres → string over the wire → exact decimal type in memory → string back to Postgres. A number anywhere in that chain is a defect, and your types should say so.

Which representation to use

RepresentationHow it worksStrengthsWeaknessesVerdict for an apparel ERP
JavaScript number (float) 64-bit binary floating point Fast; no library needed Cannot represent 0.1; errors compound; rounding silently wrong Never. Not for money, not for quantities
Integer minor units (cents) Store 2435 for $24.35; arithmetic in whole cents Exact for + and −; JSON-safe below 253 Division needs an allocation rule; unit prices often need 4 places; the number of minor units differs by currency (the yen has none, the Kuwaiti dinar has three) Excellent for totals and payments; awkward for per-unit costing
NUMERIC(14,4) + decimal.js Exact decimal in Postgres; arbitrary precision in TypeScript Exact; 4-place prices and fractional yardage; scale enforced by the column; explicit rounding modes Needs a boundary mapping layer; slower than floats The default choice. Prices, quantities, costs, tax
dinero.js v2 Immutable money objects: amount + currency + scale Every amount carries its currency, so adding USD to EUR throws "Objects must have the same currency" instead of quietly producing nonsense; allocate() splits without losing pennies Money only, not quantities; needs conversion at the database edge Great for invoice and payment logic; pair with decimal.js for quantities
big.js / bignumber.js Same family as decimal.js, same author big.js is tiny (about 6 kB minified); bignumber.js adds other bases big.js lacks some operations; three similar libraries invite mixing Fine alternatives, but pick one and forbid the others

Two words from that table. NUMERIC(14,4) is a Postgres column holding up to 14 digits in total, 4 of them after the decimal point, so up to ten digits before it, which is more than any apparel invoice needs. Immutable means an object is never changed in place: every dinero operation returns a new money object and leaves the original alone, so a value cannot be edited behind your back.

All four libraries are maintained. As of July 2026 the current releases are decimal.js 10.6.0, big.js 7.0.1, bignumber.js 11.1.5, and dinero.js 2.0.2. Version 2 left a years-long alpha with 2.0.0 on 2 March 2026. Dinero's allocate splits $10.00 three ways into 3.34, 3.33 and 3.33, so the pennies still sum to the original: the operation naive division always gets wrong. (Checked against dinero.js 2.0.2 in July 2026.)

// packages/core/src/money.ts
import Decimal from "decimal.js";

// Bankers' rounding by default: ties go to the even digit, so errors
// cancel over many lines instead of accumulating upward.
Decimal.set({ precision: 34, rounding: Decimal.ROUND_HALF_EVEN });

export type Money = Decimal;        // scale 4, matching NUMERIC(14,4)
export type Quantity = Decimal;     // scale 3, matching NUMERIC(14,3)

// Strings only. Accepting a number here would reopen the float hole.
export const money = (v: string): Money => new Decimal(v);

export function lineTotal(unitPrice: Money, quantity: Quantity): Money {
  return unitPrice
    .times(quantity)
    .toDecimalPlaces(4, Decimal.ROUND_HALF_UP);
}

/** Round to whole cents only at the moment of billing. */
export function toCents(m: Money): bigint {
  const c = m.times(100).toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
  return BigInt(c.toFixed(0));
}

// --- Verified behaviour ---
// new Decimal("0.1").plus("0.2").toString()          === "0.3"
// new Decimal("24.35").times(12).toString()          === "292.2"
// seven such lines summed                            === "2045.4"
// new Decimal("1.005")
//   .toDecimalPlaces(2, ROUND_HALF_UP).toFixed(2)    === "1.01"
// new Decimal("59.97").times("0.08875")
//   .toDecimalPlaces(2, ROUND_HALF_UP).toFixed(2)    === "5.32"

precision: 34 matches the 34 significant digits of decimal128, the IEEE standard format for exact decimal numbers. ROUND_HALF_EVEN is bankers' rounding (2.5 to 2, 3.5 to 4), so ties split evenly instead of drifting upward.

lineTotal rounds to 4 places to match the column, and toCents collapses to whole cents once, at billing. Rounding early and often is how a total stops matching its lines. (bigint is JavaScript's exact whole-number type, written with an n suffix as in 2435n, and it has no size limit, which makes it the right home for a count of cents.)

The commented block is verified output: every float failure above comes out right, including 1.005 becoming 1.01 and a $59.97 line at 8.875% tax giving exactly $5.32.

Enforcing it end to end

// packages/db/src/repositories/inventory.repository.ts
import "../types";              // registers the DATE/NUMERIC parsers
import Decimal from "decimal.js";
import type { Pool } from "pg";
import type { AtsPosition, InventoryRepository, StockMovement, TenantId }
  from "@erp/core/inventory/ports";

/** Postgres NUMERIC arrives as a STRING. That is the contract. */
interface PositionRow {
  sku: string;
  on_hand: string;      // ← not number
  allocated: string;    // ← not number
  inbound: string;      // ← not number
}

export function makeInventoryRepository(pool: Pool): InventoryRepository {
  return {
    async loadPositions(tenantId: TenantId, skus) {
      const { rows } = await pool.query<PositionRow>(
        `select sku,
            sum(delta) filter (
              where reason in ('receipt','shipment','adjustment')
            ) as on_hand,
            -sum(delta) filter (
              where reason in ('reservation','release')
            ) as allocated,
            coalesce(sum(inbound_qty), 0) as inbound
           from inventory_ledger
          where tenant_id = $1 and sku = any($2::text[])
          group by sku`,
        [tenantId, skus],
      );

      return rows.map((r): AtsPosition => ({
        sku: r.sku,
        onHand: new Decimal(r.on_hand ?? "0"),
        allocated: new Decimal(r.allocated ?? "0"),
        inbound: new Decimal(r.inbound ?? "0"),
      }));
    },

    async appendMovements(tenantId, movements, key) {
      const client = await pool.connect();
      try {
        await client.query("begin");
        const claimed = await client.query(
          `insert into idempotency_keys (tenant_id, key)
                values ($1, $2) on conflict do nothing returning key`,
          [tenantId, key],
        );
        if (claimed.rowCount === 0) {
          await client.query("rollback");
          return { applied: false };
        }
        for (const m of movements) {
          await client.query(
            `insert into inventory_ledger
               (tenant_id, sku, warehouse_id, delta, reason,
                reference_type, reference_id, idempotency_key)
             values ($1,$2,$3,$4::numeric,$5,$6,$7,$8)`,
            [tenantId, m.sku, m.warehouseId,
             m.delta.toFixed(3),   // Decimal → string, never number
             m.reason, m.referenceType, m.referenceId, key],
          );
        }
        await client.query("commit");
        return { applied: true };
      } catch (e) {
        await client.query("rollback");
        throw e;
      } finally {
        client.release();               // ← back to the pool, always
      }
    },
  };
}

The query sums the ledger three ways: shipments and receipts give on_hand, reservations and releases give allocated (negated, because a reservation is stored as a negative delta), and inbound_qty (the expected-receipt figure carried on confirmed purchase-order rows) gives inbound, which never counts toward what you may sell.

interface PositionRow is where the discipline becomes mechanical: declaring on_hand: string means that if anyone writes r.on_hand * 2, TypeScript refuses to compile it.

The declaration matches what the driver hands you. pg-types registers no parser for OID 1700, and node-postgres returns any type it has no parser for as the raw text Postgres sent. That is deliberate, because a Postgres NUMERIC holds values no JavaScript number can represent. The same reasoning makes int8 (Postgres's 64-bit integer) a string.

pool.connect() takes one connection so every statement runs on the same one. begin opens a transaction, commit makes everything in it permanent, and rollback throws it all away. Those three words are meaningless if the statements between them land on different connections.

The idempotency claim is one insert with on conflict do nothing returning key: if the key already exists, rowCount is 0 and we roll back. That is chapter 3's guarantee enforced by a unique constraint inside the database, rather than by a check in application code that two simultaneous requests can race past.

m.delta.toFixed(3) goes exact decimal → exact text → exact NUMERIC, and client.release() in a finally block returns the connection on every path, including the failing ones.

The build order: what to learn, in what sequence

Order matters more here than in most software, because these topics constrain each other. Build them out of sequence and you will rewrite your schema three times.

First: ledger modeling and isolation levels (chapters 1–2). These shape the schema, the hardest thing to change later. Once inventory is an append-only ledger of signed movements rather than a mutable quantity_on_hand column, everything follows: audit is free, corrections are new rows, concurrency stops being a lost-update problem, and "what did we think we had on 3 March?" becomes a where created_at <= ... clause. Build: the ledger table, movement append, a position query, and a concurrency test firing 50 simultaneous reservations at 10 units that proves you never oversell.

Second: the Shopify reconciliation pattern (chapters 3–4). Take one integration all the way: webhook verified, event stored under the provider's delivery id as idempotency key, worker processes it exactly once, outbox publishes your changes, and a scheduled job compares both systems and reports drift.

Do this once, properly, and you have the template for every integration that follows: the 3PL (a third-party logistics provider, an outside warehouse that picks and ships for you), the payment processor, the EDI feed (Electronic Data Interchange, a family of decades-old standard message formats, such as X12 in North America, that big retailers still use to send purchase orders). They differ in payload. They are identical in structure. Skip it and you hire someone to fix mismatches by hand.

Tenancy and sync come last

Third: RLS multi-tenancy (chapter 5). The tables are settled, so put the walls up: enable row-level security on every tenant-scoped table, write the policies, index the tenant column, and prove in tests that tenant A cannot see tenant B's rows through any path, whether query, join, aggregate or foreign key lookup. RLS is third because policies are written against a settled schema, but it must come before real customers: retrofitting tenancy onto a live system is dangerous work.

Fourth and last: offline sync (chapter 6). It is last because it is the hardest thing in the book and depends on everything else being stable. It needs a settled schema, because the client mirrors it; idempotency, because a reconnecting device replays writes; a conflict-resolution story, answerable only once you know what your ledger means; and tenancy, because the sync stream must be filtered by the database's own rules. Attempt it early and you will debug distributed state machines against a weekly-changing schema.

What you now know. How to model facts so they cannot be lost, how to make concurrent writes safe, how to make retries harmless, how to keep two systems honest about each other, how to keep tenants apart in the database, how to keep working when the network does not, how to accept messy human data, how to make reads fast without making them wrong, and how to prove it all still works tomorrow. None of those are anyone's platform skills. They will outlive every tool named here.

Your first vertical slice

Start with the smallest complete slice: one tenant, one style with a few SKUs, an append-only inventory ledger, a wholesale order that reserves against it in a transaction, and one server action calling one pure function in packages/core with a unit test and no database. Perhaps two hundred lines.

It will feel too small. Build it anyway: it is a vertical slice (one thin path that touches every layer, from the click to the ledger row), and every feature after it varies a path you have already walked.

Keep the discipline: rules in the core, plumbing at the edges, decimals never floats, dates never instants, tenants never trusted from the client.

The layering rule that keeps the project testable The layering rule that keeps the project testable. Each band may use the one below it and must know nothing about the one above. The green band is the point of the whole arrangement: your actual business rules live in ordinary functions that take values and return values, with no database connection and no web framework anywhere near them. That is what lets you test the allocation logic in milliseconds, and reuse the identical code in a background worker where there is no HTTP request at all. DEPENDENCIES POINT INWARD, ALWAYS React components — buttons, forms, tables know about pixels; know nothing about allocation rules Route handlers (your router over node:http) authenticate · set tenant · validate input · call the core Application services open the transaction · orchestrate · write the outbox row packages/core — the domain pure functions: canAllocate(), priceLine(), nextStatus() no database, no HTTP, no framework — testable in milliseconds Repositories — the only code that speaks SQL interfaces defined by the core, implemented outside it PostgreSQL outer layers may call inward inner layers must not call outward
The layering rule that keeps the project testable. Each band may use the one below it and must know nothing about the one above. The green band is the point of the whole arrangement: your actual business rules live in ordinary functions that take values and return values, with no database connection and no web framework anywhere near them. That is what lets you test the allocation logic in milliseconds, and reuse the identical code in a background worker where there is no HTTP request at all.

Field notes & further reading

Exercise

1. The core package, proven portable. Create packages/core with no dependency on postgres, node:http, or anything but the language. Its package.json should list only decimal.js. Write availableToSell, planAllocation, and a reserveOrder use case taking its repositories as arguments. Write a Vitest suite with an in-memory fake repository covering four cases: full allocation, partial allocation with a shortfall, outright rejection, and a repeated idempotency key applied only once. Then call the same reserveOrder from a server action in apps/admin and from a script in apps/worker. When you are done: the suite runs green in under a second with no database anywhere, and the identical rule executes correctly from both a browser click and a command line.

2. Break it, then make it unbreakable. On a scratch branch, add a cancel_date timestamptz column and a float-based line total. Enter a 15 March cancel date with TZ=Australia/Sydney, read it back with TZ=America/New_York, and watch the date slide to the 14th. (Direction matters: a date entered in New York and read in Sydney stays on the 15th, which is why this bug hides until the wrong pair of offices looks at it.) Separately, sum seven invoice lines of 12 units at $24.35 using JavaScript numbers. Now fix both: change the column to DATE, register setTypeParser overrides for OIDs 1082 and 1700, switch to NUMERIC(14,4) with decimal.js, and type every money and quantity row field as string. When you are done: the cancel date reads as "2026-03-15" in every timezone you can set, the total is exactly 2045.40, and multiplying a money field by a number is a compile error rather than a runtime surprise.