Architecture

System Context

C4Context title System Context โ€” Meerkat Person(joel, "Joel", "Checks repo/CI status; manages project metadata via the jh CLI") Boundary(platform, "Joel's Platform") { System(jh, "jh CLI", "Command-line client (cmd/jh) โ€” project list/get/create/update") System(meerkat, "Meerkat", "Repo/CI status cache + project metadata registry") } SystemDb(postgres, "PostgreSQL", "projects table โ€” durable project metadata") System_Ext(github, "GitHub REST API", "Repo info, workflow runs, open pull requests") Rel(joel, jh, "Runs commands") Rel(jh, meerkat, "ConnectRPC: ProjectService, StatusService") Rel(meerkat, postgres, "Reads / writes projects") Rel(meerkat, github, "Polls repo/runs/PRs on a timer")

Container Diagram

C4Container title Meerkat โ€” Internal Containers Boundary(meerkat, "Meerkat (cmd/api, single binary, :9000)") { Container(statusHandler, "grpc.Handler", "Go / ConnectRPC", "StatusService: ListRepos, GetRepo") Container(projectHandler, "projects.Service", "Go / ConnectRPC", "ProjectService: List/Get/Create/UpdateProject") Container(cache, "cache.Cache", "Go, in-memory, RWMutex", "Holds latest RepoStatus per watched repo; refreshed on a ticker") Container(ghClient, "github.Client", "Go / net-http", "Fetches repo info, workflow runs, open PRs from GitHub REST") Container(store, "projects.Store", "Go / pgx", "SQL access to the projects table") } SystemDb(postgres, "PostgreSQL", "") System_Ext(github, "GitHub REST API", "") Rel(statusHandler, cache, "All() / Get(owner/repo)") Rel(cache, ghClient, "FetchStatus(owner, repo) โ€” parallel per repo") Rel(ghClient, github, "GET repo, commits, actions/runs, pulls") Rel(projectHandler, store, "Insert / GetByUUID / GetBySlug / List / Update") Rel(store, postgres, "SQL via pgx/pgxpool")

Overview

Meerkat is a single-binary Go service (cmd/api) that exposes a Connect-RPC API over HTTP/2 (h2c) on port 9000, built on the shared archaea/server package for CORS, request logging, and graceful shutdown. It has two independent responsibilities that share nothing but a startup routine:

  1. Live GitHub status for a fixed, environment-configured list of repos.
  2. Durable project metadata for those (and any other) repos, stored in Postgres.

The projects Data Model

The projects table (migration lib/repo/migrations/00000001_projects.up.sql) has one row per repo:

ColumnNotes
uuidPrimary key. UUID v5, deterministically derived from the slug (see below) โ€” never generated randomly.
idxSequential integer from projects_idx_seq, assigned at insert time; used for stable list ordering.
owner, repo, slugslug ("owner/repo") is unique and is the natural key most callers use.
name, description, url, local_path, prefixFree-form metadata editable via UpdateProject. local_path is the local filesystem path other tooling writes exports to.
statusactive or archived, stored as text (lib/meerkat/projects/enums.go maps to/from the ProjectStatus proto enum).
created_at, updated_atMaintained by the DB; updated_at is bumped on every Update.

UUIDs are computed by lib/project/uuid.go as uuid.NewSHA1(Namespace, []byte(slug)), where Namespace = uuid5(uuid.NamespaceURL, "meerkat.holmes.haus") is a fixed constant. Any service โ€” not just Meerkat โ€” can independently recompute the same UUID for a slug without calling the API, which is what lets other tools (like jh) reference a project by a stable ID they can derive locally.

The Two RPC Services

ProjectService (lib/meerkat/projects/service.go, backed by lib/meerkat/projects/store.go) is a conventional CRUD service over the projects table:

  • ListProjects โ€” all projects, ordered by idx.
  • GetProject โ€” by uuid or slug (exactly one must be set).
  • CreateProject โ€” takes a slug, splits it into owner/repo, derives the UUID, defaults name to the repo name if omitted, and rejects duplicate slugs with CodeAlreadyExists.
  • UpdateProject โ€” partial update via optional fields; every unset field is left unchanged (COALESCE in the SQL).

StatusService (lib/meerkat/grpc/service.go) is a thin read-only wrapper around the in-memory cache.Cache:

  • ListRepos โ€” returns the cached RepoStatus for every watched repo.
  • GetRepo โ€” returns the cached status for one owner/repo, or NotFound if it isn’t in WATCHED_REPOS.

It never talks to GitHub or Postgres directly โ€” all data comes from whatever the cache last fetched.

Live GitHub Status Refresh

On startup, main.go parses WATCHED_REPOS (a comma-separated list of owner/repo slugs) and constructs a cache.Cache (lib/cache/cache.go) over that fixed repo list. Cache.Start performs a synchronous first refresh โ€” the process blocks until every repo has been fetched at least once, so the API never serves an empty cache โ€” then launches a background goroutine that re-refreshes on a REFRESH_INTERVAL ticker (default 60s).

Each refresh fetches all watched repos in parallel via golang.org/x/sync/errgroup. Per repo, github.Client.FetchStatus (lib/github/client.go):

  1. GET /repos/{owner}/{repo} for the default branch, then GET .../commits?sha={branch}&per_page=1 for the latest commit SHA (truncated to 7 chars).
  2. In parallel, GET .../actions/runs?branch={branch}&per_page=5 (recent workflow runs) and GET .../pulls?state=open&per_page=20 (open PRs).

A single repo’s fetch failure is logged and skipped (return nil from the errgroup func) rather than aborting the whole refresh cycle โ€” a bad GitHub response for one repo doesn’t blank out the rest of the dashboard. deriveCIStatus reduces the recent runs list to one of passing / failing / pending / unknown: any in_progress/queued run wins as pending; otherwise the first completed run’s conclusion (success โ†’ passing, failure/timed_out โ†’ failing) is used.

Seeding Projects from WATCHED_REPOS

After the cache’s first refresh, main.go calls seedProjects, which calls Store.SeedIfMissing for every watched slug. This does an INSERT ... ON CONFLICT (slug) DO NOTHING โ€” it guarantees a projects row exists for every watched repo (with a UUID, owner, repo, slug, name defaulted to the repo name, and status active), but never overwrites a row that already exists. That means metadata edited through UpdateProject โ€” description, URL, local path, prefix โ€” survives restarts even though the seed step runs every time the process boots.

Migrations

Schema migrations live under lib/repo/migrations/ as goose SQL files, embedded into the binary via embed.FS (lib/repo/migrations.go) and applied automatically at startup (goose.Up) against the pgx-backed *sql.DB โ€” no separate migration step or tool is required to deploy.