Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,24 @@ JWT_PREVIOUS_KEYS=
# upgrades new hashes immediately; existing hashes upgrade lazily on next login.
BCRYPT_SALT_ROUNDS=12

# Database Configuration
# Database Configuration (local / non-Docker development)
DATABASE_URL="postgresql://username:password@localhost:5432/learnault_db?schema=public"

# Docker Compose development stack (docker-compose.yml)
# The API/worker containers build their DATABASE_URL from these values and
# reach PostgreSQL at the `db` service host — no host networking needed.
POSTGRES_USER=learnault
POSTGRES_PASSWORD=learnault
POSTGRES_DB=learnault_dev
# Host ports exposed by the stack (change if 5432/6379/5000 are taken)
POSTGRES_PORT=5432
REDIS_PORT=6379
API_PORT=5000
# Redis is provisioned for upcoming queue-backed work; not yet consumed by the app
REDIS_URL=redis://localhost:6379
# Wallet-provisioning worker poll interval (ms)
WORKER_POLL_INTERVAL_MS=5000

# Logging Configuration
# LOG_LEVEL=info (options: error, warn, info, http, verbose, debug, silly)

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
- name: Lint (ESLint)
run: pnpm run lint

- name: Validate Docker Compose stack
run: docker compose config --quiet

- name: Run tests with coverage
run: pnpm run test:coverage

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,6 @@ USER appuser
EXPOSE 5000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://localhost:5000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD node -e "fetch('http://localhost:5000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

ENTRYPOINT ["./entrypoint-api.sh"]
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ pnpm db:seed
pnpm dev
```

### Run the full stack with Docker Compose (recommended)

The local stack — API, wallet worker, PostgreSQL, and Redis — starts with one command:

```bash
cp .env.example .env
docker compose up -d --build
```

Migrations and deterministic seed fixtures run automatically on boot. See
[Local Development Stack](./docs/DEVELOPMENT_STACK.md) for health checks, logs,
reset, and the smoke test (`pnpm stack:smoke`).

For detailed database setup instructions, see [Prisma Setup Guide](./prisma/SETUP.md)

### Development Workflow
Expand Down
122 changes: 122 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# ============================================================================
# Learnault API — Local Development Stack
#
# One command starts a healthy stack (API + wallet worker + PostgreSQL + Redis):
# docker compose up -d --build
#
# The API container applies migrations and seeds deterministic fixtures on
# boot (see docker/entrypoint-dev-api.sh). The worker drains the idempotent
# wallet-provisioning outbox (see src/workers/wallet-provisioning.worker.ts).
#
# Useful commands:
# docker compose ps → service status + health
# docker compose logs -f → follow logs (all services)
# docker compose down → stop the stack (keeps data volumes)
# docker compose down -v → stop and delete data volumes (project-scoped reset)
# pnpm stack:smoke → validate config + run the smoke test
#
# Docs: docs/DEVELOPMENT_STACK.md
# ============================================================================

name: learnault-dev

services:
# --------------------------------------------------------------------------
# PostgreSQL — primary datastore
# --------------------------------------------------------------------------
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-learnault}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-learnault}
POSTGRES_DB: ${POSTGRES_DB:-learnault_dev}
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-learnault} -d ${POSTGRES_DB:-learnault_dev}']
interval: 5s
timeout: 5s
retries: 10
start_period: 10s

# --------------------------------------------------------------------------
# Redis — cache/queue (reserved for upcoming queue-backed work)
# --------------------------------------------------------------------------
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- '${REDIS_PORT:-6379}:6379'
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 10

# --------------------------------------------------------------------------
# API — Express server (nodemon, hot reload)
# --------------------------------------------------------------------------
api:
build:
context: .
dockerfile: docker/Dockerfile.dev
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
NODE_ENV: development
PORT: 5000
DATABASE_URL: postgresql://${POSTGRES_USER:-learnault}:${POSTGRES_PASSWORD:-learnault}@db:5432/${POSTGRES_DB:-learnault_dev}?schema=public
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET:-dev-only-secret-change-me}
JWT_ISSUER: ${JWT_ISSUER:-learnault-api}
JWT_AUDIENCE: ${JWT_AUDIENCE:-learnault-clients}
RUN_MIGRATIONS: 'true'
RUN_SEED: 'true'
ports:
- '${API_PORT:-5000}:5000'
volumes:
# Bind-mount source for hot reload; keep the image's node_modules
- .:/app
- /app/node_modules
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://localhost:5000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
stop_grace_period: 30s

# --------------------------------------------------------------------------
# Worker — drains the wallet-provisioning outbox
# --------------------------------------------------------------------------
worker:
build:
context: .
dockerfile: docker/Dockerfile.dev
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: development
DATABASE_URL: postgresql://${POSTGRES_USER:-learnault}:${POSTGRES_PASSWORD:-learnault}@db:5432/${POSTGRES_DB:-learnault_dev}?schema=public
RUN_MIGRATIONS: 'true'
WORKER_POLL_INTERVAL_MS: ${WORKER_POLL_INTERVAL_MS:-5000}
volumes:
- .:/app
- /app/node_modules
command: ['./docker/entrypoint-dev-worker.sh']
stop_grace_period: 30s

volumes:
pgdata:
redisdata:
33 changes: 33 additions & 0 deletions docker/Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ============================================================================
# Learnault API — Development Docker Image
#
# Used by docker-compose.yml for local development. Installs all dependencies
# (including dev tooling) and generates the Prisma client. The source tree is
# bind-mounted from the host at runtime so edits hot-reload via nodemon/tsx.
#
# Build:
# docker build -f docker/Dockerfile.dev -t learnault-api:dev .
# ============================================================================

FROM node:20-slim

# OpenSSL is required by Prisma's engine detection
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*

RUN corepack enable && corepack prepare pnpm@10 --activate

WORKDIR /app

# Install ALL dependencies (dev tooling included) in a cached layer
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# Generate the Prisma client (needed before the app or worker can boot)
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN npx prisma generate

EXPOSE 5000

# Entrypoints are referenced via the bind-mounted ./docker directory at runtime
CMD ["./docker/entrypoint-dev-api.sh"]
25 changes: 25 additions & 0 deletions docker/entrypoint-dev-api.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh
set -e

# ---------------------------------------------------------------------------
# Learnault API — Development Container Entrypoint
#
# Environment variables:
# RUN_MIGRATIONS = "true" (default) → apply pending migrations on boot
# RUN_SEED = "true" (default) → seed deterministic fixtures on boot
# ---------------------------------------------------------------------------

if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
echo "[entrypoint] Applying database migrations …"
npx prisma migrate deploy
echo "[entrypoint] Migrations applied."
fi

if [ "${RUN_SEED:-true}" = "true" ]; then
echo "[entrypoint] Seeding database (deterministic fixtures) …"
npx prisma db seed
echo "[entrypoint] Seed complete."
fi

echo "[entrypoint] Starting API dev server (nodemon) …"
exec pnpm dev
18 changes: 18 additions & 0 deletions docker/entrypoint-dev-worker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
set -e

# ---------------------------------------------------------------------------
# Learnault Worker — Development Container Entrypoint
#
# Environment variables:
# RUN_MIGRATIONS = "true" (default) → apply pending migrations on boot
# ---------------------------------------------------------------------------

if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
echo "[entrypoint] Applying database migrations …"
npx prisma migrate deploy
echo "[entrypoint] Migrations applied."
fi

echo "[entrypoint] Starting wallet-provisioning worker …"
exec pnpm worker:dev
104 changes: 104 additions & 0 deletions docs/DEVELOPMENT_STACK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Local Development Stack (Docker Compose)

A reproducible local stack for the Learnault API: **API**, **wallet worker**, **PostgreSQL**, and **Redis** — started with one command.

## Prerequisites

- [Docker](https://docs.docker.com/get-docker/) with Docker Compose v2 (bundled with Docker Desktop)
- Node.js 20+ and pnpm 10+ (only needed for `pnpm` helper scripts; the stack itself is containerized)

## Quick Start

```bash
# 1. Configure environment (create once; defaults work out of the box)
cp .env.example .env

# 2. One command builds and starts a healthy stack
docker compose up -d --build

# 3. Verify everything is healthy
docker compose ps
# NAME STATUS
# learnault-dev-api Up ... (healthy)
# learnault-dev-db Up ... (healthy)
# learnault-dev-redis Up ... (healthy)
# learnault-dev-worker Up ... (healthy)
```

The API is available at `http://localhost:5000` (Swagger UI at `http://localhost:5000/api-docs`).

## What happens on startup

The `api` service entrypoint (`docker/entrypoint-dev-api.sh`) waits for PostgreSQL and Redis health, then:

1. Applies pending migrations (`prisma migrate deploy`) — deterministic, no-op when up to date.
2. Seeds deterministic fixtures (`prisma db seed`) — idempotent, safe to run repeatedly.
3. Starts the Express server under `nodemon`, so source edits hot-reload via the bind mount.

The `worker` service runs `src/workers/wallet-provisioning.worker.ts`, which polls the idempotent wallet-provisioning outbox and generates Stellar keys through the dev in-memory KMS adapter. In production, swap the KMS adapter for a real one (e.g. AWS KMS) behind the same `KmsSecretStore` interface.

## Health checks & readiness

| Endpoint | Meaning |
| ------------------- | ---------------------------------------------------- |
| `GET /health/live` | Process is alive (used by the container healthcheck) |
| `GET /health/ready` | Dependencies (database) are reachable |

The API container only reports **healthy** after `/health/live` responds; `depends_on: condition: service_healthy` keeps the worker from racing migrations. `GET /health/ready` returns `200` only when PostgreSQL is reachable — the smoke test waits on it.

## One-command helpers

`package.json` exposes convenient wrappers:

```bash
pnpm stack:up # docker compose up -d --build
pnpm stack:down # stop the stack (keeps data volumes)
pnpm stack:reset # stop + delete data volumes (project-scoped reset)
pnpm stack:logs # follow API + worker logs
pnpm stack:validate # docker compose config --quiet
pnpm stack:smoke # validate + start + probe health endpoints
```

## Logs & graceful shutdown

```bash
docker compose logs -f # all services
docker compose logs -f api # API only
docker compose logs worker # worker only
```

Both services have `stop_grace_period: 30s`, matching the app's graceful-shutdown handler (`SHUTDOWN_TIMEOUT_MS`): `docker compose down` sends `SIGTERM`, the server drains HTTP connections and closes the Prisma pool before exiting.

## Data persistence & reset

- PostgreSQL data lives in the `learnault-dev_pgdata` named volume; Redis in `learnault-dev_redisdata`.
- **Reset is project-scoped**: `docker compose down -v` removes only this project's volumes. Other projects and containers are untouched.

```bash
# Full project-scoped reset (drops all local data, then rebuild + reseed)
pnpm stack:reset
pnpm stack:up
```

## Smoke test

```bash
pnpm stack:smoke
```

This validates the compose file, starts the stack, waits for `/health/ready`, probes `/health/live` and `/health/ready`, and prints service status. Run `./scripts/stack-smoke-test.sh --validate` for config-only validation.

## Troubleshooting

| Symptom | Fix |
| ------------------------------------ | -------------------------------------------------------------------- |
| Port 5432/6379/5000 already in use | Override in `.env`: `POSTGRES_PORT=5433`, `REDIS_PORT=6380`, `API_PORT=5001` |
| Prisma client errors (`@prisma/client` export) | Run `pnpm db:generate` (or `docker compose build`), then restart the stack |
| `JWT_SECRET` required error | Set a real `JWT_SECRET` in `.env` (defaults are dev-only) |
| Containers restarting after reset | Ensure `.env` exists before `docker compose up` |

## Related

- [Prisma Setup Guide](../prisma/SETUP.md) — database schema, migrations, seeding
- [Staging Runbook](./RUNBOOK.md) — production/staging deployment
- [Architecture](./ARCHITECTURE.md) — service design
Binary file removed lint_output.json
Binary file not shown.
Binary file removed lint_results.txt
Binary file not shown.
Binary file removed lint_results_manual.txt
Binary file not shown.
16 changes: 0 additions & 16 deletions lint_results_utf8.txt

This file was deleted.

Loading
Loading