A small double-entry bookkeeping API — accounts, transactions, and the immutable entries that belong to them. Balances are always derived by summing entries, never stored and mutated directly. TypeScript on AWS Lambda, Aurora Serverless v2 (Postgres) via the RDS Data API, IAM-authenticated (SigV4) API Gateway.
Meant to be called by other services/applications, not end users directly — see the auth section below.
For why things are built this way, and how the deployment infra fits
together, see the design doc and build journal in the sibling operations
repo: operations/docs/projects/ledger-api/design.md and
operations/docs/projects/ledger-api/journal.md. The original domain
design is operations/ledger-api-plan.md.
src/
app.ts Hono app: mounts every router, error handling
index.ts Lambda entrypoint — handle(app)
db.ts Drizzle client factory — aws-data-api/pg in Lambda, node-postgres in tests
schema.ts accounts / transactions / entries tables
routes/ accounts.ts, transactions.ts, balances.ts
lib/
validation.ts zod schemas, zJson (422 on body validation failure), zIdParam (404 on malformed :id)
errors.ts ApiError, isUniqueViolation (idempotency-replay detection, cross-driver)
ledger.ts shared balance/lookup queries
case.ts / response.ts snake_case wire format bridge
drizzle/ SQL migrations (drizzle-kit generate), including the zero-sum constraint trigger
openapi/ledger-api.yaml full API spec
tests/ vitest, against a real local Postgres (see design.md's "Testing tradeoff")
| Command | Action |
|---|---|
npm install |
Install dependencies |
npm run lint |
Lint (oxlint) |
npm run typecheck |
Typecheck |
docker compose up -d |
Start a local Postgres for tests (matches CI: postgres:16, db ledger, user/pass postgres) |
npm test |
Run tests — needs DATABASE_URL pointed at a local Postgres (defaults to the compose instance) |
npm run build |
Bundle src/index.ts into dist/index.mjs (esbuild) |
npm run db:generate |
Generate a migration from src/schema.ts |
npm run db:migrate |
Apply migrations — targets Data API if RESOURCE_ARN/SECRET_ARN are set, else DATABASE_URL |
npm run start:local |
Build, then serve the real Lambda handler behind a local API Gateway emulator (see below) |
npm run start:local runs the app the way it actually runs in prod — the
built dist/index.mjs Lambda handler, invoked in a containerized Lambda
runtime behind a local HTTP API Gateway emulator (sam local start-api,
via template.yaml) — rather than a plain Node dev server. That means
requests go through the same APIGatewayProxyEventV2
translation/hono/aws-lambda code path production traffic does, so
payload-shape bugs show up locally instead of only after deploy.
Requires AWS SAM CLI
(brew install aws-sam-cli) and Docker. One-time setup:
docker compose up -d # start Postgres
DATABASE_URL=postgres://postgres:postgres@localhost:5432/ledger \
npm run db:migrate # apply migrationsThen:
npm run start:local # serves http://127.0.0.1:3000
curl -X POST http://127.0.0.1:3000/accounts \
-H 'content-type: application/json' \
-d '{"name": "cash", "type": "asset", "currency": "USD"}'template.yaml points the function at postgres://postgres:postgres@postgres:5432/ledger
— the sam local container joins the compose project's Docker network
(--docker-network ledger-api_default, wired into the npm script) and
reaches Postgres by its compose service name, not localhost.
Not covered: AWS_IAM auth. In prod that's enforced by API Gateway
before the Lambda is ever invoked, and sam local doesn't emulate
authorizers at all — there's no app-level auth behavior to exercise
either way. See operations/docs/runbooks/ledger-api-deployment.md for
testing SigV4-signed requests against a deployed stack.
See openapi/ledger-api.yaml for the full contract; summary:
POST/GET /accounts,GET /accounts/{id}GET /accounts/{id}/balance— current, or?as_of=YYYY-MM-DDGET /accounts/{id}/entries— paginatedPOST /transactions— requiresidempotency_key; a duplicate returns the original transaction with200GET /transactions/{id}GET /balances— all accounts, current or?as_of=
Every route requires AWS_IAM (SigV4). Callers need an IAM policy
granting execute-api:Invoke on this API's ARN — see
operations/docs/runbooks/ledger-api-deployment.md for the exact policy
shape and how to sign requests.