Imported from Ardakilic/BrewForm (
AGENTS.md). Install upstream withnpx skills add Ardakilic/BrewForm. Copyright stays with the author.
Implementation workflow
- Use Serena MCP (
serena_*) tools for code understanding, navigation, and editing.- Tool prefix: opencode namespaces Serena tools by the MCP server name
"serena"— the raw server logs show bare names but the agent usesserena_*. - Activate with
serena_activate_projectusing the project namebrewform(from.serena/project.yml), NOT the full path/Users/arda/projects/BrewForm.
- Tool prefix: opencode namespaces Serena tools by the MCP server name
- Before editing, use
get_symbols_overvieworfind_symbolto understand the relevant code structure. - Use
search_for_patternfor cross-file searches andreplace_contentfor regex-based edits. - Always use Context7 MCP for library, code, language, and framework documentation.
- Delegate separate jobs (research, file edits, etc.) to sub-agents so the main loop context is used more efficiently.
Development commands
Everything runs through Docker. No local Deno installation required. Use make <target>:
make up— start infrastructure only (postgres, mailpit, pgadmin, garage); does NOT start appmake install— cache deno dependencies (required once)make email-build— compile MJML → HTML templates (required before API runs)make db-generate && make db-migrate && make db-seed— full DB setupmake dev— start API (:8000) + Vite HMR (:5173)make check— type-check all workspacesmake lint— lint all apps and packagesmake test— run all tests (via Docker, with--allow-all)make test-db-provision— create + migrate + seed thebrewform_testDB (idempotent; required once before DB-backed tests run, safe to re-run)
Granular targets: make check-api, make check-web, make test-api, make test-shared, make test-specific filter=path/to/test.ts.
Run a single test file: deno test --no-check --allow-all apps/api/src/path/to/file_test.ts (inside Docker: make test-specific filter=path/to/test.ts).
Type-check + lint + format after every edit. Test command order matters: deno task check then deno task test. After finishing a batch of edits, run make fmt to apply deno fmt (lineWidth 100, indentWidth 2, singleQuote, semiColons) — the agent's symbolic edits preserve logic but may not match Deno's exact whitespace rules, so a final make fmt is mandatory before commit/PR. CI enforces deno fmt --check and will fail the build on unformatted code.
Architecture
Monorepo with 4 Deno workspace members:
apps/web ───→ @brewform/shared
↑
apps/api ─┬──→ @brewform/shared
└──→ @brewform/db ──→ @brewform/shared
- Frontend NEVER imports from
@brewform/db— only@brewform/shared. - Client runs in Docker; Vite resolves
@brewform/shared/*via explicit aliases inapps/web/vite.config.ts. - Vite proxies
/api/*tohttp://app:8000in Docker (host networking auto-detected).
API module pattern
Every domain module follows 3-layer pattern: model.ts → service.ts → index.ts.
- Services import from model files, never from
drizzle-ormdirectly. - Controllers validate with shared Zod schemas from
@brewform/shared/schemas. - All Hono routes use typed
<AppEnv>context (userId, user, cache, requestId). - Middleware stack order: cors → requestId → secureHeaders → rateLimit → bodyLimit → cache injection → crawler → onError (via
app.onError, not stack middleware) → optional /uploads static handler → routes. - Accepted deviation: the
contactmodule is a controller-only email endpoint with no DB access; it intentionally skips themodel.ts/service.tssplit.
OpenAPI documentation
Every new route (or change to a route's request/response shape) MUST include OpenAPI
metadata so the generated spec at /api/v1/openapi.json and the Scalar UI at /api/v1/docs
stay complete. This is mandatory, like logging — a route without describeRoute() is incomplete.
- Prepend
describeRoute({ ... })(fromhono-openapi) to every route with:tags,summary,description,security: [{ bearerAuth: [] }]on auth-guarded routes, path/queryparameters, arequestBody, and typedresponses. - Keep
@hono/zod-validator'szValidator(...)as the request validator (ADR-012). Never importhono-openapi'svalidator. OpenAPI metadata is additive and must not change runtime behavior, status codes, or response bodies. - Responses: wrap the entity's Output Schema in an envelope and pass it through
resolver():resolver(successEnvelope(XOutputSchema)),resolver(paginatedEnvelope(XOutputSchema)), andresolver(ErrorEnvelopeSchema)for every documented error (always401on auth-guarded routes, plus404/403/400/409where the handler maps them). - Request bodies: use
jsonRequestBody(InputSchema)fromapps/api/src/utils/openapi/index.ts(it runs Zod v4z.toJSONSchemaon the SAME schemazValidatoruses). Do NOT useresolver()for request bodies — inhono-openapiv1.3.0resolver()only converts response schemas. - Do NOT
import 'zod-openapi/extend'— that subpath does not exist inzod-openapiv6 and breaks the build;resolver()reads metadata natively from Zod v4 schemas. - Response/entity schemas live in
packages/shared/src/schemas/responses/(<Entity>OutputSchema), the envelope helpers inpackages/shared/src/schemas/response.ts. Derive output schemas from the ACTUALservice.tsreturn shape (joined objects, computed/count fields, flags), add each as an additive export with a co-located unit test, and register any new tag in thetagsarray inapps/api/src/routes/openapi.ts. - Non-JSON routes document their true content type (e.g.
text/html,application/xml,image/*) and are NOT wrapped in a JSON success envelope. - The introspection coverage test
apps/api/src/routes/openapi.coverage.test.tsenforces that every in-scope route is documented, tagged, and free of orphan tags — runmake test-apiafter adding routes.
Database rules
- No raw SQL — Drizzle ORM only. No JSONB/UUID columns. No Postgres-specific operators in application query code (schema-level Postgres features like index ordering and CHECK constraints are permitted).
- Soft deletes on all main entities (
deletedAt). Queries usefindFirst({ where: eq(t.deletedAt, null) }), neverfindUnique. - Connection pool:
max: 10viapostgres-jsdriver inpackages/db/src/index.ts. - Migrations:
deno task db:generate(creates SQL) thendeno task db:migrate(applies); seed isdeno run -A packages/db/src/seed.ts. - Schema changes: All schema changes (tables, columns, indexes, enums, constraints) MUST be made in
packages/db/src/schema.ts(the Drizzle TypeScript schema). Then runmake db-generate && make db-migrateto auto-generate and apply the migration. Never manually edit the generated SQL migration files — Drizzle's hash-based migration tracking depends on them being unmodified, and manual edits cause silent migration failures. The only exception ismake db-pushfor lightweight rename/enum-addition syncs (but it does NOT detect new CHECK constraints or indexes). drizzle-kit generate --customworkaround (TTY-less environments): Drizzle Kit 0.31's interactivegenerateprompts for column renames require a TTY — non-interactive shells (CI, piped stdin, agents without a PTY) error out with "Interactive prompts require a TTY terminal". The non-interactive pivot isdrizzle-kit generate --custom --name=<tag>which creates an empty SQL migration shell + a snapshot that is an EXACT COPY of the previous snapshot (NOT regenerated from the current schema). After writing the SQL by hand (per the design's reference template), you MUST also manually updatemeta/<NNNN>_snapshot.jsonto reflect EVERY schema change introduced by the migration — including but not limited to: renamed columns (both the JSON object key AND thenamevalue inside), new enum values (under theenumssection — Drizzle's snapshot stores them as avaluesarray), new/dropped columns, new indexes, new constraints. After editing, runmake db-generateagain and assert it outputs "No schema changes, nothing to migrate 😴" — if it produces a new migration file, your snapshot is missing a change the schema declares; diff the new migration to find the gap, fold it into the snapshot, delete the stray migration + its snapshot + journal entry, and re-run until clean. CI runsmake db-generateas a freshness check; a snapshot that doesn't match the schema fails the build. The hand-written SQL in the.sqlfile is NOT cross-checked against the snapshot — both must be authored to agree.- Seed idempotency:
packages/db/src/seed.tsmust be safe to run repeatedly. All seed helpers that insert into tables with unique constraints MUST useonConflictDoNothing({ target: [...] })keyed on those constraints, or select-and-reuse existing rows for tables without usable unique keys. This letsmake db-seedrecover when containers are recreated but the Postgres named volume still holds previous seed data. The seed script entrypoint MUST be guarded withif (import.meta.main)so the file can be imported by tests without executing the full seed. - Full DB reset:
make db-resetdrops and recreates the database, pushes the schema fresh, re-seeds, and flushes the Deno KV cache.
Testing
- Framework:
jsr:@std/testing/bdd(describe/it) +jsr:@std/expect. - Tests run with
--no-check(type-checking done separately). - Test files use
*.test.ts(or*.test.tsx) naming — never*_test.ts. - Tests need
DATABASE_URLandJWT_SECRETset;CACHE_DRIVER=memoryandAPP_ENV=testskip KV and email. - DB-backed tests target the dedicated
brewform_testdatabase (themake test*targets injectDATABASE_URLfor it) — NEVER the devbrewformDB, which tests would pollute. Runmake test-db-provisiononce aftermake up(mirrors.github/workflows/pr.ymlCI provisioning). - Email notifications are suppressed when
APP_ENV === 'test'.
Code style
- Formatting:
deno fmt(lineWidth 100, indentWidth 2, singleQuote, semiColons). - Run
make fmtbefore every commit. Symbolic edits and regex replacements preserve logic but may not match Deno's exact whitespace rules (trailing commas, line wrapping, indentation). CI runsdeno fmt --checkand fails the build on any diff. The pre-commit hook (.githooks/pre-commit, enabled viamake setup-hooks) also enforces this locally, but do not rely on the hook alone — runmake fmtproactively after each batch of edits, not just at commit time. - Lint exclusions:
no-import-prefix,no-unversioned-import(no-explicit-any,require-await,no-emptywere re-enabled in wave 5; seeopenspec/specs/lint-style). - Test files use line-level
// deno-lint-ignore <rule> -- <justification>directives, each immediately preceded by a comment explaining the rationale. File-level// deno-lint-ignore-filedirectives are not permitted (production or test). - All imports use explicit file extensions (
.ts,.tsx, etc.) — no sloppy imports. - Cache: never call
Deno.openKv()directly — useCacheProviderinterface via DI.
Logging
Every new feature or change that introduces a codepath must include structured logging.
Logger setup
- Shared interface:
@brewform/shared/loggerdefinesLogger,ChildLogger,CreateLogger. - API:
import { createLogger } from './utils/logger/index.ts'— pino-based, JSON structured. - Web:
import { createLogger } from '@/utils/logger.ts'— console-based, level-filtered viaVITE_LOG_LEVEL.
Usage
// API services
const log = createLogger('module-name');
log.debug({ userId, recipeId }, 'functionName started');
log.debug({ userId }, 'functionName completed');
log.error({ err, userId }, 'functionName failed');
// Web pages
const log = createLogger('PageName');
useEffect(() => {
log.debug({}, 'PageName mounted');
return () => { log.debug({}, 'PageName unmounted'); };
}, []);
Log levels
| Level | When to use |
|---|---|
trace |
Very detailed debugging (only in development) |
debug |
Function entry/exit, state transitions |
info |
Significant events (startup, connections, cache hits) |
warn |
Recoverable issues (rate limit hits, retries) |
error |
Operation failures (DB errors, validation failures) |
fatal |
Unrecoverable errors (server crash) |
Rules
- Never log passwords, tokens, secrets, API keys, or PII (emails, IPs).
- Create a module-scoped logger once at the top of the file.
- Use
log.debug({ relevantIds }, 'message')— include traceable IDs, exclude payloads. - Error logs must include the
errobject:log.error({ err, ...context }, 'what failed'). - API services: add entry/exit debug logs on every public function.
- Web pages: add mount/unmount debug logs via
useEffect. - Web API client errors are already logged — propagate errors properly to the caller.
- See
TODO_logs.mdfor modules still needing coverage.
Environment
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
info |
API pino log level |
LOG_FORMAT |
json |
API format (json/pretty) |
VITE_LOG_LEVEL |
info |
Web console log level |
Git Hooks
Run make setup-hooks once after cloning to enable pre-commit format and lint checks.
This sets git config core.hooksPath .githooks locally — it does not affect other contributors
until they also run the command.
Other conventions
- Check
/deno.jsontasksfield for all build/test/lint/dev commands. - Serena MCP:
make serena-upto start (SSE on :10122, dashboard :34283). - OpenAPI docs:
GET /api/v1/docs(Scalar UI),GET /api/v1/openapi.json; gated byOPENAPI_ENABLEDenv. - Self-hosted deployment:
docs/deployment_coolify.md(Coolify v4.1.x, as-built) andcoolify_deployment_plan.md(long-form). Images publish to GHCR via.github/workflows/release.yml; the web image's API URL is runtime-configurable viaVITE_API_URL(docker-web-entrypoint.shwrites/config.js). Key Coolify nuances: denokv runs as a Docker Compose resource (Docker Image resources have no command field), cross-stack reachability needs "Connect to Predefined Network", andS3_ENDPOINTis the account endpoint only (no bucket path).
