Dev Toolbar Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a development toolbar to the app UI that shows request/SQL metrics and provides snapshot/restore/delete actions for the dev database and S3.

Architecture: A middleware injected via requiring-resolve when :dev? is true renders a toolbar into HTML responses via </body> string replacement. SQL metrics come from next.jdbc/with-logging. Snapshot/restore serializes 9 document tables as EDN + syncs S3 via the AWS SDK. All dev code lives in dev/ so it's excluded from production uberjars.

Tech Stack: Clojure, Ring/Reitit, Hiccup2, next.jdbc with-logging, AWS SDK v2 (S3 ListObjectsV2), HoneySQL, EDN serialization.


Task 1: Build script — accept :extra-paths

Files:

Step 1: Update the uber function signature and paths

Change the function to accept a map with optional :extra-paths:

(defn uber
  "Build an uberjar at target/orcha.jar.
   Cleans target/, copies source paths, and packages all dependencies.
   Accepts optional `:extra-paths` vector to include additional source directories."
  [{:keys [extra-paths]}]
  (let [artifact-dir "target"
        uber-file    (format "%s/orcha.jar" artifact-dir)
        basis        (c.t.build.api/create-basis {:project "deps.edn"})
        src-dirs     (into (:paths basis) extra-paths)]
    (c.t.build.api/delete
     {:path artifact-dir})
    (c.t.build.api/copy-dir
     {:target-dir artifact-dir
      :src-dirs   src-dirs})
    (c.t.build.api/uber
     {:basis             basis
      :class-dir         artifact-dir
      :uber-file         uber-file
      :main              'clojure.main
      :conflict-handlers log4j2-conflict-handler})))

Step 2: Verify default build still works

Run: clojure -X:build Expected: Builds target/orcha.jar as before (no extra paths).

Step 3: Verify extra-paths build works

Run: clojure -X:build :extra-paths '["dev"]' Expected: Builds uberjar that includes dev/ directory contents.

Verify: jar tf target/orcha.jar | grep user.clj should show user.clj.

Step 4: Update Tolaria run-demo.sh

Modify ../spikes/tolaria/scripts/run-demo.sh line 39 — change:

  clojure -X:build

to:

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

Also update the staleness check on line 37 to include dev/:

if [[ ! -f "$JAR_FILE" ]] || [[ -n "$(find "$ORCHA_DIR/src" "$ORCHA_DIR/resources" "$ORCHA_DIR/dev" -newer "$JAR_FILE" 2>/dev/null | head -1)" ]]; then

Step 5: Commit

git add scripts/ci/build.clj
git commit -m "feat(build): accept :extra-paths CLI arg for uberjar"

Then:

git add ../spikes/tolaria/scripts/run-demo.sh
git commit -m "feat(tolaria): include dev/ in demo uberjar build"

Task 2: Config — pass :dev? to app handler

Files:

Step 1: Add :dev? to the app handler config

In config.edn, inside :com.getorcha.app.http/handler (after line 148), add:

:dev?            #ref [:com.getorcha/dev?]

This makes (:dev? request) available to handlers and middleware after inject-config merges it.

Step 2: Verify via REPL

Run: (get-in (user/config) [:integrant/config :com.getorcha.app.http/handler :dev?]) Expected: true (in local-dev profile).

Step 3: Commit

git add resources/com/getorcha/config.edn
git commit -m "feat(config): pass :dev? flag to app HTTP handler"

Task 3: Gitignore — add dev/snapshots/

Files:

Step 1: Add snapshot directory

Add to .gitignore:

# Dev toolbar snapshots
dev/snapshots/

Step 2: Commit

git add .gitignore
git commit -m "chore: gitignore dev/snapshots/"

Task 4: Middleware — wrap-dev-toolbar

This is the core middleware that collects metrics and injects the toolbar HTML.

Files:

Step 1: Create the namespace with middleware

(ns com.getorcha.dev.toolbar
  "Dev toolbar middleware and routes.

   Conditionally loaded via requiring-resolve when :dev? is true.
   Lives in dev/ so it's excluded from production uberjars."
  (:require [clojure.string :as str]
            [com.getorcha.db.sql :as db.sql]
            [hiccup2.core :as hiccup]
            [next.jdbc :as jdbc]
            [reitit.core :as reitit]
            [ring.util.http-response :as ring.resp])
  (:import (hiccup.util RawString)))

Step 2: Implement SQL metrics tracking

The middleware creates an atom and wraps the db pool with jdbc/with-logging:

(defn ^:private wrap-db-pool-with-logging
  "Wraps a db pool with next.jdbc/with-logging to track query count and timing.
   Returns [wrapped-pool metrics-atom]."
  [db-pool]
  (let [metrics (atom {:queries [] :total-sql-ms 0.0})]
    [(jdbc/with-logging db-pool
       (fn [_sym _sql-params]
         (System/nanoTime))
       (fn [_sym start-ns result]
         (let [elapsed-ms (/ (- (System/nanoTime) (long start-ns)) 1e6)]
           (swap! metrics (fn [m]
                            (-> m
                                (update :queries conj {:ms elapsed-ms
                                                       :error? (instance? Throwable result)})
                                (update :total-sql-ms + elapsed-ms)))))))
     metrics]))

Step 3: Implement toolbar Hiccup rendering

See Task 5 for the full rendering code. For now, create a placeholder:

(defn ^:private render-toolbar
  "Renders the dev toolbar as an HTML string."
  [request-ms sql-metrics request]
  (str (hiccup/html
        [:div {:style "background:#0d1117;color:#c9d1d9;padding:4px 12px;border-top:1px solid #30363d;font:12px monospace"}
         (format "%.0fms | %d SQL %.1fms" request-ms (count (:queries sql-metrics)) (:total-sql-ms sql-metrics))])))

Step 4: Implement the middleware

The middleware must handle the async Ring 3-arity pattern (used by Jetty async):

(def wrap-dev-toolbar
  "Reitit middleware that wraps request handling with metrics collection
   and injects a toolbar into HTML responses."
  {:name ::dev-toolbar
   :wrap (fn [handler]
           (fn dev-toolbar [request respond raise]
             (let [start-ns                  (System/nanoTime)
                   [logged-pool sql-metrics] (wrap-db-pool-with-logging (:db-pool request))
                   request'                  (assoc request :db-pool logged-pool)]
               (handler request'
                        (fn [response]
                          (let [elapsed-ms (/ (- (System/nanoTime) start-ns) 1e6)
                                metrics    @sql-metrics]
                            (respond
                             (if (instance? RawString (:body response))
                               (let [html-str    (str (:body response))
                                     toolbar-str (render-toolbar elapsed-ms metrics request')]
                                 (assoc response :body (str/replace html-str "</body>" (str toolbar-str "</body>"))))
                               response))))
                        raise))))})

Step 5: Verify the middleware compiles

Run: /clojure-eval (require 'com.getorcha.dev.toolbar :reload) Expected: No errors.

Step 6: Commit

git add dev/com/getorcha/dev/toolbar.clj
git commit -m "feat(dev-toolbar): add middleware with SQL metrics tracking"

Task 5: Toolbar UI — Hiccup rendering

Files:

Step 1: Replace the placeholder render-toolbar with the full implementation

The toolbar uses a CSS checkbox hack for expand/collapse. It renders metrics on the left and actions on the right. It's a normal-flow block element (not fixed/floating).

(defn ^:private snapshot-controls
  "Renders snapshot action controls (take/restore)."
  [snapshots]
  (list
   [:form {:method "post" :action "/dev/snapshot"
           :style  "display:inline-flex;align-items:center;gap:6px"}
    [:button {:type  "submit"
              :style "background:#10b981;color:#0d1117;border:none;padding:2px 8px;border-radius:3px;cursor:pointer;font:12px monospace"}
     "Take Snapshot"]]
   (when (seq snapshots)
     [:form {:method "post"
             :style  "display:inline-flex;align-items:center;gap:6px;margin-left:12px"}
      [:select {:name  "timestamp"
                :style "background:#21262d;color:#c9d1d9;border:1px solid #30363d;padding:2px 4px;border-radius:3px;font:11px monospace"}
       (for [s (reverse snapshots)]
         [:option {:value s} s])]
      [:button {:type    "submit"
                :formaction "/dev/snapshot/restore"
                :style   "background:#f59e0b;color:#0d1117;border:none;padding:2px 8px;border-radius:3px;cursor:pointer;font:12px monospace"
                :onclick "return confirm('Restore snapshot? This will replace all document data.')"}
       "Restore"]])))


(defn ^:private route-actions
  "Renders route-specific action buttons."
  [request]
  (let [match      (reitit/match-by-path (::reitit/router request) (:uri request))
        route-name (some-> match :data :name)]
    (when (= route-name :com.getorcha.app.http.documents.view/detail)
      (let [doc-id (get-in match [:path-params :document-id])]
        [:form {:method "post" :action (str "/dev/documents/" doc-id "/delete")
                :style  "display:inline;margin-left:12px"
                :onsubmit "return confirm('Delete this document and all related data?')"}
         [:button {:type  "submit"
                   :style "background:#f85149;color:#fff;border:none;padding:2px 8px;border-radius:3px;cursor:pointer;font:12px monospace"}
          "Delete Document"]]))))


(defn ^:private list-snapshots
  "Returns a sorted vector of snapshot timestamp directory names."
  []
  (let [dir (java.io.File. "dev/snapshots")]
    (if (.isDirectory dir)
      (->> (.listFiles dir)
           (filter #(.isDirectory ^java.io.File %))
           (mapv #(.getName ^java.io.File %))
           sort
           vec)
      [])))


(defn ^:private render-toolbar
  "Renders the dev toolbar as an HTML string.
   The toolbar is a normal-flow element (not fixed) that pushes the footer up."
  [request-ms sql-metrics request]
  (let [query-count (count (:queries sql-metrics))
        sql-ms      (:total-sql-ms sql-metrics)
        snapshots   (list-snapshots)]
    (str (hiccup/html
          ;; Hidden checkbox for expand/collapse — CSS-only, no JS
          [:input#dev-toolbar-toggle
           {:type    "checkbox"
            :style   "display:none"}]
          [:div {:style "background:#0d1117;border-top:2px solid #30363d;font:12px/1.4 monospace;color:#c9d1d9"}
           ;; Collapsed bar — always visible
           [:label {:for   "dev-toolbar-toggle"
                    :style "display:flex;justify-content:space-between;align-items:center;padding:4px 12px;cursor:pointer;user-select:none"}
            [:span {:style "color:#8b949e"} "Dev Toolbar"]
            [:span {:style "color:#58a6ff"}
             (format "%.0fms" request-ms)
             [:span {:style "color:#484f58;margin:0 6px"} "|"]
             (format "%d SQL %.1fms" query-count sql-ms)]]
           ;; Expanded panel — shown when checkbox is checked via CSS sibling selector
           ;; The #dev-toolbar-toggle:checked ~ div > .dev-toolbar-expanded selector
           ;; is handled by an inline <style> tag below
           [:div.dev-toolbar-expanded
            {:style "display:none;padding:8px 12px;border-top:1px solid #21262d"}
            [:div {:style "display:flex;justify-content:space-between;align-items:flex-start"}
             ;; Left: metrics
             [:div {:style "display:flex;gap:24px"}
              [:div
               [:span {:style "color:#8b949e"} "Request "]
               [:span {:style "color:#58a6ff"} (format "%.1fms" request-ms)]]
              [:div
               [:span {:style "color:#8b949e"} "SQL "]
               [:span {:style "color:#58a6ff"} (str query-count " queries")]
               [:span {:style "color:#484f58;margin:0 4px"} "/"]
               [:span {:style "color:#58a6ff"} (format "%.1fms" sql-ms)]]]
             ;; Right: actions
             [:div {:style "display:flex;align-items:center"}
              (snapshot-controls snapshots)
              (route-actions request)]]]]
          ;; Style tag for the checkbox hack — sibling selector
          [:style (hiccup.util/raw-string
                   "#dev-toolbar-toggle:checked ~ div .dev-toolbar-expanded { display: block !important; }")]))))

Step 2: Verify rendering via REPL

Run:

(require 'com.getorcha.dev.toolbar :reload)
(@(requiring-resolve 'com.getorcha.dev.toolbar/render-toolbar) 42.5 {:queries [{:ms 3.2}] :total-sql-ms 3.2} {})

Expected: HTML string containing "Dev Toolbar", metrics, and action forms.

Step 3: Commit

git add dev/com/getorcha/dev/toolbar.clj
git commit -m "feat(dev-toolbar): full toolbar UI with expand/collapse and actions"

Task 6: Dev routes — snapshot/restore/delete endpoints

Files:

Step 1: Add the routes function

This returns a Reitit route vector. The snapshot and delete logic is delegated to com.getorcha.dev.toolbar.snapshot (Task 7-8). For now, stub the handlers:

(defn routes
  "Dev-only routes for snapshot/restore/delete actions.
   Registered conditionally via requiring-resolve in app.http."
  [_config]
  ["/dev" {:middleware [(app.http.middleware.auth/wrap-authentication)]}
   ["/snapshot"
    ["" {:name    ::take-snapshot
         :post    {:handler (fn [{:keys [db-pool aws] :as _request} respond _raise]
                              ;; Task 7 will implement the real logic
                              (let [snapshot-fn (requiring-resolve 'com.getorcha.dev.toolbar.snapshot/take-snapshot!)]
                                (snapshot-fn db-pool aws)
                                (respond (ring.resp/found "/ap"))))}}]
    ["/restore" {:name ::restore-snapshot
                 :post {:handler (fn [{:keys [db-pool aws] :as request} respond _raise]
                                   (let [timestamp  (get-in request [:form-params "timestamp"])
                                         restore-fn (requiring-resolve 'com.getorcha.dev.toolbar.snapshot/restore-snapshot!)]
                                     (restore-fn db-pool aws timestamp)
                                     (respond (ring.resp/found "/ap"))))}}]]
   ["/documents/:document-id/delete"
    {:name ::delete-document
     :post {:handler    (fn [{:keys [db-pool aws] :as request} respond _raise]
                          (let [doc-id    (parse-uuid (get-in request [:path-params :document-id]))
                                delete-fn (requiring-resolve 'com.getorcha.dev.toolbar.snapshot/delete-document!)]
                            (delete-fn db-pool aws doc-id)
                            (respond (ring.resp/found "/ap"))))
            :parameters {:path [:map [:document-id :string]]}}}]])

Note: The routes use requiring-resolve for snapshot functions too — this means toolbar/snapshot.clj is only loaded when a snapshot action is first triggered, not on every request.

Step 2: Commit

git add dev/com/getorcha/dev/toolbar.clj
git commit -m "feat(dev-toolbar): add dev routes for snapshot/restore/delete"

Task 7: Snapshot — database dump & restore

Files:

Reference files:

Step 1: Create the namespace with EDN serialization

(ns com.getorcha.dev.toolbar.snapshot
  "Snapshot/restore logic for dev toolbar.

   Dumps 9 document-related tables to EDN and syncs S3 bucket contents.
   Handles EDN serialization of PGobject (JSONB), UUID, java.time types."
  (:require [clojure.java.io :as io]
            [clojure.string :as str]
            [clojure.tools.reader.edn :as edn]
            [com.getorcha.db.sql :as db.sql]
            [honey.sql :as honey]
            [next.jdbc :as jdbc]
            [next.jdbc.result-set :as jdbc.rs])
  (:import (java.io PushbackReader)
           (java.time Instant LocalDate LocalDateTime OffsetDateTime)
           (java.time.format DateTimeFormatter)
           (java.util UUID)
           (org.postgresql.util PGobject)))

Step 2: EDN writer — custom print methods

Register print methods for types that don't have default EDN representation. These are only loaded in dev (this namespace lives in dev/):

;; Tagged literal writers for EDN round-tripping
(defmethod print-method Instant [^Instant v ^java.io.Writer w]
  (.write w (str "#inst \"" (.toString v) "\"")))

(defmethod print-method LocalDate [^LocalDate v ^java.io.Writer w]
  (.write w (str "#local-date \"" (.toString v) "\"")))

(defmethod print-method LocalDateTime [^LocalDateTime v ^java.io.Writer w]
  (.write w (str "#local-date-time \"" (.toString v) "\"")))

(defmethod print-method OffsetDateTime [^OffsetDateTime v ^java.io.Writer w]
  (.write w (str "#offset-date-time \"" (.toString v) "\"")))

(defmethod print-method PGobject [^PGobject v ^java.io.Writer w]
  (.write w (str "#pg \"" (.getValue v) "\"")))

Step 3: EDN reader — tagged literal readers

(def ^:private edn-readers
  "Tagged literal readers for snapshot EDN files."
  {'inst              #(Instant/parse %)
   'local-date        #(LocalDate/parse %)
   'local-date-time   #(LocalDateTime/parse %)
   'offset-date-time  #(OffsetDateTime/parse %)
   'pg                (fn [s]
                        (doto (PGobject.)
                          (.setType "jsonb")
                          (.setValue s)))
   'uuid              #(UUID/fromString %)})

Step 4: Table definitions

(def ^:private snapshot-tables
  "Tables to snapshot, in dependency order for inserts.
   Reverse this order for truncation."
  [:document_cluster
   :document
   :ingestion
   :ingestion_post_process_stat
   :document_match
   :supplier_verification
   :supplier_verification_document
   :qa_dataset_item
   :datev_export_audit])

Step 5: Dump functions

(defn ^:private snapshot-dir
  "Returns the path for a snapshot directory."
  ^java.io.File [timestamp]
  (io/file "dev" "snapshots" timestamp))


(defn ^:private dump-tables
  "Dumps all snapshot tables to a single EDN file."
  [db-pool ^java.io.File dir]
  (let [data (into {}
               (for [table snapshot-tables]
                 [table (db.sql/execute! db-pool {:select [:*] :from [table]})]))]
    (spit (io/file dir "snapshot.edn")
          (pr-str data))))

Step 6: Restore functions

(defn ^:private current-columns
  "Returns the set of column names (as keywords) for a table."
  [db-pool table-name]
  (->> (jdbc/execute! db-pool
         [(str "SELECT column_name FROM information_schema.columns WHERE table_name = ?")
          (name table-name)]
         {:builder-fn jdbc.rs/as-unqualified-kebab-maps})
       (mapv (comp keyword :column-name))
       set))


(defn ^:private filter-to-schema
  "Filters row maps to only include keys that exist in the current schema."
  [rows valid-columns]
  (mapv #(select-keys % valid-columns) rows))


(defn ^:private restore-tables
  "Restores tables from a snapshot EDN file.
   Truncates in reverse dependency order, inserts in dependency order.
   Filters columns to match current schema."
  [db-pool ^java.io.File dir]
  (let [data (with-open [r (PushbackReader. (io/reader (io/file dir "snapshot.edn")))]
               (edn/read {:readers edn-readers} r))]
    (jdbc/with-transaction [tx db-pool]
      ;; Truncate in reverse order
      (doseq [table (reverse snapshot-tables)]
        (jdbc/execute! tx [(str "TRUNCATE TABLE " (honey/format-entity table) " CASCADE")]))
      ;; Insert in dependency order
      (doseq [table snapshot-tables
              :let  [rows (get data table)]
              :when (seq rows)]
        (let [valid-cols (current-columns tx table)
              ;; next-jdbc needs unqualified keys for insert
              clean-rows (->> rows
                              (filter-to-schema valid-cols)
                              (mapv (fn [row]
                                      (into {} (map (fn [[k v]] [(keyword (name k)) v])) row))))]
          (jdbc/execute-batch! tx
            (str "INSERT INTO " (honey/format-entity table)
                 " (" (str/join ", " (map (comp honey/format-entity name) (keys (first clean-rows)))) ")"
                 " VALUES (" (str/join ", " (repeat (count (keys (first clean-rows))) "?")) ")")
            (mapv (fn [row] (mapv row (keys (first clean-rows)))) clean-rows)
            {}))))))

Step 7: Timestamp generation

(defn ^:private timestamp-now
  "Returns a filesystem-safe timestamp string."
  []
  (.format (LocalDateTime/now) (DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH-mm-ss")))

Step 8: Public API — take-snapshot!

(defn take-snapshot!
  "Takes a snapshot of document tables and S3 bucket."
  [db-pool aws]
  (let [ts  (timestamp-now)
        dir (snapshot-dir ts)]
    (.mkdirs dir)
    (dump-tables db-pool dir)
    (sync-s3-down! aws dir)  ;; Task 8
    ts))

Step 9: Public API — restore-snapshot!

(defn restore-snapshot!
  "Restores a snapshot. Truncates tables + clears S3 bucket, then repopulates."
  [db-pool aws timestamp]
  (let [dir (snapshot-dir timestamp)]
    (when-not (.isDirectory dir)
      (throw (ex-info (str "Snapshot not found: " timestamp) {:timestamp timestamp})))
    (restore-tables db-pool dir)
    (sync-s3-up! aws dir)))  ;; Task 8

Step 10: Commit

git add dev/com/getorcha/dev/toolbar/snapshot.clj
git commit -m "feat(dev-toolbar): snapshot dump/restore for document tables"

Task 8: Snapshot — S3 sync

Files:

Reference: src/com/getorcha/aws.clj — for S3Client type hints and existing get/put/delete patterns.

Step 1: Add S3 imports

Add to the :import block:

(software.amazon.awssdk.services.s3 S3Client)
(software.amazon.awssdk.services.s3.model
  DeleteObjectRequest GetObjectRequest ListObjectsV2Request
  PutObjectRequest S3Object)
(software.amazon.awssdk.core.sync RequestBody)

Step 2: Implement list-all-objects

(defn ^:private list-all-objects
  "Lists all object keys in an S3 bucket. Handles pagination."
  [^S3Client s3-client bucket]
  (loop [keys    []
         c-token nil]
    (let [request (cond-> (-> (ListObjectsV2Request/builder)
                              (.bucket bucket))
                    c-token (.continuationToken c-token)
                    true    (.build))
          resp    (.listObjectsV2 s3-client request)
          new-keys (into keys (map #(.key ^S3Object %) (.contents resp)))]
      (if (.isTruncated resp)
        (recur new-keys (.nextContinuationToken resp))
        new-keys))))

Step 3: Implement S3 download (sync down)

(defn ^:private sync-s3-down!
  "Downloads all objects from the storage bucket to the snapshot directory."
  [{:keys [clients s3-buckets] :as _aws} ^java.io.File snapshot-dir]
  (let [s3-client ^S3Client (:s3 clients)
        bucket    (:storage s3-buckets)
        s3-dir    (io/file snapshot-dir "s3")
        all-keys  (list-all-objects s3-client bucket)]
    (doseq [k all-keys]
      (let [target (io/file s3-dir k)]
        (.mkdirs (.getParentFile target))
        (let [bytes (com.getorcha.aws/get-object s3-client bucket k)]
          (with-open [out (io/output-stream target)]
            (.write out ^bytes bytes)))))))

Step 4: Implement S3 upload (sync up)

(defn ^:private sync-s3-up!
  "Clears the S3 bucket and uploads all files from the snapshot's s3/ directory."
  [{:keys [clients s3-buckets] :as _aws} ^java.io.File snapshot-dir]
  (let [s3-client ^S3Client (:s3 clients)
        bucket    (:storage s3-buckets)
        s3-dir    (io/file snapshot-dir "s3")]
    ;; Delete all existing objects
    (doseq [k (list-all-objects s3-client bucket)]
      (com.getorcha.aws/delete-object! s3-client bucket k))
    ;; Upload snapshot files
    (when (.isDirectory s3-dir)
      (let [s3-path (.toPath s3-dir)]
        (doseq [^java.io.File f (file-seq s3-dir)
                :when (.isFile f)]
          (let [key (str (.relativize s3-path (.toPath f)))]
            (com.getorcha.aws/put-object! s3-client bucket key
              (java.nio.file.Files/readAllBytes (.toPath f))
              "application/octet-stream")))))))

Step 5: Implement delete-document!

(defn delete-document!
  "Deletes a document and all related data from DB and S3."
  [db-pool {:keys [clients s3-buckets] :as _aws} document-id]
  (let [s3-client ^S3Client (:s3 clients)
        bucket    (:storage s3-buckets)]
    (jdbc/with-transaction [tx db-pool]
      ;; Find related ingestion IDs for S3 cleanup
      (let [ingestion-ids (->> (db.sql/execute! tx {:select [:id] :from [:ingestion]
                                                     :where  [:= :document-id document-id]})
                               (mapv :ingestion/id))]
        ;; Delete from tables in reverse dependency order
        (doseq [table [:datev_export_audit :qa_dataset_item
                        :supplier_verification_document :supplier_verification
                        :document_match :ingestion_post_process_stat :ingestion]]
          (db.sql/execute! tx {:delete-from table
                               :where       [:= :document-id document-id]}))
        ;; document_match has document_a_id and document_b_id
        (db.sql/execute! tx {:delete-from :document_match
                             :where       [:or
                                           [:= :document-a-id document-id]
                                           [:= :document-b-id document-id]]})
        ;; Finally delete the document itself
        (db.sql/execute! tx {:delete-from :document
                             :where       [:= :id document-id]})
        ;; S3 cleanup — delete document files and ingestion artifacts
        (doseq [k (list-all-objects s3-client bucket)]
          (when (or (str/starts-with? k (str "documents/" document-id))
                    (some #(str/starts-with? k (str "ingestions/" %)) ingestion-ids))
            (com.getorcha.aws/delete-object! s3-client bucket k)))))))

Step 6: Verify compilation

Run: /clojure-eval (require 'com.getorcha.dev.toolbar.snapshot :reload) Expected: No errors.

Step 7: Commit

git add dev/com/getorcha/dev/toolbar/snapshot.clj
git commit -m "feat(dev-toolbar): S3 sync and document delete for snapshots"

Task 9: Wire up — conditional loading in app/http.clj

Files:

Step 1: Modify the router function

Add dev? to the destructured keys and conditionally load dev routes and middleware. The key changes are in two places:

  1. Routes — add dev routes alongside existing routes (line 40-60 area):
(defn router
  [{:keys [session dev?] :as config}]
  (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)))]
    (r.ring/router
     [;; Health check
      ["/health" {:handler (fn [_request respond _raise]
                              (log/debug "ok health")
                              (respond {:status 200 :body [:p "ok"]}))}]
      ;; Unauthenticated views
      (app.http.connect.datev-rewe/routes config)
      (app.http.login/routes config)
      (app.http.webhooks/routes config)
      (app.http.settings.notifications/verify-routes config)
      ;; Dev routes (nil when not in dev mode)
      dev-routes
      ;; Authenticated views
      ["" {:middleware [(app.http.middleware.auth/wrap-authentication)]}
       (app.http.ap/routes config)
       (app.http.documents/routes config)
       (app.http.oauth/routes config)
       (app.http.profile/routes config)
       (app.http.settings.booking-history/routes config)
       (app.http.settings.data/routes config)
       (app.http.settings.google-drive/routes config)
       (app.http.settings.integrations/routes config)
       (app.http.settings.notifications/routes config)
       (app.http.notifications/routes config)]]
     {:data {:muuntaja   http.formats/muuntaja
             :coercion   reitit.coercion.malli/coercion
             :middleware (cond-> [xray/wrap-xray-tracing
                                  ring.middleware.cookies/wrap-cookies
                                  [ring.session/wrap-session
                                   {:store        (http.session/postgres-session-store config)
                                    :cookie-name  "orcha-app-session"
                                    :cookie-attrs {:http-only true
                                                   :same-site :lax
                                                   :secure    (:secure? session)
                                                   :path      "/"}}]
                                  r.ring.middleware.parameters/parameters-middleware
                                  r.ring.middleware.muuntaja/format-negotiate-middleware
                                  r.ring.middleware.muuntaja/format-response-middleware
                                  [app.http.error/wrap-error-pages]
                                  http.middleware/exception-middleware
                                  r.ring.middleware.muuntaja/format-request-middleware
                                  r.ring.coercion/coerce-response-middleware
                                  r.ring.coercion/coerce-request-middleware
                                  r.ring.middleware.multipart/multipart-middleware
                                  [http.middleware/inject-config (dissoc config :session)]]
                          dev-middleware (conj dev-middleware))}
      #_#_
      :reitit.middleware/transform (requiring-resolve 'reitit.ring.middleware.dev/print-request-diffs)})))

Step 2: Verify — reset the system

Run: /clojure-eval (reset) (with 30s timeout since system restart is slow) Expected: System restarts. Console shows "Started server on 8888."

Step 3: Verify — visit a page

Open https://orcha.barreto.tech/ap in a browser. The toolbar should appear at the bottom of the page (collapsed). Click it to expand and see metrics.

Step 4: Commit

git add src/com/getorcha/app/http.clj
git commit -m "feat(dev-toolbar): wire up conditional loading via requiring-resolve"

Task 10: Integration test — manual verification

No automated tests for this feature — it's dev-only UI tooling. Verify manually:

Step 1: Toolbar appears on pages

  1. Visit /ap — toolbar at bottom, collapsed
  2. Click to expand — shows request time and SQL count/time
  3. Visit /documents/<some-id> — toolbar shows "Delete Document" button

Step 2: Snapshot/restore cycle

  1. Expand toolbar on /ap
  2. Click "Take Snapshot" — should redirect back to /ap, snapshot created in dev/snapshots/
  3. Verify dev/snapshots/<timestamp>/snapshot.edn exists and contains data
  4. Verify dev/snapshots/<timestamp>/s3/ contains document files
  5. Delete a document via the toolbar on a document page
  6. Expand toolbar on /ap — dropdown should show the snapshot
  7. Select it and click "Restore" — document should reappear

Step 3: Lint

Run: clj-kondo --lint dev Expected: No errors.

Step 4: Final commit if any fixes needed


Task 11: Cleanup — squash or tag

Review all commits, ensure they're clean. The feature is complete when: