Architecture
Deckgauge is a pnpm + Turborepo monorepo: four apps and three shared packages, each with one job. This page maps what owns what, how a browser request reaches the database, and how source data becomes the numbers on an intelligence widget.
The workspace's two package globs — everything under apps/* and packages/* is a workspace member.
What each app and package owns
The workspace is defined by pnpm-workspace.yaml (apps/* and packages/*), and turbo.json pipelines build, dev, test, and lint across all of them. Four apps ship as separate Docker containers; three packages are pure library code with no server of their own.
apps/web— the Next.js 14 App Router frontend. The board, dashboards, timesheet, org trees, and settings all live underapps/web/app. It never talks to Postgres or ClickHouse directly — every read and write goes throughapps/apiover HTTP, from server actions inapps/web/app/actions.apps/api— a Fastify server (apps/api/src/server.ts) that is the only thing with a database connection. Routes are grouped by domain (boards,widgets,intelligence-query,org-trees, and so on), each registered as a Fastify plugin. Everything under the app's main plugin registration runs behind two layered plugins: a Keycloak JWT auth plugin (apps/api/src/auth/keycloak-auth.plugin.ts) that verifies a bearer token and resolves it to a localUserread asrequest.user— it doesn't reject anything by itself; a missing or invalid token just means norequest.usergets set — and a policy plugin (apps/api/src/auth/policy.plugin.ts) that does the actual enforcing. Every route declares aconfig.policy(public,authenticated, a board or roadmap role,comparison, orconnectionOwner— seeapps/api/src/auth/policy.ts), and a second preHandler evaluates it on every request, answering401when there's no valid token and403when there is one but it doesn't meet the policy. A route registered with no declared policy doesn't just fail its own requests — it fails the server's boot entirely, with every offending route collected and reported together rather than one at a time. The resulting map of all 278 routes and their policies is committed atapps/api/src/route-inventory.snapshot.json, so there's no route left to "assume" about. The one way around all of it:DECKGAUGE_SINGLE_USER=true, which bypasses every policy — see Configuration reference.apps/worker— a BullMQ worker with no HTTP surface. It runs the scheduled and manually-triggered sync jobs that pull from Jira, GitHub, GitLab, and Azure DevOps, writes board rows to Postgres, and — for intelligence — dual-writes normalized rows to ClickHouse (apps/worker/src/github-dual-writer.tsand its Jira/GitLab/ADO equivalents).apps/advisor-bridge— a standalone process (started withpnpm deckgauge:advisororscripts/advisor-bridge.sh, not part ofdocker compose) that exposes the AI advisor over an ACP/WebSocket bridge so a local coding agent can act on the board. The published open-source snapshot excludes most ofscripts/pending audit —pnpm deckgauge:advisordoesn't depend on it and still works there.packages/shared— every request/response shape as a Zod schema, plus type-only constants both sides agree on: widget type unions (widget-types.ts), DORA thresholds (dora.ts), sync-queue names, and provider adapters. Bothapps/webandapps/apiimport from it; it has no dependency on either.packages/db— the source of truth for database types. It re-exports the generated Prisma client and model types (PrismaClient,Board,User, and so on) frompackages/db/prisma/schema.prisma, and exports the shared ClickHouse client (clickhouse,chInsertMany) and migration runner. Nothing outside this package touches Prisma or ClickHouse connection details directly.packages/ui— shared React components used across the board and dashboard surfaces (row and column primitives, badges, comment editor, keyboard-navigation provider) — presentation, not data-fetching.
The request path: browser to database
A page in apps/web renders a Server Component or calls a server action, which calls apps/api over plain HTTP — apps/web/app/lib/api-server.ts resolves the API's base URL from API_URL (defaulting to http://localhost:3001) and attaches the signed-in user's Keycloak access token as a bearer header. In apps/api/src/server.ts, that request passes through the Keycloak auth plugin, then the policy plugin's authorization check, then a domain's Fastify route handler, which delegates to a *.service.ts for the actual Prisma query against Postgres. The response is a plain JSON object shaped by a Zod schema from packages/shared, so both sides can trust its shape without re-validating it.
A board read: browser through web, api, and Postgres.
Where intelligence data comes from
Widgets under apps/web/app/components/dashboard/widgets don't read Postgres at all — their numbers come from ClickHouse, populated on a separate path. apps/worker runs a sync job per source (jira-sync.handler.ts, github-sync.handler.ts, gitlab-sync.handler.ts, azure-devops-sync.handler.ts); alongside writing board rows to Postgres, each source has an intelligence handler and a dual-writer (for example github-dual-writer.ts) that inserts normalized rows — pull requests, reviews, commits, issues — into ClickHouse tables like github_pull_requests and github_commits via chInsertMany from packages/db.
On the read side, apps/api/src/intelligence-query/builders holds one file per widget type, each building a parameterized ClickHouse SQL query and self-registering with registerBuilder(widgetType, builderFn). apps/api/src/widgets/widget-data.service.ts looks up a widget's builder, runs the query against the shared clickhouse client, and shapes the rows into the response the widget component expects. The full path from a Jira issue landing to a tile updating: source API → worker adapter → ClickHouse dual-write → intelligence-query builder → widget-data service → widget component.
Related
- The data model — why there are two databases, and which one holds what.
- Postgres schema — the Prisma models behind a board.
- ClickHouse schema — every analytics table, its engine and its sort key.
- Setting up to develop — get this stack running locally.
- Adding an intelligence widget — the builder/service/component path traced file by file.
- Code conventions — the
*.service.ts/*.routes.tssplit and where shapes live. - Engineering intelligence — what the ClickHouse-backed widgets show, from a user's side.
Last updated