Skip to content

Local Development

Everything in the API repo is driven by mise tasks, which also load the service’s environment. Prefer mise run <task> over bare go commands so the env is present.

  • mise (provisions Go 1.26, sqlc, swag, sql-formatter, golangci-lint, gofumpt, revive, the Railway CLI, and more from the repo’s mise.toml). Migrations run through cmd/migrate (goose as a library), so goose is not a standalone provisioned CLI.
  • Docker — for the local Postgres and Redis containers.
  • Railway accessmise run env pulls config from Railway, so you need to be logged in with project access.
  1. Install tools:

    Terminal window
    mise install
  2. Pull environment variables from Railway:

    Terminal window
    mise run env # writes .env.json (gitignored); mise auto-loads it
  3. Start local Postgres (and Redis):

    Terminal window
    mise run db:local
    mise run redis:local
  4. Migrate and seed:

    Terminal window
    mise run db:migrate # go run ./cmd/migrate up
    mise run nuke # go run ./cmd/nuke — resets, re-migrates, and seeds
    # the root org/user from cmd/nuke/seed.json
  5. Run the server:

    Terminal window
    mise run run # go run ./cmd/server, logs piped through logfmt

    Serves on :${PORT} — health at GET /, Swagger UI at GET /docs/*.

Two generators must be re-run when their inputs change:

Terminal window
mise run sql # sql-formatter + sqlc generate — after editing any db/*.query.sql
mise run docs # swag fmt + swag init + jq post-process — after changing handlers/models

mise run docs runs swag to emit docs/swagger.json (OpenAPI 3.1), then scripts/openapi_query.jq rewrites the QUERY endpoints and bumps the spec to OpenAPI 3.2.

  • Enums live in internal/model (type X string + // @name X); DB columns stay string, converted at the boundary. See Architecture.
  • FromDB converters — every model type has an XFromDB(...); services return model.*, never raw sqlc rows. utils.Map converts slices.
  • Transactions — services Begin, derive Queries.WithTx(tx) + logger.WithTx(tx), defer tx.Rollback, and commit at the end. Audit writes ride the same transaction.
  • Errors — sentinel model.Error values with Wrap/WithMsg; pgx.ErrNoRows maps to ErrNotFound. Each sentinel carries its own HTTP status (model.Error implements Echo’s HTTPStatusCoder), so Echo’s DefaultHTTPErrorHandler renders it directly — there is no error-mapping middleware.
  • IDs — ULIDs everywhere (internal/id.ULID), stored as Postgres uuid; the zero ULID marshals to SQL NULL / JSON null.
  • Config injection — constructors and initializers that need config take the whole *config.Config and reach into the sub-config they need, so callers don’t have to know which slice a package consumes. This is uniform across the service (service.New, db.New, authn.New, storage.New, crypt.Init, …). The CryptConfig / BucketConfig / DBConfig sub-structs group related keys (and drive reflection-based startup validation of required env vars), but they are read inside the package from the passed *config.Config rather than accepted as parameters.
  • Formatting/lintgofumpt + golangci-lint (with revive). sqlc-generated internal/db/** is excluded from lint.
  • Swagger docstrings — a mandatory godoc first line, tab-indented annotations in a fixed order (@Summary → @Description → @Tags → @Accept → @Produce → @Param → @Success → @Failure → @Router), function-scoped request-struct naming, and no comments inside function bodies. (See .github/agents/docs.agent.md in the repo.)

CI runs on push and mirrors what you can run locally. The unified check:

Terminal window
mise run check # test + fmt + lint + docs
Step Command Purpose
Test mise run test go test ./...
Format mise run fmt gofumpt -l -w .
Lint mise run lint golangci-lint run
Docs mise run docs Keep the OpenAPI spec in sync with handlers

For schema changes, also run mise run sql before submitting so the generated code matches your SQL.

The TypeScript and Python SDKs are generated from this repo’s CI (not from the SDK repos), driven directly by the Speakeasy CLI. The artifact repos (api-client-typescript, api-client-python) are pure build outputs — the authoritative Speakeasy config lives here under .speakeasy/.

graph LR
    A[push to staging<br/>internal/**.go changed] --> B[build-spec<br/>mise run docs]
    B --> C[swagger.json artifact]
    C --> D[generate: typescript]
    C --> E[generate: python]
    D --> F["@huddlesurety/api<br/>→ GitHub Packages"]
    E --> G["huddlesurety-api<br/>→ git-installed"]
  1. Trigger — a push to the staging branch that touches internal/**/*.go, mise.toml, .speakeasy/**, or the workflow itself (plus manual workflow_dispatch). Runs are serialized.

  2. build-spec — runs mise run docs and uploads docs/swagger.json.

  3. generate (matrix: TypeScript + Python) — each language uses its own Speakeasy workspace/key, clones its artifact repo, drops in the spec + .speakeasy/<lang>/ config, computes a deterministic version 0.1.<commit-count>, runs speakeasy run --set-version, and pushes to the artifact repo’s main if anything changed.

  • TypeScript — package @huddlesurety/api, class HuddleAPI, published to GitHub Packages (restricted). Consumers authenticate installs with a GITHUB_TOKEN that has read:packages.
  • Python — package huddlesurety-api, class HuddleAPI, git-installed (no PyPI publish).