API (api-v2)
Structure & conventions
api-v2 (GitHub totlob/cypher) is a strict-TypeScript Express service that ports the original Go backend. Thin transport routers sit over 34 domain modules. Each module owns its tables and talks to other modules only through their services.
Stack
| Concern | Choice |
|---|---|
| Language | TypeScript 5.7, strict, ES2022, NodeNext ESM. Node ≥ 20 (CI runs 24). |
| HTTP | Express 4 + @ts-rest/express, with compression and cookie-parser |
| Validation | zod 3 schemas from the contract packages. Env is validated in src/config/env.ts, the only file that reads process.env. |
| Database | PostgreSQL 16 via pg. The pool converts snake_case columns to camelCase. |
| Queries | PgTyped: hand-written <module>.sql generates <module>.queries.ts (never edit the generated file) |
| Migrations | dbmate, in db/migrations/ (73 so far) |
| Cache | Redis (ioredis), read-through with per-namespace versioning. Optional. |
| Events | RabbitMQ (amqplib). Optional; dispatched in-process without a broker. |
| IDs | Integer identity PKs; uuidv7 only for genuinely new tables |
| Tests | vitest 2 + supertest against a real Postgres |
Layout
src/
app.ts middleware: cors → requestLog → compression → json → cookies
GET /health · ops router · docs · 5 audiences · errorHandler
server.ts boot; installs Redis + Mailgun; exits in prod without Mailgun
consumer.ts queue consumer process (ledger postings, search)
sync-rates.ts daily FX job (in progress)
config/env.ts zod-validated environment
api/<audience>/v1/ *.contract.ts + *.router.ts — transport only
modules/<name>/ the domain
<name>.sql <name>.queries.ts <name>.repository.ts
<name>.service.ts <name>.mapper.ts
modules/ownership.ts table → owning module
shared/ auth/ cache/ db/ fx/ http/ mail/ media/ payments/ queue/ search/
money.ts filters.ts date.ts
src/modules/category/ is the canonical module to copy when adding a new one.
Pure logic lives in separate files so it can be unit-tested without a database: discount.resolve.ts, ledger.posting.ts, jarde.plan.ts, shift.math.ts, pos.payments.ts, product.cook.ts.
Request lifecycle
POST /<audience>/<service>/v1/<kebab-name>with a JSON body.shopIdis in the body for shop-scoped routes.authenticateverifies the bearer JWT. It's stateless.requireShopAccess(req => req.body.shopId)resolves owner or staff access onto the request.- A permission guard runs (see Auth, roles & permissions).
- The handler reads
verifiedShopId(req), calls the service, and returns{ status, body }. - The service calls the repository, which calls PgTyped queries. The mapper is the only place a database row becomes a contract shape.
Audiences
| Mount | Clients | Notes |
|---|---|---|
/merchant/* | Dashboard | The largest surface |
/pos/v1/* | POS | Fully implemented against contracts-pos 0.1.14 |
/storefront/v1/* | Merchant websites | Swagger at /storefront/docs |
/internal/* | Internal tools | Mounted only with INTERNAL_SECRET_KEY. Docs at /internal/docs. |
/internal/ops/v1/* | Ops scripts | Mounted only with OPS_TOKEN |
/customer/* | — | Empty mount |
Errors
src/shared/http/errors.ts defines AppError and its subclasses, and error-handler.ts maps them to HTTP statuses:
| Class | Status |
|---|---|
ValidationError | 400 |
UnauthorizedError | 401 |
ForbiddenError, AppNotInstalledError | 403 |
NotFoundError | 404 |
ConflictError | 409 |
TooManyRequestsError | 429, with Retry-After |
ServiceUnavailableError | 503 |
| anything else | 500, with no detail |
Tenancy leaks are avoided by design. A shop you don't own is a 403. A row that isn't in your shop is a 404 (400 in entity), so callers can't probe other tenants' IDs.
Module boundaries
- Every table has one owning module (
src/modules/ownership.ts). A module's SQL may read only its own tables plusreferencelookups. - Cross-module calls go service to service and are batch-shaped: IDs in,
Mapout. They takedb: Db = poolas the last argument so they can join a caller's transaction. - This matters because the cache invalidates by bumping a version per namespace (
src/shared/cache/namespace.ts). A foreign write would leave stale cache. - Orchestrators own no tables:
pos,accounting,catalogue-import,reports,jarde,inventory-screen,places. test/unit/module-boundaries.test.tsenforces table ownership and the absence of import cycles.
Status per module-status.md (2026-08-10): 0 foreign writes, 0 import cycles, 31 foreign reads left. Two decisions are still open: the direction of order ↔ return, and the shift → order cycle.
Money
src/shared/money.tsuses bigint at scale 6, never a JS number.- Journal amounts are
numeric(20,6)and exchange rates arenumeric(20,10). - Catalogue prices are stored in USD and converted to the shop's currency on read.
Events
The topic exchange is RABBITMQ_EXCHANGE (default totlob.accounting). Queues, each with a dead-letter queue:
| Queue | Binding | Events |
|---|---|---|
.postings | accounting.# | accounting.order.paid |
.search | search.# | search.product.changed/removed, search.stock.changed, search.category.*, search.section.*, search.shop.* |
Module index
| Area | Modules | Page |
|---|---|---|
| Catalogue | shop, category, product, tag, search, entity, modifier, media, section | Catalogue modules |
| Orders | order (+ refund), return, fulfillment, carrier | Orders, refunds & returns |
| Stock | inventory, inventory-screen, location, places, jarde | Inventory & locations |
| Pricing | discount | Discounts |
| Till | pos, shift, till-section | POS & shifts |
| People | auth, customer, staff, notification, onboarding | Customers, staff & shop |
| Money | ledger, accounting, reports, billing | Ledger, FX & reports |
| Other | cart (storefront), app, catalogue-import, internal | Storefront, internal & ops |