Files
solopm-server/plans/01-architecture.md
2026-08-14 11:29:11 -06:00

6.4 KiB

01 — Architecture & Conventions

Backend is a single Go module solopm.com/solopm-server, one binary, serving the JSON API and the embedded Vue SPA. Layering is strict: HTTP handler → domain service → sqlc-generated store. Handlers never touch the database; services never parse HTTP. All mutations run inside a transaction and emit domain events (03-domain-events.md).

Repository layout

  • Create the repo skeleton (Phase 0 subset; internal/domain/*, internal/events, internal/jobs, internal/search, internal/mail, internal/auth, internal/files are added by the phases that need them):
/                       (repo root; /app in this workspace)
├── cmd/solopm/main.go          — flag/env parsing, wiring, serve + CLI subcommands (migrate, reindex, cull)
├── internal/
│   ├── config/                 — env config loader + validation
│   ├── httpserver/             — chi router, middleware, handler registration, SPA fallback
│   ├── domain/
│   │   ├── users/              — service + handlers per domain (users, projects, issues, epics,
│   │   ├── projects/             comments, wiki, notifications, search, webhooks, export, retention)
│   │   └── ...
│   ├── db/
│   │   ├── migrations/         — golang-migrate SQL files (NNNN_name.up.sql / .down.sql)
│   │   ├── queries/            — sqlc input .sql files, one file per domain
│   │   └── sqlcgen/            — generated code (checked in)
│   ├── events/                 — Emit(), outbox dispatcher, consumer registry
│   ├── jobs/                   — job queue workers (email, webhook delivery, search sync, digest, cull)
│   ├── search/                 — Meilisearch client + index definitions
│   ├── mail/                   — SMTP sender + templates
│   ├── auth/                   — session/PAT middleware, OAuth clients, bcrypt helpers
│   └── files/                  — attachment storage on disk under UPLOAD_DIR
├── web/                        — Vue app (see 13-frontend.md); web/dist embedded via go:embed
├── plans/                      — these specs
├── Makefile                    — fmt, vet, test, sqlc, migrate, build (frontend then backend)
├── sqlc.yaml
├── docker-compose.yml
└── .env.example                — every env var with sane defaults (never commit .env)

Configuration (env vars)

  • Implement internal/config reading these variables (fail fast at startup on missing required ones):
    • DATABASE_URL (required) — pgx connection string
    • HTTP_PORT (default 8080)
    • BASE_URL (required) — external URL, used in emails/OAuth callbacks/webhook payloads
    • SESSION_SECRET (required) — 32+ bytes
    • UPLOAD_DIR (default ./uploads) — attachment storage root
    • MAX_UPLOAD_MB (default 25)
    • MEILI_URL, MEILI_KEY (required for search; app runs degraded without them — search endpoints return 503)
    • SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM (optional; email disabled when unset)
    • GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET (optional — hides the button when unset)
    • GITEA_URL, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET (optional — same)
    • CULL_AFTER_DAYS (default 30)
    • LOG_LEVEL (default info)

Cross-cutting conventions

  • IDs: bigint GENERATED ALWAYS AS IDENTITY primary keys everywhere.
  • Timestamps: timestamptz; created_at NOT NULL DEFAULT now(); updated_at nullable, set by the service on update; never trust client clocks.
  • Soft delete: nullable deleted_at on user-facing content tables. Every list/get query filters deleted_at IS NULL (enforce via sqlc query text). Hard deletion happens only in the culling job (12-retention.md).
  • Foreign keys: declared with ON DELETE RESTRICT. Cascades are the culling job's responsibility (it must also delete disk files — a DB cascade can't).
  • Transactions: one per request mutation; service receives pgx.Tx; events.Emit is called inside the same tx.
  • Errors: JSON {"error": {"code": "<machine_code>", "message": "<human>", "fields": {"title": "required"}}}. Codes: validation_failed, not_found, forbidden, unauthorized, conflict, cycle_detected, rate_limited, internal. Non-members receive 404, not 403, for projects they can't see (don't leak existence).
  • Logging: log/slog, JSON in production, request-id middleware; log every request (method, path, status, duration, user id).
  • Validation: in services, not handlers; titles trimmed/non-empty; markdown bodies size-capped (64 KB).
  • Time/date fields: start_date/target_date are date (no time component); completed_at is timestamptz set by status automation.
  • Graceful shutdown: on SIGINT/SIGTERM stop accepting requests, close SSE hub, drain dispatcher + job workers (10s cap), close pgx pool. (SSE hub/dispatcher/job workers land in later phases; server shutdown + pool close done in Phase 0)
  • SPA serving: go:embed web/dist; any non-/api, non-/webhooks, non-/files GET serves index.html (deep links work).
  • Makefile targets: make dev (Vite + Go with reload), make build (pnpm build → go build), make test, make sqlc, make migrate-up/down, make lint. (make dev runs both servers concurrently; no file-watcher/hot-reload for the Go side yet)
  • Tests: table-driven unit tests for services (cycle detection, numbering, fan-out rules); integration tests against a real Postgres (dockertest or TEST_DATABASE_URL); no mocking of sqlc stores.

Middleware chain (chi)

  • Order: RequestID → RealIP → slog logger → Recoverer → CORS (same-origin default; permissive only in dev) → session loader (scs) → auth resolver (session cookie or Authorization: Bearer solopm_… PAT → sets user in context) → per-route guards:
    • RequireAuth — 401 if no user
    • RequireAdmin — 403 if not is_admin
    • RequireProjectMember / RequireProjectOwner — loads project from URL, 404 if absent/soft-deleted/not a member, 403 if member-but-not-owner where owner is required
  • Rate-limit login/register/password endpoints (per-IP token bucket, e.g. 10/min).