Architecture
PICKET is a modular monolith in a pnpm workspace: one image, several identical replicas, PostgreSQL as the single source of truth.
text
apps/bot composition root: HTTP server, Gateway runner, command line
packages/kernel identifiers, Result, Clock, Logger, Secret
packages/config environment validation
packages/i18n translations, locale resolution, catalog checks
packages/persistence database access, migrations, row-level security
packages/coordination leases with fencing, per-key locks, Gateway session store
packages/discord interaction pipeline, command and component registries, HTTP and Gateway adapters, REST ports
packages/guild servers: settings, permissions, lifecycle
packages/todolist todo lists: grammar, layout, ticking (state lives in the Discord message)
packages/testing helpers shared by the testsLayers
Each feature package is split into domain/, application/, infrastructure/ and presentation/. Dependencies only point inwards. A test (tests/architecture) fails the build when domain/ or application/ imports Discord, the database, HTTP or Node modules, when a package reaches into another one instead of its public API, or when code outside @picket/config reads process.env.
Rules worth knowing
- A server is a tenant. Every table that holds server data has a
guild_idcolumn and row-level security that readsapp.guild_id; code reaches it throughwithTenant. A test fails when such a table lacks the policy. The application connects with a role that owns nothing and cannot bypass the policy. - Idempotence. Interactions are claimed once across replicas; writes use natural keys and conditional updates.
- Singletons use leases. Work that must run once (a Gateway shard) is held through a lease with a fencing token, checked by the writes of the holder.
- Rolling updates. Migrations only add; payloads and identifiers that cross versions are versioned.
- Commands are declared once. The registry validates names, options and texts at startup and generates the JSON sent to Discord. Every command states its required level explicitly.
Add a command
- Add the texts to
packages/i18n/locales/en.jsonandfr.json, then runpnpm i18n:keys. - Write the use case in the
application/layer, with a port for what it needs. - Add the adapter in
infrastructure/and the command inpresentation/discord/(level, options, handler usingt). - Register it in
apps/bot/src/composition.ts. - Test it: unit tests with in-memory fakes, and an integration test against PostgreSQL when it touches data.
Add a button or a form
- Declare a
ComponentFamily: a namespace (td), a version, the required level, and the server feature it belongs to. - Build identifiers with
encodeCustomId(namespace:version:payload, 100 characters at most). A new format means a new version: old buttons are answered as expired, never ignored. - Register the family in
apps/bot/src/composition.ts. Buttons and forms go through the same chain as commands: duplicates, access level, feature, suspension, language, errors. - Return
{ kind: 'deferred', ... }for anything that talks to Discord: the pipeline acknowledges within 3 seconds, runs the work afterwards and delivers the result; shutting a replica down waits for it.
Tests
sh
pnpm test # typecheck + unit tests (fast, no Docker)
pnpm test:int # integration tests on a throw-away PostgreSQL (needs Docker)
pnpm test:all # bothThe toolchain is TypeScript 7 (tsc), Jest with @swc/jest, and no linter: the architecture test and the compiler's strict mode do that job.