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.
Prerequisites
Section titled “Prerequisites”- mise (provisions Go 1.26, sqlc, swag,
sql-formatter, golangci-lint, gofumpt, revive, the Railway CLI, and more from the repo’smise.toml). Migrations run throughcmd/migrate(goose as a library), sogooseis not a standalone provisioned CLI. - Docker — for the local Postgres and Redis containers.
- Railway access —
mise run envpulls config from Railway, so you need to be logged in with project access.
-
Install tools:
Terminal window mise install -
Pull environment variables from Railway:
Terminal window mise run env # writes .env.json (gitignored); mise auto-loads it -
Start local Postgres (and Redis):
Terminal window mise run db:localmise run redis:local -
Migrate and seed:
Terminal window mise run db:migrate # go run ./cmd/migrate upmise run nuke # go run ./cmd/nuke — resets, re-migrates, and seeds# the root org/user from cmd/nuke/seed.json -
Run the server:
Terminal window mise run run # go run ./cmd/server, logs piped through logfmtServes on
:${PORT}— health atGET /, Swagger UI atGET /docs/*.
Code generation
Section titled “Code generation”Two generators must be re-run when their inputs change:
mise run sql # sql-formatter + sqlc generate — after editing any db/*.query.sqlmise run docs # swag fmt + swag init + jq post-process — after changing handlers/modelsmise 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.
Conventions
Section titled “Conventions”- Enums live in
internal/model(type X string+// @name X); DB columns staystring, converted at the boundary. See Architecture. FromDBconverters — everymodeltype has anXFromDB(...); services returnmodel.*, never raw sqlc rows.utils.Mapconverts slices.- Transactions — services
Begin, deriveQueries.WithTx(tx)+logger.WithTx(tx),defer tx.Rollback, and commit at the end. Audit writes ride the same transaction. - Errors — sentinel
model.Errorvalues withWrap/WithMsg;pgx.ErrNoRowsmaps toErrNotFound. Each sentinel carries its own HTTP status (model.Errorimplements Echo’sHTTPStatusCoder), so Echo’sDefaultHTTPErrorHandlerrenders it directly — there is no error-mapping middleware. - IDs — ULIDs everywhere (
internal/id.ULID), stored as Postgresuuid; the zero ULID marshals to SQLNULL/ JSONnull. - Config injection — constructors and initializers that need config take
the whole
*config.Configand 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, …). TheCryptConfig/BucketConfig/DBConfigsub-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.Configrather than accepted as parameters. - Formatting/lint —
gofumpt+golangci-lint(withrevive). sqlc-generatedinternal/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.mdin the repo.)
Continuous integration
Section titled “Continuous integration”CI runs on push and mirrors what you can run locally. The unified check:
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.
SDK generation
Section titled “SDK generation”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"]
-
Trigger — a
pushto thestagingbranch that touchesinternal/**/*.go,mise.toml,.speakeasy/**, or the workflow itself (plus manualworkflow_dispatch). Runs are serialized. -
build-spec— runsmise run docsand uploadsdocs/swagger.json. -
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 version0.1.<commit-count>, runsspeakeasy run --set-version, and pushes to the artifact repo’smainif anything changed.
- TypeScript — package
@huddlesurety/api, classHuddleAPI, published to GitHub Packages (restricted). Consumers authenticate installs with aGITHUB_TOKENthat hasread:packages. - Python — package
huddlesurety-api, classHuddleAPI, git-installed (no PyPI publish).