Part 2 — Core Engineering
10 Stack Specifics: Next.js, Supabase, Turborepo
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 chapter
- What you need to know first
- The shape of the repository
- The framework-free core
- Wiring the core into Next.js: server actions vs route handlers
- tRPC, server actions, or REST?
- Supabase Postgres: what you get, and where it bites
- Zod: one definition of "valid," shared by both sides
- Timezone discipline: instants versus calendar dates
- Decimal arithmetic: never, ever floats
- 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.
React, Next.js, 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."
Next.js is a framework: 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, Zod) is a tool you call. A framework is a house you move into, and it calls you.
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 zod 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 itself, or a faster alternative such as pnpm. 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. Turborepo knows which project depends on which, runs tasks in the right order, and caches results so unchanged projects are never rebuilt. Its convention is 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 Zod 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 and shared dev tools
├── pnpm-workspace.yaml # which folders are projects
├── turbo.json # how to build/test/lint, and what to cache
│
├── apps/
│ ├── admin/ # Next.js app: the internal ERP UI
│ ├── 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/ # Zod schemas shared by client and server
└── tsconfig/ # shared TypeScript settings
turbo.json holds each task's recipe: to build apps/admin, first build packages/core, and if core has not changed, reuse the cache. apps/ holds what you deploy: admin is the Next.js site 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(Zod schemas 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,
"packageManager": "pnpm@11.17.0",
"devDependencies": {
"turbo": "^2.10.7",
"typescript": "^7.0.2"
},
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"dev": "turbo run dev"
}
}
Read that file top to bottom:
"name"is what the repository calls itself."private": truemeans "never publish this to npm.""packageManager"pins the exact package manager version."devDependencies"lists tools needed only while you work, and they never ship to a customer's browser.- The
^in"^2.10.7"is a version range: install 2.10.7 or any later 2.x release, but never 3.x, because a change in the first number is allowed to break things. "scripts"gives short names to long commands, sopnpm run buildrunsturbo run build, which builds every project in the right order.
Those three pinned versions were the current releases in July 2026. Look up the current ones before you copy these into a repository of your own.
# pnpm-workspace.yaml (repository root)
packages:
- "apps/*"
- "packages/*"
pnpm-workspace.yaml is what turns a folder of folders into a monorepo. Every directory beneath those paths that has 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.
Which file holds that list matters: pnpm reads only pnpm-workspace.yaml. npm, Yarn and Bun instead take the list from a "workspaces" array inside package.json, and pnpm will not read that array. Put the list in the wrong place and pnpm prints "The workspaces field in package.json is not supported by pnpm. Create a pnpm-workspace.yaml file instead", then fails to resolve every local import. (Verified against pnpm 11, July 2026.)
The framework-free core
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 Next.js 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 Next.js: server actions vs route handlers
Next.js gives you two ways to run server code. A server action (Next's docs also say Server Function) is an ordinary async function marked 'use server'. You import it into a component and call it like a normal function. Next turns that call into an HTTP request. No URL, no JSON, no fetch. It is for your UI talking to your server.
A route handler is a file named route.ts exporting functions named after HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. It gives a stable URL and control over status codes, headers and the raw body, for anyone who is not your UI: a webhook, a customer's script, a health check.
(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.)
A server action, step by step
// apps/admin/app/orders/actions.ts
"use server";
import Decimal from "decimal.js";
import { updateTag } from "next/cache";
import { reserveOrder } from "@erp/core/orders/reserve-order";
import { reserveOrderInput } from "@erp/contracts/orders";
import { getSession } from "@/lib/auth";
import { makeDeps } from "@/lib/deps";
export async function reserveOrderAction(
_prev: unknown,
formData: FormData,
) {
// 1. AUTHENTICATE — a server action is a public HTTP endpoint.
const session = await getSession();
if (!session) return { ok: false as const, error: "Not signed in" };
// 2. VALIDATE at the boundary. Never trust the browser.
const parsed = reserveOrderInput.safeParse({
orderId: formData.get("orderId"),
warehouseId: formData.get("warehouseId"),
revision: Number(formData.get("revision")),
allowPartial: formData.get("allowPartial") === "on",
cancelDate: formData.get("cancelDate"),
customerEmail: formData.get("customerEmail"),
lines: JSON.parse(String(formData.get("lines") ?? "[]")),
});
if (!parsed.success) {
return { ok: false as const, issues: parsed.error.issues };
}
// 3. CONVERT decimal strings into exact Decimal values.
const lines = parsed.data.lines.map((l) => ({
lineId: l.lineId,
sku: l.sku,
quantity: new Decimal(l.quantity),
}));
// 4. AUTHORISE — tenant comes from the session, NEVER from the form.
const result = await reserveOrder(makeDeps(), {
tenantId: session.tenantId,
orderId: parsed.data.orderId,
warehouseId: parsed.data.warehouseId,
allowPartial: parsed.data.allowPartial,
lines,
idempotencyKey:
`${parsed.data.orderId}:reserve:${parsed.data.revision}`,
});
// 5. Invalidate the cached ATS views (chapter 8).
updateTag(`ats:${session.tenantId}`);
return { ok: true as const, result };
}
"use server" marks every exported function as server-only. Next compiles each one into an endpoint (a URL on your server that accepts requests) and gives the browser only a stub, a tiny placeholder function that makes the network call. The bundle (the JavaScript file the browser downloads) therefore never contains this code.
Step 2 uses formData, the browser's collection of submitted form fields, and safeParse, which returns a result object saying "valid, here is the data" or "invalid, here are the problems" instead of throwing an error.
Step 1 matters more than it looks, because the framework does not authenticate for you. Next's own guidance is blunt: "Always authenticate and authorize users before performing sensitive server-side operations. Read authentication from cookies or headers rather than accepting tokens as function parameters."
Step 3 is the seam between the outside world and the core. Zod hands back quantities as decimal strings (the Zod section explains why), and reserveOrder wants exact Decimal values, so the conversion happens here, once, at the edge. Step 4 holds the most important line: tenantId: session.tenantId. Take it from the form instead and a user can type another company's id and read their inventory. That is chapter 5's threat model in one field. The idempotency key is order plus revision, so a double-click submits the same key and the second attempt applies nothing.
Step 5 marks the tenant's cached ATS stale: chapter 8's invalidation, in framework terms. In Next.js 16 use updateTag inside a server action, which expires the tag immediately so the user sees their own write on the next render. Its sibling revalidateTag(tag, "max") is for route handlers and webhooks, where serving slightly stale data while fresh data loads in the background is fine. The old one-argument revalidateTag(tag) still runs, but Next's docs now mark it deprecated.
Verifying a Shopify webhook
Shopify has no session cookie and cannot call a server action. It needs a URL.
// apps/admin/app/api/webhooks/shopify/route.ts
import crypto from "node:crypto";
import { recordExternalEvent }
from "@erp/core/integrations/record-external-event";
import { makeDeps } from "@/lib/deps";
export const runtime = "nodejs"; // node:crypto needs Node, not edge
export async function POST(request: Request) {
// Read the RAW body. The signature covers the exact bytes sent,
// so nothing may parse or re-serialise them first.
const raw = await request.text();
const sent = request.headers.get("x-shopify-hmac-sha256") ?? "";
const expected = crypto
.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!)
.update(raw, "utf8")
.digest("base64");
const ok =
sent.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
if (!ok) return new Response("bad signature", { status: 401 });
// Store and acknowledge fast. Do the real work in apps/worker.
await recordExternalEvent(makeDeps(), {
source: "shopify",
externalAccount: request.headers.get("x-shopify-shop-domain") ?? "",
// Dedupe on the DELIVERY id, per Shopify's docs.
idempotencyKey: request.headers.get("x-shopify-webhook-id") ?? "",
// The EVENT id groups deliveries from one merchant action.
eventId: request.headers.get("x-shopify-event-id") ?? "",
topic: request.headers.get("x-shopify-topic") ?? "unknown",
payload: raw,
});
return new Response(null, { status: 200 });
}
The folder path becomes the URL and the exported function name the method it answers. runtime = "nodejs" asks for the full Node environment. Next can also run route handlers on a lighter "edge" runtime that lacks Node's built-in modules, and node:crypto is one of those modules. await request.text() reads the body as raw text before anything parses it. Verification depends on the exact bytes Shopify sent, and 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.
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.
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.
Marking a function 'use server' publishes it. Next compiles it into an HTTP endpoint that anyone able to reach your site can call with any arguments they like. Every action must authenticate, validate its inputs, and take the tenant from the session. Treat each one as a public API, because that is what it is.
tRPC, server actions, or REST?
RPC is Remote Procedure Call: calling a function on another computer should look like calling one here. tRPC is a TypeScript-first RPC toolkit: define procedures on the server and the client gets autocomplete and type checking with no code generation, because both sides are one TypeScript project. 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, clients in any language, and mock servers.
| Concern | Server actions | tRPC | Versioned REST + OpenAPI |
|---|---|---|---|
| Who calls it | Your own Next.js UI only | Any TypeScript client in the monorepo | Anyone, in any language |
| Type safety | End to end, within one app | End to end, across apps, no codegen | Via clients generated from the OpenAPI document |
| Setup cost | Zero, built in | Small: a router, a context, one route handler | Real: versioning, docs, keys, rate limits, deprecation |
| Works with plain forms / no JavaScript | Yes, built in | No | No |
| Stable URLs an outsider can depend on | No, identifiers change between builds | Not for outside consumers | Yes, that is the point |
| Cost of a breaking change | Free, both sides deploy together | Free within the monorepo | Expensive: version and support the old shape |
| Use it for | Admin UI mutations and forms | A shared internal API across several apps | Customer and partner integrations |
The practical sequence: start with server actions only. They are free, built in, and enough for one admin app for a long time. Add tRPC the day a second TypeScript client appears. Add REST only when someone outside your control needs access.
// packages/api/src/router.ts — tRPC v11, once a second client exists
import { initTRPC, TRPCError } from "@trpc/server";
import {
reserveOrderInput, atsQueryInput, toReserveCommand,
} from "@erp/contracts/orders";
import { reserveOrder } from "@erp/core/orders/reserve-order";
import { readAtsSnapshot } from "@erp/core/inventory/read-ats";
import type { Ctx } from "./context";
const t = initTRPC.context<Ctx>().create();
/** Middleware: every procedure below is authenticated and tenant-scoped. */
const authed = t.procedure.use(({ ctx, next }) => {
if (!ctx.session) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, tenantId: ctx.session.tenantId } });
});
export const appRouter = t.router({
inventory: t.router({
ats: authed
.input(atsQueryInput) // a Zod schema
.query(({ ctx, input }) =>
readAtsSnapshot(ctx.deps, { tenantId: ctx.tenantId, ...input })),
}),
orders: t.router({
reserve: authed
.input(reserveOrderInput)
// The input carries decimal STRINGS; the core wants exact
// Decimal values, so convert here exactly as the server
// action does in its step 3.
.mutation(({ ctx, input }) =>
reserveOrder(ctx.deps, toReserveCommand(ctx.tenantId, input))),
}),
});
// The client imports ONLY this type:
export type AppRouter = typeof appRouter;
initTRPC.context<Ctx>().create() declares what the per-request context holds: session, database handle, dependency bag. t.procedure.use(...) defines middleware (code that runs before every procedure built on it), so unauthenticated calls are rejected centrally and the tenant id lives in context where no procedure can forget it.
.query() is for reads, .mutation() for writes: tRPC's only verb distinction, because in RPC the meaning lives in the name. toReserveCommand is the shared helper that turns the validated decimal strings into exact Decimal values and builds the idempotency key, so the tRPC entry point and the server action feed the core identical commands.
Exporting the router's type and nothing else gives the client full autocomplete while shipping zero server code, and every procedure body is one line delegating to @erp/core.
When to add the REST surface
Add it when someone outside your codebase needs programmatic access: a buyer polling stock, a marketplace pulling your catalog. You then accept three obligations:
- Versioning: the URL carries
/v1/and its shape never breaks, whatever your database does. - A published contract: an OpenAPI document generated from the same Zod schemas, so it cannot drift.
- A deprecation policy: how long
/v1/lives after/v2/.
When you add it, it is a thin translation over @erp/core. Three surfaces, one brain.
Supabase Postgres: what you get, and where it bites
Supabase is managed Postgres with useful things pre-attached, and it is a good starting point for one reason: it is real Postgres. Everything from chapters 1 to 8 works unchanged, and pg_dump (Postgres's own command for writing an entire database out to a file) moves you to any other host.
What comes attached:
- Row-level security enforced by the database itself, chapter 5's mechanism:
ENABLE ROW LEVEL SECURITYplus policies that act like an invisibleWHEREclause on every query. - Auth primitives: accounts, sessions, and JWTs. (A JWT, or JSON Web Token, is a small signed blob of facts about the signed-in user. The facts inside are called claims, and policies read them with
auth.uid()andauth.jwt().) - Point-in-time recovery, a paid add-on that continuously archives the write-ahead log (the running record Postgres writes before it changes any data) using the WAL-G tool. Supabase's documentation says it backs those files up at two-minute intervals by default (checked July 2026), which is what lets you restore to a chosen moment with seconds of granularity rather than to last night's snapshot.
- And PowerSync compatibility, since PowerSync drives chapter 6's offline sync by reading Postgres's logical replication stream, the live feed of row changes that Postgres can publish to other systems.
Two production gotchas. First, write (SELECT auth.uid()) = user_id rather than auth.uid() = user_id: wrapping the call in a sub-select lets the Postgres planner run it once per statement instead of once per row, and Supabase's own published benchmark shows a query dropping from 179ms to 9ms (Supabase RLS performance docs, July 2026).
Second, add an index on every column your policies reference, the tenant column above all. The same benchmark page measures 171ms falling to under 0.1ms.
The service_role key needs a rule of its own: it is the administrative key Supabase issues to your server, and a client created with it and no signed-in user's token bypasses row-level security completely. Keep it in server-side code and out of the browser.
Connection pooling: the thing that breaks on deploy day
A connection pool is a set of already-open database connections kept ready for reuse. Opening one is expensive (handshake, authentication, a whole server-side process), and a Postgres server accepts only a limited number at once: the PostgreSQL manual puts the default max_connections at "typically 100", and hosted plans commonly sit in the tens to low hundreds.
The problem is serverless hosting: your app runs as many short-lived function instances that the platform starts on demand and throws away, and each one wants a connection. Supabase's answer is Supavisor, a pooler that sits in front of the database and shares a small number of real connections among many thousands of client connections.
Picking the wrong one of its three connection strings is the classic deploy-day outage.
| Mode | Host / port | How it lends connections | Use it for | Prepared statements |
|---|---|---|---|---|
| Direct | db.<ref>.supabase.co:5432 | A dedicated connection for the life of your process | Migrations, pg_dump, long-lived servers | Yes |
| Supavisor — session mode | aws-<region>.pooler.supabase.com:5432 | One real connection held for your whole session | A direct-connection substitute on IPv4-only networks | Yes |
| Supavisor — transaction mode | aws-<region>.pooler.supabase.com:6543 | Borrowed per transaction, then returned | Serverless and edge functions, many transient clients | No |
That IPv4 row needs a word of explanation. IPv4 and IPv6 are the two generations of internet address, and some networks and hosting platforms still speak only the older IPv4. Supabase direct connections are IPv6 unless you buy the IPv4 add-on, while the Supavisor pooler answers on IPv4 everywhere, so session mode is the drop-in replacement when your host cannot reach the database directly.
# apps/admin/.env — three URLs, three jobs. Do not mix them up.
# Copy the exact hosts from the Supabase dashboard. The pooler host
# has the form aws-N-REGION.pooler.supabase.com: both the number and
# the region differ per project, so never type it from memory.
# 1. Serverless request handling. Port 6543 = transaction mode.
DATABASE_URL="postgresql://postgres.REF:PW@POOLER_HOST:6543/postgres"
# 2. Schema migrations and pg_dump. Direct, port 5432, no pooler.
DIRECT_URL="postgresql://postgres:PW@db.REF.supabase.co:5432/postgres"
# 3. The long-running worker. Session mode, port 5432 on the pooler.
WORKER_URL="postgresql://postgres.REF:PW@POOLER_HOST:5432/postgres"
Port 6543 is transaction mode, used by server actions and route handlers: they run briefly, do one transaction, and vanish. DIRECT_URL on 5432 bypasses the pooler, which is what migrations need. A migration is a script that changes your database's structure, adding a table or an index.
Migrations issue session-level commands such as CREATE INDEX CONCURRENTLY (which builds an index without blocking reads and writes on the table, and which Postgres refuses to run inside a transaction) and advisory locks (a flag one connection holds so a second copy of the migration waits). Both assume they keep the same connection across several statements, and a pooler that reassigns your connection mid-migration breaks them.
The worker uses session mode: one long-lived process holding a few connections.
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, which is precisely why transaction mode cannot support them: your driver prepares s1 on connection A, the transaction ends, A goes back to the pool, and your next call lands on B, asks for s1, and gets "prepared statement s1 does not exist". Supabase's docs say it flatly: "Transaction mode does not support prepared statements. To avoid errors, turn off prepared statements for your connection library."
// packages/db/src/client.ts
import { Pool } from "pg";
import postgres from "postgres";
/**
* node-postgres ("pg"): does NOT use prepared statements by default,
* so ordinary pool.query(text, values) calls are safe under
* transaction pooling. Danger appears only if you opt in with `name`.
*/
export const pgPool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5, // per instance — keep this SMALL
idleTimeoutMillis: 10_000,
connectionTimeoutMillis: 5_000,
});
// SAFE under transaction pooling — parameterised, but unnamed:
export const findStyle = (tenantId: string, styleCode: string) =>
pgPool.query(
`select id, style_code, name
from styles
where tenant_id = $1 and style_code = $2`,
[tenantId, styleCode],
);
// UNSAFE under transaction pooling — `name` creates a *named* prepared
// statement bound to one physical connection:
// pgPool.query({ name: "find_style", text: "...", values: [...] })
/** postgres.js ("postgres"): prepares by default. Must be turned OFF. */
export const sql = postgres(process.env.DATABASE_URL!, {
prepare: false, // ← required on port 6543
max: 5,
});
/** Migrations get their own client on DIRECT_URL. Prepared is fine. */
export const migrationSql = postgres(process.env.DIRECT_URL!, { max: 1 });
max: 5 is per serverless instance, so a hundred instances at five each means five hundred connections hitting Supavisor. Keep it small. findStyle uses $1/$2 placeholders with a separate values array. That is parameterization, which stops SQL injection, and it does not create a named prepared statement, so it passes safely through the pooler. The commented-out call is the trap: a name property tells node-postgres to cache the plan on one physical connection. postgres.js prepares everything by default, so prepare: false is mandatory on port 6543.
"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. Disable prepared statements on the pooled connection, and always run migrations on the direct one.
Zod: one definition of "valid," shared by both sides
Zod is a schema library. Describe the shape of data once and get two things: a runtime check that rejects bad input, and a TypeScript type derived from that same description. Write the shape 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. Zod 4 is the current stable line (4.4.3 as of July 2026).
// packages/contracts/src/orders.ts — imported by browser AND server
import { z } from "zod";
/** A quantity: a decimal string, not a JS number. Explained below. */
const quantityString = z
.string()
.regex(/^-?\d+(\.\d{1,3})?$/, { error: "Up to 3 decimal places" });
/** Money: a decimal string, max 4 places, matching NUMERIC(14,4). */
const moneyString = z
.string()
.regex(/^-?\d+(\.\d{1,4})?$/, { error: "Up to 4 decimal places" });
export const orderLineInput = z.object({
lineId: z.uuid(),
sku: z.string().min(1).max(64).regex(/^[A-Z0-9-]+$/, {
error: "SKUs are uppercase letters, digits and hyphens",
}),
quantity: quantityString,
unitPrice: moneyString,
});
export const reserveOrderInput = z.object({
orderId: z.uuid(),
revision: z.int().nonnegative(),
warehouseId: z.uuid(),
allowPartial: z.boolean().default(false),
cancelDate: z.iso.date().nullable(), // a DATE, not a timestamp
lines: z.array(orderLineInput).min(1).max(500),
customerEmail: z.email(),
});
// The type is DERIVED. There is no second source of truth.
export type ReserveOrderInput = z.infer<typeof reserveOrderInput>;
// { orderId: string; revision: number; warehouseId: string;
// allowPartial: boolean; cancelDate: string | null;
// lines: {...}[]; customerEmail: string }
Take the pieces in turn. z.object({...}) says "an object with exactly these fields," z.array(x).min(1).max(500) says "a list of between 1 and 500 of those," and .regex(...) checks a string against a regular expression, a compact pattern language for text. Read /^-?\d+(\.\d{1,4})?$/ as: an optional minus sign, one or more digits, then optionally a dot and one to four more digits, and nothing else.
Zod 4 idioms, and why money is a string
Several idioms changed in version 4. z.uuid(), z.email() and z.iso.date() are now top-level functions. The older z.string().uuid() and z.string().email() forms still work, but the migration guide marks them deprecated. A single error key replaces v3's message, invalid_type_error, required_error and errorMap. z.iso.date() accepts a YYYY-MM-DD string 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.)
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. z.infer<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: server action, 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.
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.
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.
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
| Representation | How it works | Strengths | Weaknesses | Verdict 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 Next.js or Supabase 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.
Field notes & further reading
- Supabase — Connecting to your database: the three connection modes, ports 5432 vs 6543, and the statement that transaction mode does not support prepared statements.
- Supabase — Row Level Security: the policy syntax, plus the benchmarks behind wrapping
auth.uid()in a sub-select and indexing every column a policy touches. - Supabase — Database backups and PostgreSQL — Connection settings: where point-in-time recovery, WAL-G and the two-minute WAL interval are described, and the source for the default
max_connectionsof 100. use server, Route Handlers andupdateTag: the two server entry points, Next's guidance on authenticating inside every action, and when to useupdateTaginstead ofrevalidateTag.- Zod 4 migration guide: the
errorparameter replacingmessage/invalid_type_error/errorMap, and top-levelz.email()andz.uuid(). - node-postgres — Types, the pg-types registry and the postgres-date parser it uses: why unregistered types come back as strings, and the source comment "Force YYYY-MM-DD dates to be parsed as local time".
- Shopify — HTTPS webhook delivery: the HMAC header, the raw-body rule, the five-second budget, and which header to deduplicate on.
- MDN — Temporal and caniuse: Temporal:
PlainDatevsInstantvsZonedDateTime, and the support picture. - Dinero.js, decimal.js, pnpm workspaces and Turborepo — Structuring a repository: money objects with currency in the type, and the
apps/+packages/convention.
1. The core package, proven portable. Create packages/core with no dependency on Next.js, pg, or any HTTP library. 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.