From c34908ac8fe5ce9ee1d89ec5141e437cb10d7119 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Mon, 9 Mar 2026 19:46:44 +0100 Subject: [PATCH 1/5] init xtdb -> datomic refactor --- CLAUDE.md | 178 +++++++++++++++++++++++++++++++ deps.edn | 1 + src/snippets/infra/db2.clj | 210 +++++++++++++++++++++++++++++++++++++ src/snippets/main.clj | 5 +- src/snippets/mocks.clj | 7 ++ 5 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 src/snippets/infra/db2.clj create mode 100644 src/snippets/mocks.clj diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..32aecf0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,178 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A REST API backend for managing code snippets, written in Clojure using XTDB for data storage. The API provides endpoints for creating, reading, editing, and deleting snippets, with support for tagging and slug-based lookup. + +**Related Projects:** +- [Snippets CMS](https://git.travisshears.com/travisshears/snippets_cms) - TUI CMS and MCP server companion project + +## Development Commands + +### Run Development Server + +```sh +clojure -M -m snippets.main +``` + +Starts the API server on the configured host/port (default: localhost:8080). The REPL can be used to develop with the server: + +```clojure +; Start server in background from REPL: +(def server (jetty/run-jetty #'app {:port 3000 :join? false})) +; Stop server: +(.stop server) +``` + +### Build Uberjar + +```sh +clojure -T:build uber +``` + +Creates a standalone JAR file at `target/snippets-standalone.jar` that includes all dependencies. + +### REPL Commands + +Common REPL operations: + +```clojure +; Pretty-print data structures +(clojure.pprint/pprint {:name "Alice" :age 30}) + +; Require a namespace (fresh import) +(require 'clojure.string) +(require '[clojure.string :as str]) +(require 'my.namespace :reload) + +; List namespace contents +(dir clojure.string) + +; Switch namespaces +(in-ns 'my.namespace) +(in-ns 'user) +``` + +### Linting + +The project uses clj-kondo for static analysis. Configuration is in `.clj-kondo/config.edn`. Linting is integrated into most IDEs via LSP. + +## Code Architecture + +### Directory Structure + +- **src/snippets/infra/** - Infrastructure layer (HTTP API, database, configuration) + - `api.clj` - HTTP routing and handlers using reitit + - `db.clj` - XTDB queries and transactions + - `config.clj` - Configuration loading (config.edn + environment variables) +- **src/snippets/use_cases/** - Business logic layer + - `view.clj` - Query/read operations, includes serialization for JSON output + - `create.clj` - Create snippets + - `edit.clj` - Patch snippets with validation + - `delete.clj` - Delete snippets +- **test/** - Test suite + +### Architecture Pattern + +Clean separation between layers: + +1. **Infrastructure (infra/)**: Handles external concerns + - HTTP/REST via reitit + ring + - XTDB database client and queries + - Environment configuration + +2. **Use Cases**: Business logic implementing application features + - Each use case is a module with focused responsibility + - Calls infrastructure layer (infra.db, infra.config) as needed + - Performs serialization/transformation for API responses + +3. **Main Entry Point (main.clj)**: Minimal bootstrap that runs the API server + +### Data Model + +Snippets are XTDB documents with: +- `xt/id` - UUID identifier (set by create use case) +- `title` - Snippet title +- `slug` - URL-friendly identifier for query lookups +- `markdown` - Snippet content in markdown format +- `tags` - Vector of strings for categorization +- `pub-date` - Date object (serialized to ISO-8601 strings for API responses) + +### API Endpoints + +All routes under `/api`: + +- `GET /api/ping` - Health check +- `GET /api/snippets` - List snippets (with optional `limit` and `skip` query params) +- `GET /api/snippet?id=` - Get snippet by UUID +- `GET /api/snippet-by-slug?slug=` - Get snippet by slug +- `POST /api/snippet` - Create snippet +- `PATCH /api/snippet?id=` - Edit snippet (patches are validated against schema) +- `DELETE /api/snippet?id=` - Delete snippet +- `GET /api/tags` - List all tags with counts +- `GET /api/tag?tag=` - Get snippets by tag + +### Key Dependencies + +- **reitit** (0.9.1) - HTTP routing and coercion +- **ring** (1.13.0) - HTTP server (jetty adapter) +- **XTDB** (2.0.0-beta9) - Temporal database +- **malli** (0.18.0) - Schema validation and generation +- **muuntaja** (0.6.11) - JSON/EDN encoding/decoding +- **telemere** (1.0.0) - Structured logging +- **environ** (1.2.0) - Environment variable loading + +### Configuration + +Configuration comes from `config.edn` with environment variable overrides: + +```edn +{:jetty {:host "localhost" :port 8080} + :xtdb {:host "192.168.1.157" :port "5007" :user "xtdb" :dbname "xtdb"}} +``` + +Environment variables can override specific values: +- `HOST` - Jetty host +- `PORT` - Jetty port (parsed as integer) +- `XTDB_HOST`, `XTDB_PORT`, `XTDB_USER`, `XTDB_DBNAME` - XTDB connection details + +See `infra/config.clj` for how overrides are applied. + +## Testing + +Run the test suite: + +```sh +clojure -M:test:clojure.test/run +``` + +Or from REPL: + +```clojure +(clojure.test/run-tests 'snippets-test) +``` + +The test namespace `snippets-test` includes: +- Basic arithmetic tests +- XTDB integration tests using test node +- HTML rendering tests using rum + +## Deployment + +Docker image built and pushed to AWS ECR via `build.sh`: + +```sh +./build.sh +``` + +Requires AWS CLI configured with `personal` profile and permission to push to ECR repository. + +## Notes + +- XTDB queries use XTQL (temporal query language) +- Date serialization happens in `view.clj` - pub-date objects are converted to ISO-8601 strings for JSON responses +- Patch validation is strict (`{:closed true}`) - only specific fields can be updated +- String formatting in some XTQL queries uses `eval` and `read-string` for dynamic query construction +- UUIDs used for snippet IDs; generated client-side on creation diff --git a/deps.edn b/deps.edn index fbc464d..0a79182 100644 --- a/deps.edn +++ b/deps.edn @@ -5,6 +5,7 @@ org.slf4j/slf4j-simple {:mvn/version "2.0.16"} ;; db + com.datomic/local {:mvn/version "1.0.291"} com.xtdb/xtdb-api {:mvn/version "2.0.0-beta9"} com.github.seancorfield/next.jdbc {:mvn/version "1.3.1002"} org.postgresql/postgresql {:mvn/version "42.7.6"} diff --git a/src/snippets/infra/db2.clj b/src/snippets/infra/db2.clj new file mode 100644 index 0000000..99ee9ec --- /dev/null +++ b/src/snippets/infra/db2.clj @@ -0,0 +1,210 @@ +(ns snippets.infra.db2 + (:require + [datomic.client.api :as d] + [taoensso.telemere :as t] + [malli.core :as m])) + +;; Initialize the Datomic Local client +;; :system "dev" groups your databases in the "dev" system +;; In production, you'd set :storage-dir to a persistent path +(def client (d/client {:server-type :datomic-local + :system "dev"})) + +(def db-name "snippets") + +;; Create the database if it doesn't exist +(defn ensure-db + "Check if db exists, create it if not." + [] + (d/create-database client {:db-name db-name}) + (t/log! {:level :info} "Snippets database created if needed")) + +;; Get a connection to the database +(defn get-conn [] + (d/connect client {:db-name db-name})) + +;; Define the schema for snippets +;; Transact this once to set up the database structure +(def snippet-schema + [{:db/ident :snippet/title + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :snippet/slug + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value} + {:db/ident :snippet/markdown + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :snippet/tags + :db/valueType :db.type/string + :db/cardinality :db.cardinality/many} + {:db/ident :snippet/pub-date + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one}]) + +(defn ensure-schema + "Transact the schema if it doesn't exist. Call this once on startup." + [] + (let [conn (get-conn)] + (d/transact conn {:tx-data snippet-schema}) + (t/log! {:level :info} "Snippet schema created if needed"))) + +(defn start-up-check [] + (ensure-db) + (ensure-schema)) + +(defn snippet-to-entity + "Convert a snippet map to a Datomic DB entity." + [snippet] + {:snippet/title (:title snippet) + :snippet/slug (:slug snippet) + :snippet/markdown (:markdown snippet) + :snippet/tags (:tags snippet) + :snippet/pub-date (:pub-date snippet)}) + +;; create +(def create-schema + "Malli schema for a valid snippet entity creation." + [:map + [:snippet/title :string] + [:snippet/slug :string] + [:snippet/markdown :string] + [:snippet/tags [:vector :string]] + [:snippet/pub-date [:fn #(instance? java.util.Date %)]]]) + +(defn valid-create? + "Check if a snippet map is a valid Datomic entity." + [entity] + (m/validate create-schema entity)) + +(defn put-snippets + "Create new snippets in the database." + [snippets] + (t/log! {:level :info, :data {:slugs (map :slug snippets)}} "Saving new snippets to db") + (let [conn (get-conn) + entities (map snippet-to-entity snippets)] + (if (every? valid-create? entities) + (d/transact conn {:tx-data entities}) + (throw (ex-info "Invalid snippet entity" {:entities entities}))))) + +;; read +(defn get-snippet-by-slug + "Get a single snippet by its slug." + [slug] + (let [conn (get-conn) + db (d/db conn) + query '[:find (pull ?e [*]) + :where + [?e :snippet/slug ?slug]] + results (d/q {:query query :args [db slug]})] + (ffirst results))) + +;; update +(def update-schema + "Malli schema for a valid update to a snippet entity." + [:map + [:snippet/title {:optional true} :string] + [:snippet/slug {:optional true} :string] + [:snippet/markdown {:optional true} :string] + [:snippet/tags {:optional true} [:vector :string]]]) + +(defn to-update [patch] + (cond-> {} + (some? (:title patch)) (assoc :snippet/title (:title patch)) + (some? (:slug patch)) (assoc :snippet/slug (:slug patch)) + (some? (:markdown patch)) (assoc :snippet/markdown (:markdown patch)) + (some? (:tags patch)) (assoc :snippet/tags (:tags patch)))) + +(defn patch-snippet + "Update specific fields of a snippet." + [slug raw-patch] + (let [conn (get-conn) + eid (:db/id (get-snippet-by-slug slug)) + patch (to-update raw-patch)] + (t/log! {:level :info, :data {:patch patch :slug slug}} "Patching snippet") + (when (nil? eid) + (throw (ex-info "Snippet not found" {:slug slug}))) + (when-not (m/validate update-schema patch) + (throw (ex-info "Invalid patch" {:errors (m/explain update-schema patch) :patch patch}))) + (let [tx-data [(merge {:db/id eid} patch)]] + (d/transact conn {:tx-data tx-data})))) + +;; (defn list-snippets +;; "List snippets with optional pagination. Default order is by pub-date descending." +;; [{:keys [skip limit]}] +;; (let [conn (get-conn) +;; db (d/db conn) +;; query '[:find (pull ?e [*]) +;; :where +;; [?e :snippet/title]] +;; results (d/q {:query query :args [db]})] +;; ;; Convert results and sort by pub-date descending +;; (->> results +;; (map first) +;; (sort-by #(-> % :snippet/pub-date .getTime) >) +;; (drop (or skip 0)) +;; (cond-> limit (take limit)) +;; vec))) + +;; (defn get-snippet-by-slug [slug] +;; "Get a single snippet by its slug." +;; (let [conn (get-conn) +;; db (d/db conn) +;; query '[:find (pull ?e [*]) +;; :where +;; [?e :snippet/slug ?slug]] +;; results (d/q {:query query :args [db slug]})] +;; (first (first results)))) + +;; (defn delete-snippet [id] +;; "Soft delete a snippet (retract its entity)." +;; (t/log! {:level :info, :data {:id id}} "Deleting snippet") +;; (let [conn (get-conn) +;; db (d/db conn) +;; query '[:find ?e . +;; :where +;; [?e :snippet/uuid ?uuid]] +;; eid (d/q {:query query :args [db id]})] +;; (when eid +;; (d/transact conn {:tx-data [[:db/retractEntity eid]]})))) + +;; (defn erase-snippet [id] +;; "Hard delete a snippet. In Datomic, this is the same as soft delete +;; since all history is still available through time-travel queries." +;; (delete-snippet id)) + +;; (defn patch-snippet [id patch] +;; "Update specific fields of a snippet." +;; (t/log! {:level :info, :data {:patch patch :id id}} "Patching snippet") +;; (let [conn (get-conn) +;; db (d/db conn) +;; query '[:find ?e . +;; :where +;; [?e :snippet/uuid ?uuid]] +;; eid (d/q {:query query :args [db id]})] +;; (when eid +;; (let [tx-data (into [] (map (fn [[k v]] +;; (let [attr (keyword "snippet" (name k))] +;; [:db/add eid attr v])) +;; patch))] +;; (d/transact conn {:tx-data tx-data}))))) + +;; (defn list-tags [] +;; "List all tags used in snippets with their counts." +;; (let [conn (get-conn) +;; db (d/db conn) +;; query '[:find ?tag (count ?e) +;; :where +;; [?e :snippet/tags ?tag]]] +;; (d/q {:query query :args [db]}))) + +;; (defn get-snippets-by-tag [tag] +;; "Get all snippets that have a specific tag." +;; (let [conn (get-conn) +;; db (d/db conn) +;; query '[:find (pull ?e [*]) +;; :where +;; [?e :snippet/tags ?tag]] +;; results (d/q {:query query :args [db tag]})] +;; (mapv first results))) diff --git a/src/snippets/main.clj b/src/snippets/main.clj index 4e0e10d..e167b83 100644 --- a/src/snippets/main.clj +++ b/src/snippets/main.clj @@ -1,6 +1,9 @@ (ns snippets.main - (:require [snippets.infra.api :as api]) + (:require + [snippets.infra.api :as api] + [snippets.infra.db2 :refer [start-up-check]]) (:gen-class)) (defn -main [] + (start-up-check) (api/run-server)) diff --git a/src/snippets/mocks.clj b/src/snippets/mocks.clj new file mode 100644 index 0000000..1e41d32 --- /dev/null +++ b/src/snippets/mocks.clj @@ -0,0 +1,7 @@ +(ns snippets.mocks) + +(def mock-snippet {:slug "mock-1" + :title "Mock Snippet 1" + :markdown "## Mock Snippet 1" + :tags ["mock"] + :pub-date (java.util.Date/from (java.time.Instant/parse "2020-01-01T00:00:00Z"))}) From 7d497191cbaf387a16b58c5106b17575d0c600fc Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Tue, 10 Mar 2026 13:48:32 +0100 Subject: [PATCH 2/5] add more db2 fns new fns: - get-snippets-by-tag - list-snippets - delete-snippet-by-slug --- src/snippets/infra/db2.clj | 122 +++++++++++++------------------------ 1 file changed, 43 insertions(+), 79 deletions(-) diff --git a/src/snippets/infra/db2.clj b/src/snippets/infra/db2.clj index 99ee9ec..37ef082 100644 --- a/src/snippets/infra/db2.clj +++ b/src/snippets/infra/db2.clj @@ -95,10 +95,9 @@ (let [conn (get-conn) db (d/db conn) query '[:find (pull ?e [*]) - :where - [?e :snippet/slug ?slug]] - results (d/q {:query query :args [db slug]})] - (ffirst results))) + :in $ ?slug + :where [?e :snippet/slug ?slug]]] + (ffirst (d/q query db slug)))) ;; update (def update-schema @@ -130,81 +129,46 @@ (let [tx-data [(merge {:db/id eid} patch)]] (d/transact conn {:tx-data tx-data})))) -;; (defn list-snippets -;; "List snippets with optional pagination. Default order is by pub-date descending." -;; [{:keys [skip limit]}] -;; (let [conn (get-conn) -;; db (d/db conn) -;; query '[:find (pull ?e [*]) -;; :where -;; [?e :snippet/title]] -;; results (d/q {:query query :args [db]})] -;; ;; Convert results and sort by pub-date descending -;; (->> results -;; (map first) -;; (sort-by #(-> % :snippet/pub-date .getTime) >) -;; (drop (or skip 0)) -;; (cond-> limit (take limit)) -;; vec))) +(defn list-snippets + "List all the snippets" + [] + (let [conn (get-conn) + db (d/db conn) + query '[:find (pull ?e [*]) + :where + [?e :snippet/slug]]] + (->> (d/q query db) + (map first)))) -;; (defn get-snippet-by-slug [slug] -;; "Get a single snippet by its slug." -;; (let [conn (get-conn) -;; db (d/db conn) -;; query '[:find (pull ?e [*]) -;; :where -;; [?e :snippet/slug ?slug]] -;; results (d/q {:query query :args [db slug]})] -;; (first (first results)))) +(defn delete-snippet-by-slug + "Soft delete a snippet (retract its entity)." + [slug] + (t/log! {:level :info, :data {:slug slug}} "Retracting snippet") + (let [conn (get-conn) + db (d/db conn) + eid (:db/id (get-snippet-by-slug slug))] + (when (nil? eid) + (throw (ex-info "Snippet not found" {:slug slug}))) + (d/transact conn {:tx-data [[:db/retractEntity eid]]}))) -;; (defn delete-snippet [id] -;; "Soft delete a snippet (retract its entity)." -;; (t/log! {:level :info, :data {:id id}} "Deleting snippet") -;; (let [conn (get-conn) -;; db (d/db conn) -;; query '[:find ?e . -;; :where -;; [?e :snippet/uuid ?uuid]] -;; eid (d/q {:query query :args [db id]})] -;; (when eid -;; (d/transact conn {:tx-data [[:db/retractEntity eid]]})))) +(defn list-tags + "List all tags used in snippets with their counts." + [] + (let [conn (get-conn) + db (d/db conn) + query '[:find ?tag (count ?e) + :where + [?e :snippet/tags ?tag]]] + (d/q query db))) -;; (defn erase-snippet [id] -;; "Hard delete a snippet. In Datomic, this is the same as soft delete -;; since all history is still available through time-travel queries." -;; (delete-snippet id)) - -;; (defn patch-snippet [id patch] -;; "Update specific fields of a snippet." -;; (t/log! {:level :info, :data {:patch patch :id id}} "Patching snippet") -;; (let [conn (get-conn) -;; db (d/db conn) -;; query '[:find ?e . -;; :where -;; [?e :snippet/uuid ?uuid]] -;; eid (d/q {:query query :args [db id]})] -;; (when eid -;; (let [tx-data (into [] (map (fn [[k v]] -;; (let [attr (keyword "snippet" (name k))] -;; [:db/add eid attr v])) -;; patch))] -;; (d/transact conn {:tx-data tx-data}))))) - -;; (defn list-tags [] -;; "List all tags used in snippets with their counts." -;; (let [conn (get-conn) -;; db (d/db conn) -;; query '[:find ?tag (count ?e) -;; :where -;; [?e :snippet/tags ?tag]]] -;; (d/q {:query query :args [db]}))) - -;; (defn get-snippets-by-tag [tag] -;; "Get all snippets that have a specific tag." -;; (let [conn (get-conn) -;; db (d/db conn) -;; query '[:find (pull ?e [*]) -;; :where -;; [?e :snippet/tags ?tag]] -;; results (d/q {:query query :args [db tag]})] -;; (mapv first results))) +(defn get-snippets-by-tag + "Get all snippets that have a specific tag." + [tag] + (let [conn (get-conn) + db (d/db conn) + query '[:find (pull ?e [*]) + :in $ ?tag + :where + [?e :snippet/tags ?tag]] + results (d/q query db tag)] + (mapv first results))) From d3babebcc4c47dd26a0db35d32aca1fced7fd039 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Tue, 10 Mar 2026 14:19:16 +0100 Subject: [PATCH 3/5] start using db2 in view use-case --- src/snippets/infra/api.clj | 8 +---- src/snippets/infra/db2.clj | 60 ++++++++++++++++++++++++++------- src/snippets/use_cases/view.clj | 17 ++++++---- 3 files changed, 58 insertions(+), 27 deletions(-) diff --git a/src/snippets/infra/api.clj b/src/snippets/infra/api.clj index 14f8a37..65b87eb 100644 --- a/src/snippets/infra/api.clj +++ b/src/snippets/infra/api.clj @@ -38,12 +38,7 @@ {:status 200 :body (snippets.use-cases.view/view-snippets {:limit limit-num :skip skip-num})}) {:status 200 - :body (snippets.use-cases.view/view-snippets)})) - -(defn handle-view-snippet [{params :query-params}] - (let [id (get params "id")] - {:status 200 - :body (snippets.use-cases.view/view-snippet id)})) + :body (snippets.use-cases.view/view-snippets nil)})) (defn handle-delete-snippet [{params :query-params}] (let [id (get params "id")] @@ -82,7 +77,6 @@ ["/snippet-by-slug" {:get handle-view-snippet-by-slug}] ["/snippets" {:get handle-view-snippets}] ["/snippet" {:post handle-create-snippet - :get handle-view-snippet :patch handle-edit-snippet :delete handle-delete-snippet}]]) (rr/create-default-handler))) diff --git a/src/snippets/infra/db2.clj b/src/snippets/infra/db2.clj index 37ef082..9df0f27 100644 --- a/src/snippets/infra/db2.clj +++ b/src/snippets/infra/db2.clj @@ -7,20 +7,21 @@ ;; Initialize the Datomic Local client ;; :system "dev" groups your databases in the "dev" system ;; In production, you'd set :storage-dir to a persistent path +;; TODO: add save file location for prod (def client (d/client {:server-type :datomic-local :system "dev"})) (def db-name "snippets") ;; Create the database if it doesn't exist -(defn ensure-db +(defn- ensure-db "Check if db exists, create it if not." [] (d/create-database client {:db-name db-name}) (t/log! {:level :info} "Snippets database created if needed")) ;; Get a connection to the database -(defn get-conn [] +(defn- get-conn [] (d/connect client {:db-name db-name})) ;; Define the schema for snippets @@ -43,18 +44,20 @@ :db/valueType :db.type/instant :db/cardinality :db.cardinality/one}]) -(defn ensure-schema +(defn- ensure-schema "Transact the schema if it doesn't exist. Call this once on startup." [] (let [conn (get-conn)] (d/transact conn {:tx-data snippet-schema}) (t/log! {:level :info} "Snippet schema created if needed"))) -(defn start-up-check [] +(defn start-up-check + "Should be run at startup to ensure the database and schema are created." + [] (ensure-db) (ensure-schema)) -(defn snippet-to-entity +(defn- snippet-to-entity "Convert a snippet map to a Datomic DB entity." [snippet] {:snippet/title (:title snippet) @@ -63,6 +66,26 @@ :snippet/tags (:tags snippet) :snippet/pub-date (:pub-date snippet)}) +(defn- entity-to-snippet + "Convert a Datomic DB entity to a snippet map." + [entity] + {:title (:snippet/title entity) + :slug (:snippet/slug entity) + :markdown (:snippet/markdown entity) + :tags (:snippet/tags entity) + :pub-date (:snippet/pub-date entity)}) + +(defn- wrap-snippet-return + "Wraps an fn that returns snippet, snippet[], or nil; converting the entity to a snippet map." + [snippet-fn] + (fn [& args] + (let [res (apply snippet-fn args)] + (cond + (nil? res) nil + :else (if (sequential? res) + (map entity-to-snippet res) + (entity-to-snippet res)))))) + ;; create (def create-schema "Malli schema for a valid snippet entity creation." @@ -73,12 +96,12 @@ [:snippet/tags [:vector :string]] [:snippet/pub-date [:fn #(instance? java.util.Date %)]]]) -(defn valid-create? +(defn- valid-create? "Check if a snippet map is a valid Datomic entity." [entity] (m/validate create-schema entity)) -(defn put-snippets +(defn- put-snippets "Create new snippets in the database." [snippets] (t/log! {:level :info, :data {:slugs (map :slug snippets)}} "Saving new snippets to db") @@ -88,8 +111,11 @@ (d/transact conn {:tx-data entities}) (throw (ex-info "Invalid snippet entity" {:entities entities}))))) +(def create-snippets + (wrap-snippet-return put-snippets)) + ;; read -(defn get-snippet-by-slug +(defn- get-snippet-by-slug-from-db "Get a single snippet by its slug." [slug] (let [conn (get-conn) @@ -99,6 +125,9 @@ :where [?e :snippet/slug ?slug]]] (ffirst (d/q query db slug)))) +(def get-snippet-by-slug + (wrap-snippet-return get-snippet-by-slug-from-db)) + ;; update (def update-schema "Malli schema for a valid update to a snippet entity." @@ -108,14 +137,14 @@ [:snippet/markdown {:optional true} :string] [:snippet/tags {:optional true} [:vector :string]]]) -(defn to-update [patch] +(defn- to-update [patch] (cond-> {} (some? (:title patch)) (assoc :snippet/title (:title patch)) (some? (:slug patch)) (assoc :snippet/slug (:slug patch)) (some? (:markdown patch)) (assoc :snippet/markdown (:markdown patch)) (some? (:tags patch)) (assoc :snippet/tags (:tags patch)))) -(defn patch-snippet +(defn- patch-snippet-in-db "Update specific fields of a snippet." [slug raw-patch] (let [conn (get-conn) @@ -129,7 +158,9 @@ (let [tx-data [(merge {:db/id eid} patch)]] (d/transact conn {:tx-data tx-data})))) -(defn list-snippets +(def update-snippet (wrap-snippet-return patch-snippet-in-db)) + +(defn list-snippets-in-db "List all the snippets" [] (let [conn (get-conn) @@ -140,12 +171,13 @@ (->> (d/q query db) (map first)))) +(def list-snippets (wrap-snippet-return list-snippets-in-db)) + (defn delete-snippet-by-slug "Soft delete a snippet (retract its entity)." [slug] (t/log! {:level :info, :data {:slug slug}} "Retracting snippet") (let [conn (get-conn) - db (d/db conn) eid (:db/id (get-snippet-by-slug slug))] (when (nil? eid) (throw (ex-info "Snippet not found" {:slug slug}))) @@ -161,7 +193,7 @@ [?e :snippet/tags ?tag]]] (d/q query db))) -(defn get-snippets-by-tag +(defn get-snippets-by-tag-in-db "Get all snippets that have a specific tag." [tag] (let [conn (get-conn) @@ -172,3 +204,5 @@ [?e :snippet/tags ?tag]] results (d/q query db tag)] (mapv first results))) + +(def get-snippets-by-tag (wrap-snippet-return get-snippets-by-tag-in-db)) diff --git a/src/snippets/use_cases/view.clj b/src/snippets/use_cases/view.clj index 9626152..5d7a936 100644 --- a/src/snippets/use_cases/view.clj +++ b/src/snippets/use_cases/view.clj @@ -1,7 +1,7 @@ (ns snippets.use-cases.view (:require [taoensso.telemere :as t] - [snippets.infra.db :as db])) + [snippets.infra.db2 :as db])) (defn serialize-snippet "Converts snippet pub-date to ISO-8601 string for EDN serialization" @@ -9,12 +9,15 @@ (when snippet (assoc snippet :pub-date (.toString (:pub-date snippet))))) -(defn view-snippet [key] - (t/log! {:level :info, :data {:key key}} "Viewing snippet by id") - (serialize-snippet (db/get-snippet-by-id key))) - -(defn view-snippets [& args] - (map serialize-snippet (db/list-snippets args))) +(defn view-snippets [options] + (if (nil? options) + (map serialize-snippet (db/list-snippets)) + (let [limit (:limit options) + skip (:skip options)] + (->> (db/list-snippets) + (drop skip) + (take limit) + (map serialize-snippet))))) (defn view-tags [] (t/log! {:level :info} "Viewing tags") From 5fad04d04c4490bc318eac0623ca5aacb0e4397f Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Tue, 10 Mar 2026 14:55:33 +0100 Subject: [PATCH 4/5] fix update-snippet Created via bruno", Updated via bruno" does a cool thing", --- README.md | 10 +++++- bruno/CodeSnippets/create_snippet.bru | 6 ++-- bruno/CodeSnippets/delete_snippet.bru | 4 +-- bruno/CodeSnippets/edit_snippet.bru | 8 ++--- bruno/CodeSnippets/get_snippet.bru | 23 ------------- bruno/CodeSnippets/get_snippet_by_slug.bru | 4 +-- bruno/CodeSnippets/get_snippets.bru | 4 +-- bruno/CodeSnippets/get_tag.bru | 4 +-- src/snippets/infra/api.clj | 27 +++++++++------ src/snippets/infra/db2.clj | 33 ++++++++++++------- src/snippets/use_cases/backfill_db2.clj | 15 +++++++++ src/snippets/use_cases/backfill_from_file.clj | 25 -------------- src/snippets/use_cases/create.clj | 11 +++---- src/snippets/use_cases/delete.clj | 8 ++--- src/snippets/use_cases/edit.clj | 10 +++--- 15 files changed, 90 insertions(+), 102 deletions(-) delete mode 100644 bruno/CodeSnippets/get_snippet.bru create mode 100644 src/snippets/use_cases/backfill_db2.clj delete mode 100644 src/snippets/use_cases/backfill_from_file.clj diff --git a/README.md b/README.md index df3ddf8..6a01a49 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,16 @@ This project is written in [Clojure](https://clojure.org/) and data is stored in ### How to run dev server +Run the server + ``` -$ clojure -M -m snippets.infra.api +$ clojure -M -m snippets.main +``` + +Hot reload: + +```shell +$ fd -e clj | entr -r clojure -M -m snippets.main ``` ### Repl diff --git a/bruno/CodeSnippets/create_snippet.bru b/bruno/CodeSnippets/create_snippet.bru index 5972683..f1a3072 100644 --- a/bruno/CodeSnippets/create_snippet.bru +++ b/bruno/CodeSnippets/create_snippet.bru @@ -12,9 +12,9 @@ post { body:json { { - "title": "TEST from Bruno", + "title": "Mock snippet sent via Bruno", "slug": "bruno-test", - "markdown": "this is a test", - "tags": ["test"] + "markdown": "## MOCK SNIPPET\nCreated via bruno", + "tags": ["mock"] } } diff --git a/bruno/CodeSnippets/delete_snippet.bru b/bruno/CodeSnippets/delete_snippet.bru index 8e9a782..9311985 100644 --- a/bruno/CodeSnippets/delete_snippet.bru +++ b/bruno/CodeSnippets/delete_snippet.bru @@ -5,13 +5,13 @@ meta { } delete { - url: {{host}}/api/snippet?id=d77d3463-c76e-4c53-a1d5-ecaf16c6c54e + url: {{host}}/api/snippet?slug=bruno-test body: none auth: none } params:query { - id: d77d3463-c76e-4c53-a1d5-ecaf16c6c54e + slug: bruno-test } body:json { diff --git a/bruno/CodeSnippets/edit_snippet.bru b/bruno/CodeSnippets/edit_snippet.bru index dbfd39d..69fc83c 100644 --- a/bruno/CodeSnippets/edit_snippet.bru +++ b/bruno/CodeSnippets/edit_snippet.bru @@ -5,18 +5,18 @@ meta { } patch { - url: {{host}}/api/snippet?id=680a3508-7709-4f71-b5c3-3dcbffe6f5cf + url: {{host}}/api/snippet?slug=bruno-test body: json auth: none } params:query { - id: 680a3508-7709-4f71-b5c3-3dcbffe6f5cf + slug: bruno-test } body:json { { - "title": "quick way to push last jj commit to git", - "tags": ["jj", "git"] + "title": "Mock snippet sent via Bruno with updated title", + "markdown": "## MOCK SNIPPET\nUpdated via bruno" } } diff --git a/bruno/CodeSnippets/get_snippet.bru b/bruno/CodeSnippets/get_snippet.bru deleted file mode 100644 index 7dd3683..0000000 --- a/bruno/CodeSnippets/get_snippet.bru +++ /dev/null @@ -1,23 +0,0 @@ -meta { - name: get_snippet - type: http - seq: 6 -} - -get { - url: {{host}}/api/snippet?id=aea69336-5116-49ac-ab52-bc221bdb7830 - body: none - auth: none -} - -params:query { - id: aea69336-5116-49ac-ab52-bc221bdb7830 -} - -body:json { - { - "title": "Test Snippet", - "markdown": "## Cool Snippet\ndoes a cool thing", - "tags": ["git", "jj"] - } -} diff --git a/bruno/CodeSnippets/get_snippet_by_slug.bru b/bruno/CodeSnippets/get_snippet_by_slug.bru index 3c17768..3bea0bb 100644 --- a/bruno/CodeSnippets/get_snippet_by_slug.bru +++ b/bruno/CodeSnippets/get_snippet_by_slug.bru @@ -5,13 +5,13 @@ meta { } get { - url: {{host}}/api/snippet-by-slug?slug=netcat-over-ping + url: {{host}}/api/snippet-by-slug?slug=bruno-test body: none auth: none } params:query { - slug: netcat-over-ping + slug: bruno-test } body:json { diff --git a/bruno/CodeSnippets/get_snippets.bru b/bruno/CodeSnippets/get_snippets.bru index e2de66e..4b186b6 100644 --- a/bruno/CodeSnippets/get_snippets.bru +++ b/bruno/CodeSnippets/get_snippets.bru @@ -5,13 +5,13 @@ meta { } get { - url: {{host}}/api/snippets?limit=25&skip=0 + url: {{host}}/api/snippets?limit=2&skip=0 body: none auth: none } params:query { - limit: 25 + limit: 2 skip: 0 } diff --git a/bruno/CodeSnippets/get_tag.bru b/bruno/CodeSnippets/get_tag.bru index c040ed9..c976fa3 100644 --- a/bruno/CodeSnippets/get_tag.bru +++ b/bruno/CodeSnippets/get_tag.bru @@ -5,13 +5,13 @@ meta { } get { - url: {{host}}/api/tag?tag=git + url: {{host}}/api/tag?tag=mock body: none auth: none } params:query { - tag: git + tag: mock } body:json { diff --git a/src/snippets/infra/api.clj b/src/snippets/infra/api.clj index 65b87eb..1bae601 100644 --- a/src/snippets/infra/api.clj +++ b/src/snippets/infra/api.clj @@ -23,9 +23,9 @@ {:status 200, :body "snippet created"}) (defn handle-edit-snippet [{body :body-params params :query-params}] - (let [id (get params "id")] - (t/log! {:level :info, :data {:body body :id id}} "Received request to edit snippet") - (let [{success :success :as res} (snippets.use-cases.edit/edit-snippet id body)] + (let [slug (get params "slug")] + (t/log! {:level :info, :data {:body body :slug slug}} "Received request to edit snippet") + (let [{success :success :as res} (snippets.use-cases.edit/edit-snippet slug body)] (cond success {:status 200, :body "snippet updated"} (= (:reason res) :invalid-patch) {:status 400, :body "invalid patch"} @@ -41,10 +41,13 @@ :body (snippets.use-cases.view/view-snippets nil)})) (defn handle-delete-snippet [{params :query-params}] - (let [id (get params "id")] - (snippets.use-cases.delete/delete-snippet id) - {:status 200 - :body (format "Deleted snippet with id: %s if it existed" id)})) + (let [slug (get params "slug") + res (snippets.use-cases.delete/delete-snippet slug)] + (if (nil? res) + {:status 404 + :body "No snippet with that slug found"} + {:status 200 + :body (format "Deleted snippet with slug: %s if it existed" slug)}))) (defn handle-view-tags [_args] (let [tags (snippets.use-cases.view/view-tags)] @@ -57,9 +60,13 @@ :body (snippets.use-cases.view/view-snippets-by-tag tag)})) (defn handle-view-snippet-by-slug [{params :query-params}] - (let [slug (get params "slug")] - {:status 200 - :body (snippets.use-cases.view/view-snippet-by-slug slug)})) + (let [slug (get params "slug") + snippet (snippets.use-cases.view/view-snippet-by-slug slug)] + (if (nil? snippet) + {:status 404 + :body "No snippet with that slug found"} + {:status 200 + :body snippet}))) (defn wrap [handler id] (fn [request] diff --git a/src/snippets/infra/db2.clj b/src/snippets/infra/db2.clj index 9df0f27..9cbf100 100644 --- a/src/snippets/infra/db2.clj +++ b/src/snippets/infra/db2.clj @@ -122,8 +122,10 @@ db (d/db conn) query '[:find (pull ?e [*]) :in $ ?slug - :where [?e :snippet/slug ?slug]]] - (ffirst (d/q query db slug)))) + :where [?e :snippet/slug ?slug]] + snippet (ffirst (d/q query db slug))] + (t/log! {:level :info, :data {:slug slug :snippet snippet}} "Got snippet by slug") + snippet)) (def get-snippet-by-slug (wrap-snippet-return get-snippet-by-slug-from-db)) @@ -132,6 +134,7 @@ (def update-schema "Malli schema for a valid update to a snippet entity." [:map + [:db/id :int] [:snippet/title {:optional true} :string] [:snippet/slug {:optional true} :string] [:snippet/markdown {:optional true} :string] @@ -148,17 +151,23 @@ "Update specific fields of a snippet." [slug raw-patch] (let [conn (get-conn) - eid (:db/id (get-snippet-by-slug slug)) - patch (to-update raw-patch)] - (t/log! {:level :info, :data {:patch patch :slug slug}} "Patching snippet") + db (d/db conn) + ;; query '[:find ?e + ;; :in $ ?slug + ;; :where [?e :snippet/slug ?slug]] + ;; eid (ffirst (d/q query db slug)) + eid (:db/id (get-snippet-by-slug-from-db slug)) + patch (merge (to-update raw-patch) {:db/id eid})] + (t/log! {:level :info, :data {:patch patch :slug slug :eid eid}} "Patching snippet") (when (nil? eid) (throw (ex-info "Snippet not found" {:slug slug}))) (when-not (m/validate update-schema patch) (throw (ex-info "Invalid patch" {:errors (m/explain update-schema patch) :patch patch}))) - (let [tx-data [(merge {:db/id eid} patch)]] - (d/transact conn {:tx-data tx-data})))) + (d/transact conn {:tx-data [patch]}))) -(def update-snippet (wrap-snippet-return patch-snippet-in-db)) +(defn update-snippet [& args] + (let [res (apply patch-snippet-in-db args)] + (t/log! {:level :info, :data {:res res :args args}} "Finished patching snippet"))) (defn list-snippets-in-db "List all the snippets" @@ -178,10 +187,10 @@ [slug] (t/log! {:level :info, :data {:slug slug}} "Retracting snippet") (let [conn (get-conn) - eid (:db/id (get-snippet-by-slug slug))] - (when (nil? eid) - (throw (ex-info "Snippet not found" {:slug slug}))) - (d/transact conn {:tx-data [[:db/retractEntity eid]]}))) + eid (:db/id (get-snippet-by-slug-from-db slug))] + (if (nil? eid) + nil + (d/transact conn {:tx-data [[:db/retractEntity eid]]})))) (defn list-tags "List all tags used in snippets with their counts." diff --git a/src/snippets/use_cases/backfill_db2.clj b/src/snippets/use_cases/backfill_db2.clj new file mode 100644 index 0000000..c2bf21a --- /dev/null +++ b/src/snippets/use_cases/backfill_db2.clj @@ -0,0 +1,15 @@ +(ns snippets.use-cases.backfill-db2 + (:require + [snippets.infra.db :as old-db] + [snippets.infra.db2 :as new-db] + [taoensso.telemere :as t])) + +(defn- zdt-to-date [zdt] + (java.util.Date/from (.toInstant zdt))) + +(defn backfill [] + (t/log! {:level :info} "Backfilling DB2") + (let [old-snippets (old-db/list-snippets {}) + new-snippets (map #(assoc % :pub-date (zdt-to-date (:pub-date %))) old-snippets)] + (t/log! {:level :info :data {:count (count new-snippets)}} "Creating snippets") + (new-db/create-snippets new-snippets))) diff --git a/src/snippets/use_cases/backfill_from_file.clj b/src/snippets/use_cases/backfill_from_file.clj deleted file mode 100644 index 4bda21e..0000000 --- a/src/snippets/use_cases/backfill_from_file.clj +++ /dev/null @@ -1,25 +0,0 @@ -(ns snippets.use-cases.backfill-from-file - (:require - [clojure.java.io :as io] - [clojure.pprint :as pprint] - [clojure.string :as str] - [frontmatter.core :as fm])) - -(defn scrape-files [] - (let [dir "./old_snippets"] - (->> (io/file dir) - (.listFiles) - (map #(.getName %)) - (map #(hash-map :slug (first (str/split % #"\.")) :full-path (str dir "/" %))) - ;; (map #(fm/parse (:full-path %)))))) - (map #(let [{frontmatter :frontmatter body :body} (fm/parse (:full-path %))] - (assoc % - :title (:title frontmatter) - :pub-date (:date frontmatter) - :markdown body - :scraped true - :tags (:snippet_types frontmatter)))) - (map #(dissoc % :full-path))))) - -;; used repl to do backfill -;; (doseq [s old-snippets] (xt/execute-tx db/client [[:put-docs :snippets (merge {:xt/id (:slug s)} s)]])) diff --git a/src/snippets/use_cases/create.clj b/src/snippets/use_cases/create.clj index 347f18d..244ead9 100644 --- a/src/snippets/use_cases/create.clj +++ b/src/snippets/use_cases/create.clj @@ -1,12 +1,9 @@ (ns snippets.use-cases.create (:require [taoensso.telemere :as t] - [snippets.infra.db :as db])) - -(defn- uuid [] (str (java.util.UUID/randomUUID))) + [snippets.infra.db2 :as db])) (defn create-snippet [{:keys [title slug markdown tags]}] - (let [id (uuid) - pub-date (java.util.Date.)] - (t/log! {:level :info, :data {:title title :slug slug :id id}} "Creating snippet") - (db/put-snippet id {:title title :slug slug :markdown markdown :tags tags :pub-date pub-date}))) + (let [pub-date (java.util.Date.)] + (t/log! {:level :info, :data {:title title :slug slug}} "Creating snippet") + (db/create-snippets [{:title title :slug slug :markdown markdown :tags tags :pub-date pub-date}]))) diff --git a/src/snippets/use_cases/delete.clj b/src/snippets/use_cases/delete.clj index cfd024a..9febf99 100644 --- a/src/snippets/use_cases/delete.clj +++ b/src/snippets/use_cases/delete.clj @@ -1,8 +1,8 @@ (ns snippets.use-cases.delete (:require - [snippets.infra.db :as db] + [snippets.infra.db2 :as db] [taoensso.telemere :as t])) -(defn delete-snippet [key] - (t/log! {:level :info, :data {:key key}} "Deleting snippet by id") - (db/delete-snippet key)) +(defn delete-snippet [slug] + (t/log! {:level :info, :data {:slug slug}} "Deleting snippet by slug") + (db/delete-snippet-by-slug slug)) diff --git a/src/snippets/use_cases/edit.clj b/src/snippets/use_cases/edit.clj index 8e9ac2b..1929822 100644 --- a/src/snippets/use_cases/edit.clj +++ b/src/snippets/use_cases/edit.clj @@ -2,7 +2,7 @@ (:require [taoensso.telemere :as t] [malli.core :as m] - [snippets.infra.db :as db])) + [snippets.infra.db2 :as db])) (def valid-patch? (m/validator @@ -12,11 +12,11 @@ [:tags {:optional true} [:seqable :string]] [:slug {:optional true} :string]])) -(defn edit-snippet [id patch] - (t/log! {:level :info, :data {:patch patch :id id}} "Editing snippet") +(defn edit-snippet [slug patch] + (t/log! {:level :info, :data {:patch patch :slug slug}} "Editing snippet") (if (valid-patch? patch) (do - (t/log! {:level :info, :data {:patch patch :id id}} "Valid changes editing snippet") - (db/patch-snippet id patch) + (t/log! {:level :info, :data {:patch patch :slug slug}} "Valid changes editing snippet") + (db/update-snippet slug patch) {:success true}) {:success false :reason :invalid-patch})) From 5245c9a2dd3aeb39e6ab53205e682d4a2d242eef Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Tue, 10 Mar 2026 17:56:41 +0100 Subject: [PATCH 5/5] make tag updates wipe non-included tags Updated via bruno" Updated via bruno", --- bruno/CodeSnippets/edit_snippet.bru | 3 ++- src/snippets/infra/db2.clj | 21 +++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/bruno/CodeSnippets/edit_snippet.bru b/bruno/CodeSnippets/edit_snippet.bru index 69fc83c..f42fb44 100644 --- a/bruno/CodeSnippets/edit_snippet.bru +++ b/bruno/CodeSnippets/edit_snippet.bru @@ -17,6 +17,7 @@ params:query { body:json { { "title": "Mock snippet sent via Bruno with updated title", - "markdown": "## MOCK SNIPPET\nUpdated via bruno" + "markdown": "## MOCK SNIPPET\nUpdated via bruno", + "tags": ["mock", "updated"] } } diff --git a/src/snippets/infra/db2.clj b/src/snippets/infra/db2.clj index 9cbf100..134f93a 100644 --- a/src/snippets/infra/db2.clj +++ b/src/snippets/infra/db2.clj @@ -1,8 +1,9 @@ (ns snippets.infra.db2 (:require + [clojure.set :as set] [datomic.client.api :as d] - [taoensso.telemere :as t] - [malli.core :as m])) + [malli.core :as m] + [taoensso.telemere :as t])) ;; Initialize the Datomic Local client ;; :system "dev" groups your databases in the "dev" system @@ -151,19 +152,19 @@ "Update specific fields of a snippet." [slug raw-patch] (let [conn (get-conn) - db (d/db conn) - ;; query '[:find ?e - ;; :in $ ?slug - ;; :where [?e :snippet/slug ?slug]] - ;; eid (ffirst (d/q query db slug)) - eid (:db/id (get-snippet-by-slug-from-db slug)) + snippet (get-snippet-by-slug-from-db slug) + eid (:db/id snippet) + new-tags (get raw-patch :tags '[]) + existing-tags (get snippet :snippet/tags '[]) + tags-to-remove (vec (set/difference (set existing-tags) (set new-tags))) + retracts (map #(vector :db/retract eid :snippet/tags %) tags-to-remove) patch (merge (to-update raw-patch) {:db/id eid})] - (t/log! {:level :info, :data {:patch patch :slug slug :eid eid}} "Patching snippet") + (t/log! {:level :info, :data {:patch patch :retracts retracts :slug slug :eid eid}} "Patching snippet") (when (nil? eid) (throw (ex-info "Snippet not found" {:slug slug}))) (when-not (m/validate update-schema patch) (throw (ex-info "Invalid patch" {:errors (m/explain update-schema patch) :patch patch}))) - (d/transact conn {:tx-data [patch]}))) + (d/transact conn {:tx-data (into [patch] retracts)}))) (defn update-snippet [& args] (let [res (apply patch-snippet-in-db args)]