Dev Toolbar Design

A development toolbar inspired by Django's debug-toolbar. Appears when :dev? is true in config.edn, backed by middleware that is only loaded when the dev alias is active.

UI

Fixed bar at the bottom of every page, in normal document flow (not floating — pushes footer upward). Collapsed by default to a thin ~28px strip, expandable via CSS checkbox hack (hidden <input type="checkbox">

Collapsed view: "Dev Toolbar" label + summary metrics (42ms | 7 SQL 12ms).

Expanded view (~120px):

Left (metrics) Right (actions)
Request time: 42ms Take Snapshot (text input for optional name, defaults to timestamp)
SQL: 7 queries, 12ms Restore Snapshot (dropdown of existing snapshots, defaults to most recent)
Route-specific actions

Route-specific actions:

Styling: Inline styles in Hiccup (no production CSS changes). Colors from existing theme: #0d1117 background, #30363d border, #10b981 accent. Monospace font for metric values.

Middleware & Metrics Collection

A single middleware (wrap-dev-toolbar) does everything:

  1. Record System/nanoTime start.
  2. Create a metrics atom.
  3. Wrap the db pool with next.jdbc/with-logging — the sql-logger returns (System/nanoTime) as state, the result-logger computes elapsed ms and swap!s query info into the atom.
  4. Pass the wrapped pool through the request (replacing :db-pool in the injected config).
  5. Call the handler — returns a response whose :body is a hiccup.util.RawString.
  6. Compute request duration from nanoTime delta.
  7. Read SQL metrics from the atom.
  8. Render the toolbar as HTML via (str (hiccup/html ...)).
  9. Convert the response body to string, replace </body> with toolbar-html</body>. Return the modified response with a plain String body.
  10. Only inject when the body is a RawString (skip HTMX fragments, JSON, redirects, etc.).

This is the same approach Django's debug-toolbar uses: render after the handler completes, string-replace into the response.

Dev-only routes (registered conditionally):

Snapshot & Restore

Storage

dev/snapshots/
  2026-03-21T14-30-00/
    snapshot.edn
    s3/
      documents/...
      ingestions/...
  2026-03-21T16-45-12/
    snapshot.edn
    s3/

Each snapshot is a timestamped directory, never overwritten. dev/snapshots/ is gitignored.

Database

Single snapshot.edn file containing a map keyed by table name, each value a vector of row maps:

{:document          [{...} {...}]
 :ingestion         [{...} {...}]
 :document_match    [{...} {...}]
 ...}

Tables (9, in dependency order for inserts):

  1. document_cluster
  2. document
  3. ingestion
  4. ingestion_post_process_stat
  5. document_match
  6. supplier_verification
  7. supplier_verification_document
  8. qa_dataset_item
  9. datev_export_audit

EDN serialization: Custom readers/writers for java.util.UUID, java.time.Instant, java.time.LocalDate, and JSONB (PGobject) — same approach as the existing fetch-document babashka tool.

Column compatibility on restore: Read current columns from information_schema.columns for each table. For each row in the snapshot, keep only keys that exist in the current schema. Missing (new) columns get database defaults. Dropped columns are silently skipped.

S3

Pure AWS SDK v2 implementation — list-objects-v2, get-object, put-object! — self-contained in the dev namespace, not in aws.clj.

Snapshot: List all objects in the storage bucket, download each to dev/snapshots/<timestamp>/s3/ preserving key paths.

Restore: Delete all objects in the bucket, then upload everything from dev/snapshots/<timestamp>/s3/ back.

Delete Document

Cascade delete from the 9 tables in reverse dependency order (by document_id or related ingestion_id), then delete S3 objects: documents/<id>.*, documents/<id>/, and ingestions/<ingestion-id>/ for each related ingestion.

Namespace & File Structure

All code lives in dev/ (only on classpath with :dev alias):

dev/
  user.clj                              # existing
  com/getorcha/dev/
    toolbar.clj                         # middleware, routes, rendering
    toolbar/
      snapshot.clj                      # snapshot/restore/delete logic

Conditional Loading

In app/http.clj, routes and middleware are loaded via requiring-resolve wrapped in try/catch:

(let [dev-routes    (when dev?
                      (try ((requiring-resolve 'com.getorcha.dev.toolbar/routes) config)
                           (catch Exception _ nil)))
      dev-middleware (when dev?
                      (try (requiring-resolve 'com.getorcha.dev.toolbar/wrap-dev-toolbar)
                           (catch Exception _ nil)))]
  ...)

If dev/ is not on the classpath (e.g., sandbox running from uberjar without extra paths), the requiring-resolve fails silently and the toolbar is not loaded.

Build Changes

scripts/ci/build.clj accepts an optional :extra-paths CLI argument:

(defn uber
  [{:keys [extra-paths]}]
  (let [basis    (c.t.build.api/create-basis {:project "deps.edn"})
        src-dirs (into (:paths basis) extra-paths)
        ...]
    ...))

Tolaria demo builds with:

clojure -X:build :extra-paths '["dev"]'

Default clojure -X:build (no args) is unchanged.