init xtdb -> datomic refactor

This commit is contained in:
Travis Shears 2026-03-09 19:46:44 +01:00
parent c60a0e1de0
commit c34908ac8f
Signed by: travisshears
GPG key ID: CB9BF1910F3F7469
5 changed files with 400 additions and 1 deletions

178
CLAUDE.md Normal file
View file

@ -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=<id>` - Get snippet by UUID
- `GET /api/snippet-by-slug?slug=<slug>` - Get snippet by slug
- `POST /api/snippet` - Create snippet
- `PATCH /api/snippet?id=<id>` - Edit snippet (patches are validated against schema)
- `DELETE /api/snippet?id=<id>` - Delete snippet
- `GET /api/tags` - List all tags with counts
- `GET /api/tag?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

View file

@ -5,6 +5,7 @@
org.slf4j/slf4j-simple {:mvn/version "2.0.16"} org.slf4j/slf4j-simple {:mvn/version "2.0.16"}
;; db ;; db
com.datomic/local {:mvn/version "1.0.291"}
com.xtdb/xtdb-api {:mvn/version "2.0.0-beta9"} com.xtdb/xtdb-api {:mvn/version "2.0.0-beta9"}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.1002"} com.github.seancorfield/next.jdbc {:mvn/version "1.3.1002"}
org.postgresql/postgresql {:mvn/version "42.7.6"} org.postgresql/postgresql {:mvn/version "42.7.6"}

210
src/snippets/infra/db2.clj Normal file
View file

@ -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)))

View file

@ -1,6 +1,9 @@
(ns snippets.main (ns snippets.main
(:require [snippets.infra.api :as api]) (:require
[snippets.infra.api :as api]
[snippets.infra.db2 :refer [start-up-check]])
(:gen-class)) (:gen-class))
(defn -main [] (defn -main []
(start-up-check)
(api/run-server)) (api/run-server))

7
src/snippets/mocks.clj Normal file
View file

@ -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"))})