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

ConcernChoice
LanguageTypeScript 5.7, strict, ES2022, NodeNext ESM. Node ≥ 20 (CI runs 24).
HTTPExpress 4 + @ts-rest/express, with compression and cookie-parser
Validationzod 3 schemas from the contract packages. Env is validated in src/config/env.ts, the only file that reads process.env.
DatabasePostgreSQL 16 via pg. The pool converts snake_case columns to camelCase.
QueriesPgTyped: hand-written <module>.sql generates <module>.queries.ts (never edit the generated file)
Migrationsdbmate, in db/migrations/ (73 so far)
CacheRedis (ioredis), read-through with per-namespace versioning. Optional.
EventsRabbitMQ (amqplib). Optional; dispatched in-process without a broker.
IDsInteger identity PKs; uuidv7 only for genuinely new tables
Testsvitest 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

  1. POST /<audience>/<service>/v1/<kebab-name> with a JSON body. shopId is in the body for shop-scoped routes.
  2. authenticate verifies the bearer JWT. It's stateless.
  3. requireShopAccess(req => req.body.shopId) resolves owner or staff access onto the request.
  4. A permission guard runs (see Auth, roles & permissions).
  5. The handler reads verifiedShopId(req), calls the service, and returns { status, body }.
  6. The service calls the repository, which calls PgTyped queries. The mapper is the only place a database row becomes a contract shape.

Audiences

MountClientsNotes
/merchant/*DashboardThe largest surface
/pos/v1/*POSFully implemented against contracts-pos 0.1.14
/storefront/v1/*Merchant websitesSwagger at /storefront/docs
/internal/*Internal toolsMounted only with INTERNAL_SECRET_KEY. Docs at /internal/docs.
/internal/ops/v1/*Ops scriptsMounted 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:

ClassStatus
ValidationError400
UnauthorizedError401
ForbiddenError, AppNotInstalledError403
NotFoundError404
ConflictError409
TooManyRequestsError429, with Retry-After
ServiceUnavailableError503
anything else500, 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 plus reference lookups.
  • Cross-module calls go service to service and are batch-shaped: IDs in, Map out. They take db: Db = pool as 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.ts enforces 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.ts uses bigint at scale 6, never a JS number.
  • Journal amounts are numeric(20,6) and exchange rates are numeric(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:

QueueBindingEvents
.postingsaccounting.#accounting.order.paid
.searchsearch.#search.product.changed/removed, search.stock.changed, search.category.*, search.section.*, search.shop.*

Module index

AreaModulesPage
Catalogueshop, category, product, tag, search, entity, modifier, media, sectionCatalogue modules
Ordersorder (+ refund), return, fulfillment, carrierOrders, refunds & returns
Stockinventory, inventory-screen, location, places, jardeInventory & locations
PricingdiscountDiscounts
Tillpos, shift, till-sectionPOS & shifts
Peopleauth, customer, staff, notification, onboardingCustomers, staff & shop
Moneyledger, accounting, reports, billingLedger, FX & reports
Othercart (storefront), app, catalogue-import, internalStorefront, internal & ops
Previous
Auth, roles & permissions