Files
penpot/backend/AGENTS.md
2026-03-24 18:00:39 +01:00

4.9 KiB
Raw Permalink Blame History

Penpot Backend Agent Instructions

Clojure backend (RPC) service running on the JVM.

Uses Integrant for dependency injection, PostgreSQL for storage, and Redis for messaging/caching.

General Guidelines

To ensure consistency across the Penpot JVM stack, all contributions must adhere to these criteria:

1. Testing & Validation

  • Coverage: If code is added or modified in src/, corresponding tests in test/backend_tests/ must be added or updated.

  • Execution:

    • Isolated: Run clojure -M:dev:test --focus backend-tests.my-ns-test for the specific test namespace.
    • Regression: Run clojure -M:dev:test to ensure the suite passes without regressions in related functional areas.

2. Code Quality & Formatting

  • Linting: All code must pass clj-kondo checks (run pnpm run lint:clj)
  • Formatting: All the code must pass the formatting check (run pnpm run check-fmt). Use pnpm run fmt to fix formatting issues. Avoid "dirty" diffs caused by unrelated whitespace changes.
  • Type Hinting: Use explicit JVM type hints (e.g., ^String, ^long) in performance-critical paths to avoid reflection overhead.

Code Conventions

Namespace Overview

The source is located under src directory and this is a general overview of namespaces structure:

  • app.rpc.commands.* RPC command implementations (auth, files, teams, etc.)
  • app.http.* HTTP routes and middleware
  • app.db.* Database layer
  • app.tasks.* Background job tasks
  • app.main Integrant system setup and entrypoint
  • app.loggers Internal loggers (auditlog, mattermost, etc.) (not to be confused with app.common.logging)

RPC

The RPC methods are implemented using a multimethod-like structure via the app.util.services namespace. The main RPC methods are collected under app.rpc.commands namespace and exposed under /api/rpc/command/<cmd-name>.

The RPC method accepts POST and GET requests indistinctly and uses the Accept header to negotiate the response encoding (which can be Transit — the default — or plain JSON). It also accepts Transit (default) or JSON as input, which should be indicated using the Content-Type header.

The main convention is: use get- prefix on RPC name when we want READ operation.

Example of RPC method definition:

(sv/defmethod ::my-command
  {::rpc/auth true            ;; requires auth
   ::doc/added "1.18"
   ::sm/params [:map ...]     ;; malli input schema
   ::sm/result [:map ...]}    ;; malli output schema
  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
  ;; return a plain map or throw
  {:id (uuid/next)})

Look under src/app/rpc/commands/*.clj to see more examples.

Tests

Test namespaces match .*-test$ under test/. Config is in tests.edn.

Integrant System

The src/app/main.clj declares the system map. Each key is a component; values are config maps with ::ig/ref for dependencies. Components implement ig/init-key / ig/halt-key!.

Database Access

app.db wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.

;; Query helpers
(db/get cfg-or-pool :table {:id id})                    ; fetch one row (throws if missing)
(db/get* cfg-or-pool :table {:id id})                   ; fetch one row (returns nil)
(db/query cfg-or-pool :table {:team-id team-id})        ; fetch multiple rows
(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert
(db/update! cfg-or-pool :table {:name "y"} {:id id})    ; update
(db/delete! cfg-or-pool :table {:id id})                ; delete

;; Run multiple statements/queries on single connection
(db/run! cfg (fn [{:keys [::db/conn]}]
               (db/insert! conn :table row1)
               (db/insert! conn :table row2))


;; Transactions
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
                  (db/insert! conn :table row)))

Almost all methods in the app.db namespace accept pool, conn, or cfg as params.

Migrations live in src/app/migrations/ as numbered SQL files. They run automatically on startup.

Error Handling

The exception helpers are defined on Common module, and are available under app.common.exceptions namespace.

Example of raising an exception:

(ex/raise :type :not-found
          :code :object-not-found
          :hint "File does not exist"
          :file-id id)

Common types: :not-found, :validation, :authorization, :conflict, :internal.

Performance Macros (app.common.data.macros)

Always prefer these macros over their clojure.core equivalents — they provide optimized implementations:

(dm/select-keys m [:a :b])     ;; faster than core/select-keys
(dm/get-in obj [:a :b :c])     ;; faster than core/get-in
(dm/str "a" "b" "c")           ;; string concatenation

Configuration

src/app/config.clj reads PENPOT_* environment variables, validated with Malli. Access anywhere via (cf/get :smtp-host). Feature flags: (cf/flags :enable-smtp).