├── .dockerignore ├── docs ├── entity.cleanshot ├── schema.cleanshot ├── 20250617_entity_browser.mp4 ├── attribute_detail.cleanshot ├── 20250627_datomic_schema_app.png └── 20250627_datomic_entity_browser.png ├── resources └── public │ └── hyperfiddle-starter-app │ ├── index.css │ ├── index.dev.html │ └── index.prod.html ├── src-dev ├── user.clj ├── logback.xml ├── dev_jetty9.cljc └── dev.cljc ├── .cljfmt.edn ├── src-prod ├── logback.xml └── prod.cljc ├── .gitignore ├── .clj-kondo └── config.edn ├── shadow-cljs.edn ├── fly.toml ├── run_datomic.sh ├── src-build ├── README.md └── build.clj ├── Dockerfile ├── datomic_fixtures_mbrainz_full.sh ├── datomic_fixtures.sh ├── src └── dustingetz │ ├── hyperfiddle_datomic_browser_demo.cljc │ ├── learn_hfql_datomic.clj │ └── datomic_browser2.cljc ├── deps.edn └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | resources/public/electric_starter_app/js 2 | target/ 3 | .cpcache 4 | .shadow-cljs -------------------------------------------------------------------------------- /docs/entity.cleanshot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperfiddle/datomic-browser/HEAD/docs/entity.cleanshot -------------------------------------------------------------------------------- /docs/schema.cleanshot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperfiddle/datomic-browser/HEAD/docs/schema.cleanshot -------------------------------------------------------------------------------- /docs/20250617_entity_browser.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperfiddle/datomic-browser/HEAD/docs/20250617_entity_browser.mp4 -------------------------------------------------------------------------------- /docs/attribute_detail.cleanshot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperfiddle/datomic-browser/HEAD/docs/attribute_detail.cleanshot -------------------------------------------------------------------------------- /docs/20250627_datomic_schema_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperfiddle/datomic-browser/HEAD/docs/20250627_datomic_schema_app.png -------------------------------------------------------------------------------- /docs/20250627_datomic_entity_browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperfiddle/datomic-browser/HEAD/docs/20250627_datomic_entity_browser.png -------------------------------------------------------------------------------- /resources/public/hyperfiddle-starter-app/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | font-family: 'Open Sans', Arial, Verdana, sans-serif; 7 | background-color: rgb(248 250 252); 8 | } 9 | -------------------------------------------------------------------------------- /src-dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user) ; Under :dev alias, automatically load 'dev so the REPL is ready to go with zero interaction 2 | 3 | (print "[user] loading dev... ") (flush) 4 | (require 'dev) ; jetty 10+ – the default 5 | ;; (require '[dev-jetty9 :as dev]) ; require :jetty9 alias 6 | (println "Ready.") -------------------------------------------------------------------------------- /.cljfmt.edn: -------------------------------------------------------------------------------- 1 | {:indents {#re ".*" [[:inner 0]]} 2 | :remove-surrounding-whitespace? false 3 | :remove-trailing-whitespace? false 4 | :remove-consecutive-blank-lines? false 5 | :test-code [(sui/ui-grid {:columns 2} 6 | (sui/ui-grid-row {} 7 | (sui/ui-grid-column {:width 12} 8 | ...))) 9 | (let [foo bar] 10 | (str "foo" 11 | "bar"))]} -------------------------------------------------------------------------------- /src-prod/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %highlight(%-5level) %logger: %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Please add user editor configs to system gitignore: 2 | # git config --global core.excludesfile 3 | # git config --global core.excludesfile ~/.gitignore 4 | # see also: https://gist.github.com/subfuzion/db7f57fff2fb6998a16c 5 | .clj-kondo/.cache 6 | .cpcache 7 | .lsp 8 | .idea 9 | .nrepl-port 10 | .shadow-cljs 11 | node_modules 12 | /resources/public/hyperfiddle-starter-app/js 13 | /resources/electric-manifest.edn 14 | /target 15 | .DS_Store 16 | /.dir-locals.el 17 | /state -------------------------------------------------------------------------------- /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {hyperfiddle.electric3/defn clojure.core/defn 2 | hyperfiddle.electric3/for clojure.core/let 3 | hyperfiddle.electric3/cursor clojure.core/let 4 | hyperfiddle.electric3/with-cycle clojure.core/let 5 | hyperfiddle.electric3/fn clojure.core/fn 6 | hyperfiddle.electric.impl.array-fields/deffields clojure.core/declare} 7 | :linters {:redundant-expression {:level :off} 8 | :var-same-name-except-case {:level :off} 9 | :clojure-lsp/unused-public-var {:level :off}}} -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:builds {:dev 2 | {:target :browser 3 | :output-dir "resources/public/hyperfiddle-starter-app/js" 4 | :asset-path "/hyperfiddle-starter-app/js" 5 | :modules {:main {:entries [dev] :init-fn dev/-main}} 6 | :build-hooks [(hyperfiddle.electric.shadow-cljs.hooks3/reload-clj)]} 7 | :prod 8 | {:target :browser 9 | :output-dir "resources/public/hyperfiddle-starter-app/js" 10 | :asset-path "/hyperfiddle-starter-app/js" 11 | :modules {:main {:entries [prod] :init-fn prod/-main}} 12 | :module-hash-names true}} 13 | :nrepl false ; not needed for the demo – less verbose boot 14 | } 15 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # Fly.io deployment configuration 2 | 3 | # app = "hyperfiddle-starter-app" 4 | app = "dry-wood-1492" 5 | primary_region = "ewr" 6 | kill_signal = "SIGINT" 7 | kill_timeout = "5s" 8 | 9 | [experimental] 10 | auto_rollback = true 11 | 12 | [[services]] 13 | protocol = "tcp" 14 | internal_port = 8080 15 | processes = ["app"] 16 | 17 | [[services.ports]] 18 | port = 80 19 | handlers = ["http"] 20 | force_https = true 21 | 22 | [[services.ports]] 23 | port = 443 24 | handlers = ["tls", "http"] 25 | [services.concurrency] 26 | type = "connections" 27 | hard_limit = 200 28 | soft_limit = 150 29 | 30 | [[services.tcp_checks]] 31 | interval = "15s" 32 | timeout = "2s" 33 | grace_period = "1s" 34 | restart_limit = 0 35 | 36 | [[vm]] 37 | memory = '8gb' 38 | cpu_kind = 'performance' 39 | cpus = 4 40 | -------------------------------------------------------------------------------- /resources/public/hyperfiddle-starter-app/index.dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Integrated Datomic Browser 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src-dev/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %highlight(%-5level) %logger: %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /run_datomic.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # * **Watch out:** the Datomic command works only from the datomic_browser directory precisely. The `transactor` java process will resolve the config file path relative to the java resource path, or something. This is a common gotcha! 4 | # * **Nix users**: user reports, "The transactor and the datomic bash files begin with an invalid shebang that only matters to nix people, see https://www.reddit.com/r/NixOS/comments/k8ja54/nixos_running_scripts_problem/" 5 | 6 | 7 | set -e 8 | nc -z localhost 4334 2>/dev/null && { echo "Port 4334 already in use"; exit 1; } || true 9 | 10 | set -eux -o pipefail 11 | 12 | # Without explicit bindAddress h2 will bind to 0.0.0.0 on fly for large mbrainz, but localhost for small mbrainz. No idea why. 13 | export JAVA_OPTS='-Dh2.bindAddress=localhost -XX:+UseG1GC -XX:MaxGCPauseMillis=50' 14 | ./state/datomic-pro/bin/transactor config/samples/dev-transactor-template.properties >>state/datomic.log 2>&1 & 15 | 16 | set +x 17 | sleep 1 18 | echo "Datomic is starting in background. You can proceed." -------------------------------------------------------------------------------- /resources/public/hyperfiddle-starter-app/index.prod.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hyperfiddle 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src-build/README.md: -------------------------------------------------------------------------------- 1 | # How to build for prod 2 | 3 | ## Uberjar 4 | 5 | clojure -X:prod:build uberjar :build/jar-name "datomic-browser.jar" 6 | java -cp target/datomic-browser.jar clojure.main -m prod datomic-uri 'datomic:dev://localhost:4334/*' 7 | 8 | ## Uberjar with jetty9 9 | 10 | You must have edited `src-prod/prod.cljc` to use jetty9. See comments in code. 11 | 12 | clojure -X:jetty9:prod:build uberjar :aliases '[:jetty9 :prod]' :build/jar-name "datomic-browser.jar" 13 | java -cp target/datomic-browser.jar clojure.main -m prod datomic-uri 'datomic:dev://localhost:4334/*' 14 | 15 | 16 | ## Docker 17 | 18 | docker build --build-arg VERSION=$(git rev-parse HEAD) -t hyperfiddle-starter-app:latest . 19 | docker run --rm -it -p 8080:8080 hyperfiddle-starter-app:latest 20 | 21 | ## Classpath integration 22 | 23 | 1. Add `com.hyperfiddle/hyperfiddle` to your deps. 24 | 2. Reproduce the [electric3-starter-app](https://gitlab.com/hyperfiddle/electric3-starter-app) integration into your project. 25 | Follow instructions there. You should end up with: 26 | - electric server and client running in your application 27 | - hot code reload with shadow-cljs working. 28 | 3. Copy `src/dustingetz/datomic_browser2.cljc` and `src/dustingetz/hyperfiddle_datomic_browser_demo` into your source path. 29 | 4. Adapt the client and server entrypoints you've got from the starter app so they match `src-dev/dev.cljc` from this repo. They only differs slightly. 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM clojure:temurin-11-tools-deps-1.12.0.1501 AS datomic-fixtures 2 | WORKDIR /app 3 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends unzip curl wget 4 | COPY datomic_fixtures.sh datomic_fixtures.sh 5 | COPY datomic_fixtures_mbrainz_full.sh datomic_fixtures_mbrainz_full.sh 6 | # RUN ./datomic_fixtures.sh 7 | RUN ./datomic_fixtures_mbrainz_full.sh 8 | # Shaves 3Gb+ of docker image 9 | RUN rm state/*.tar 10 | RUN rm state/*.zip 11 | 12 | FROM clojure:temurin-11-tools-deps-1.12.0.1501 AS build 13 | WORKDIR /app 14 | COPY deps.edn deps.edn 15 | ARG VERSION 16 | ENV VERSION=$VERSION 17 | RUN clojure -A:prod -M -e ::ok # preload – rebuilds if deps or commit version changes 18 | RUN clojure -A:build:prod -M -e ::ok # preload 19 | 20 | COPY shadow-cljs.edn shadow-cljs.edn 21 | COPY src src 22 | COPY src-prod src-prod 23 | COPY src-build src-build 24 | COPY resources resources 25 | 26 | RUN clojure -X:prod:build uberjar :version "\"$VERSION\"" :build/jar-name "app.jar" 27 | 28 | FROM amazoncorretto:11 AS app 29 | # FROM clojure:temurin-11-tools-deps-1.12.0.1501 AS app 30 | WORKDIR /app 31 | COPY run_datomic.sh run_datomic.sh 32 | COPY --from=datomic-fixtures /app/state state 33 | COPY --from=build /app/target/app.jar app.jar 34 | RUN echo -e "/state/\n/vendor/" > .gitignore 35 | 36 | EXPOSE 8080 37 | # CMD ./run_datomic.sh && java -cp app.jar clojure.main -m prod datomic-uri datomic:dev://localhost:4334/mbrainz-1968-1973 38 | CMD ./run_datomic.sh && java -cp app.jar clojure.main -m prod datomic-uri datomic:dev://localhost:4334/mbrainz-full 39 | -------------------------------------------------------------------------------- /datomic_fixtures_mbrainz_full.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -uo pipefail 3 | 4 | function fail { 5 | echo "$@" 6 | exit 1 7 | } 8 | 9 | function info { 10 | echo "[INFO] $(date +"%T.%3N") $*" 11 | } 12 | 13 | function downloadAsNeeded { 14 | curl "$1" -O -C - || fail "Failed to download $1" 15 | } 16 | 17 | function timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; } 18 | 19 | mkdir -p state 20 | pushd state 21 | 22 | info "Downloading datomic-pro" 23 | downloadAsNeeded https://datomic-pro-downloads.s3.amazonaws.com/1.0.6735/datomic-pro-1.0.6735.zip 24 | info "Extracting datomic-pro" 25 | if [ ! -d "datomic-pro" ]; then 26 | unzip -q datomic-pro-1.0.6735.zip || fail "Failed to unzip datomic-pro" 27 | mv datomic-pro-1.0.6735 datomic-pro 28 | fi 29 | 30 | info "Downloading mbrainz dataset" 31 | mbrainz=https://s3.amazonaws.com/mbrainz/datomic-mbrainz-backup-20130611.tar 32 | mbrainz_backup_dir=datomic-mbrainz-backup-20130611 33 | downloadAsNeeded "$mbrainz" 34 | info "Extracting mbrainz dataset" 35 | if [ ! -d "$mbrainz_backup_dir" ]; then 36 | tar -xf "${mbrainz##*/}" || fail "failed to untar mbrainz dataset" 37 | fi 38 | 39 | info "Importing the mbrainz dataset" 40 | # Pick a random port for a short-lived datomic instance 41 | while PORT=$((RANDOM % 60000 + 5000)); lsof -i:$PORT >/dev/null 2>&1; do :; done 42 | sed "s/^port=.*/port=$PORT/" datomic-pro/config/samples/dev-transactor-template.properties > datomic-pro/config/fixtures-transactor.properties 43 | 44 | datomic-pro/bin/transactor config/fixtures-transactor.properties & 45 | datomic_transactor_pid=$! 46 | 47 | info "Waiting for Datomic to start on port $PORT..." 48 | while ! timeout bash -c "echo > /dev/tcp/localhost/$PORT 2> /dev/null" 2> /dev/null; do :; done 49 | 50 | # https://datomic.narkive.com/OUskfRdr/backup-error 51 | datomic-pro/bin/datomic restore-db "file:$(pwd)/${mbrainz_backup_dir} datomic:dev://localhost:$PORT/mbrainz-full" 52 | kill $datomic_transactor_pid 53 | 54 | info "Cleaning up $mbrainz_backup_dir" 55 | rm -rf ${mbrainz_backup_dir} 56 | 57 | info "Sample dataset ready." 58 | -------------------------------------------------------------------------------- /datomic_fixtures.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -uo pipefail 3 | 4 | function fail { 5 | echo "$@" 6 | exit 1 7 | } 8 | 9 | function info { 10 | echo "[INFO] $(date +"%T.%3N") $*" 11 | } 12 | 13 | function downloadAsNeeded { 14 | curl "$1" -O -C - || fail "Failed to download $1" 15 | } 16 | 17 | function timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; } 18 | 19 | mkdir -p state 20 | pushd state 21 | 22 | info "Downloading datomic-pro" 23 | downloadAsNeeded https://datomic-pro-downloads.s3.amazonaws.com/1.0.6735/datomic-pro-1.0.6735.zip 24 | info "Extracting datomic-pro" 25 | if [ ! -d "datomic-pro" ]; then 26 | unzip -q datomic-pro-1.0.6735.zip || fail "Failed to unzip datomic-pro" 27 | mv datomic-pro-1.0.6735 datomic-pro 28 | fi 29 | 30 | info "Downloading mbrainz dataset" 31 | mbrainz=https://s3.amazonaws.com/mbrainz/datomic-mbrainz-1968-1973-backup-2017-07-20.tar 32 | mbrainz_backup_dir=datomic-mbrainz-1968-1973-backup-2017-07-20 33 | downloadAsNeeded "$mbrainz" 34 | info "Extracting mbrainz dataset" 35 | if [ ! -d "$mbrainz_backup_dir" ]; then 36 | tar -xf "${mbrainz##*/}" || fail "failed to untar mbrainz dataset" 37 | fi 38 | 39 | info "Importing the mbrainz dataset" 40 | # Pick a random port for a short-lived datomic instance 41 | while PORT=$((RANDOM % 60000 + 5000)); lsof -i:$PORT >/dev/null 2>&1; do :; done 42 | sed "s/^port=.*/port=$PORT/" datomic-pro/config/samples/dev-transactor-template.properties > datomic-pro/config/fixtures-transactor.properties 43 | 44 | datomic-pro/bin/transactor config/fixtures-transactor.properties & 45 | datomic_transactor_pid=$! 46 | 47 | info "Waiting for Datomic to start on port $PORT..." 48 | while ! timeout bash -c "echo > /dev/tcp/localhost/$PORT 2> /dev/null" 2> /dev/null; do :; done 49 | 50 | # https://datomic.narkive.com/OUskfRdr/backup-error 51 | datomic-pro/bin/datomic restore-db "file:$(pwd)/mbrainz-1968-1973 datomic:dev://localhost:$PORT/mbrainz-1968-1973" 52 | kill $datomic_transactor_pid 53 | 54 | info "Cleaning up $mbrainz_backup_dir" 55 | rm -rf ${mbrainz_backup_dir} 56 | 57 | info "Sample dataset ready." 58 | -------------------------------------------------------------------------------- /src/dustingetz/hyperfiddle_datomic_browser_demo.cljc: -------------------------------------------------------------------------------- 1 | (ns dustingetz.hyperfiddle-datomic-browser-demo 2 | (:require 3 | [dustingetz.datomic-browser2 :refer [BrowseDatomicByURI #?(:clj sitemap)]] 4 | #?(:clj [dustingetz.datomic-contrib2 :refer [set-db-name-in-datomic-uri]]) 5 | 6 | [hyperfiddle.electric3 :as e] 7 | [hyperfiddle.electric-dom3 :as dom] 8 | [hyperfiddle.entrypoint2 :refer [Hyperfiddle]])) 9 | 10 | (e/defn InjectAndRunHyperfiddle [ring-request datomic-transactor-uri] 11 | (e/client 12 | (binding [dom/node js/document.body 13 | e/http-request (e/server ring-request)] 14 | (dom/div (dom/props {:style {:display "contents"}}) ; mandatory wrapper div https://github.com/hyperfiddle/electric/issues/74 15 | (Hyperfiddle ; setup fiddle router 16 | {`dustingetz.datomic-browser2/DatomicBrowser ; a "fiddle" i.e. app, routable, encoded into the URL 17 | (e/fn ; fiddle entrypoint, any arguments are from the URL s-expression 18 | ; /(dustingetz.datomic-browser2$DatomicBrowser)/ -- no db-name in URL expr 19 | ; /dustingetz.datomic-browser2$DatomicBrowser/ -- equivalent fiddle url 20 | ([] (e/server (BrowseDatomicByURI sitemap ['databases] datomic-transactor-uri))) 21 | ; /(dustingetz.datomic-browser2$DatomicBrowser,'mbrainz-full')/ -- inject dbname from URL expr 22 | ([db-name] (e/server (let [db-uri (set-db-name-in-datomic-uri datomic-transactor-uri db-name)] 23 | (BrowseDatomicByURI sitemap ['databases 'attributes] db-uri)))))}))))) 24 | 25 | (defn hyperfiddle-demo-boot [ring-request datomic-uri] 26 | #?(:clj (e/boot-server {} InjectAndRunHyperfiddle (e/server ring-request) (e/server datomic-uri)) ; client/server entrypoints must be symmetric 27 | :cljs (e/boot-client {} InjectAndRunHyperfiddle (e/server (e/amb)) (e/server (e/amb))))) ; ring-request is server only, client sees nothing in place; same for datomic-uri 28 | 29 | ; Use this to inject a secure connection, not uri (can't list databases from a connection) 30 | #_(dustingetz.datomic-browser2/BrowseDatomicByConnection (dissoc sitemap 'databases) ['attributes] datomic-conn) -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {org.clojure/clojure {:mvn/version "1.12.3"} 2 | com.hyperfiddle/hyperfiddle {:mvn/version "v0-alpha-86cef0fb"} 3 | com.datomic/peer {:mvn/version "1.0.7075"} 4 | ;; ring/ring {:mvn/version "1.14.1"} ; jetty 12, works but message payload size tunning API has changed, we need to upgrade it. 5 | ring/ring {:mvn/version "1.11.0"} ; to serve the app 6 | ch.qos.logback/logback-classic {:mvn/version "1.4.14"} 7 | } 8 | :paths ["src" "resources"] 9 | :aliases {:dev {:extra-paths ["src-dev"] 10 | :extra-deps {thheller/shadow-cljs {:mvn/version "2.26.2"} 11 | ch.qos.logback/logback-classic {:mvn/version "1.4.14"}}} 12 | :prod {:extra-paths ["src-prod"] 13 | :extra-deps {ch.qos.logback/logback-classic {:mvn/version "1.4.14"}}} 14 | :private {:override-deps {com.hyperfiddle/hyperfiddle {:local/root "../hyperfiddle"}}} 15 | :profile {:extra-deps {criterium/criterium {:mvn/version "0.4.6"} 16 | com.clojure-goes-fast/clj-async-profiler {:mvn/version "1.2.2"}} 17 | :jvm-opts ["-Djdk.attach.allowAttachSelf" 18 | "-XX:+UnlockDiagnosticVMOptions" 19 | "-XX:+DebugNonSafepoints"]} 20 | :build ; use `clj -X:prod:build build-client`, NOT -T! 21 | {:extra-paths ["src-build"] 22 | :ns-default build 23 | :extra-deps {thheller/shadow-cljs {:mvn/version "2.26.2"} 24 | com.datomic/peer {:mvn/version "1.0.7075"} 25 | io.github.clojure/tools.build {:mvn/version "0.10.8"}}} 26 | :jetty9 {:override-deps {ring/ring {:mvn/version "1.9.6"}} 27 | :extra-deps {ring/ring-jetty-adapter {:mvn/version "1.8.2"} 28 | ring/ring-servlet {:mvn/version "1.8.2"} 29 | org.eclipse.jetty.websocket/websocket-server {:mvn/version "9.4.31.v20200723"} ; matching jetty version from ring-jetty-adapter 30 | org.eclipse.jetty.websocket/websocket-servlet {:mvn/version "9.4.31.v20200723"}}}} 31 | :mvn/repos 32 | {"clojars" {:url "https://repo.clojars.org/" :snapshots {:enabled true :update :always}}}} 33 | -------------------------------------------------------------------------------- /src-build/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b] 3 | [clojure.tools.logging :as log] 4 | [shadow.cljs.devtools.api :as shadow-api] 5 | [shadow.cljs.devtools.server :as shadow-server])) 6 | 7 | (def electric-user-version (b/git-process {:git-args "describe --tags --long --always --dirty"})) 8 | 9 | (defn build-client " 10 | invoke like: clj -X:build:prod build-client` 11 | Note: do not use `clj -T`, because Electric shadow compilation requires 12 | application classpath to be available" 13 | [{:keys [optimize debug verbose version] 14 | :or {optimize true, debug false, verbose false, version electric-user-version} 15 | :as config}] 16 | (log/info 'build-client (pr-str config #_argmap)) 17 | (b/delete {:path "resources/public/hyperfiddle-starter-app/js"}) 18 | (b/delete {:path "resources/electric-manifest.edn"}) 19 | 20 | ; bake electric-user-version into artifact, cljs and clj 21 | (b/write-file {:path "resources/electric-manifest.edn" :content (-> config (dissoc :version) (assoc :hyperfiddle/electric-user-version version))}) 22 | 23 | ; "java.lang.NoClassDefFoundError: com/google/common/collect/Streams" is fixed by 24 | ; adding com.google.guava/guava {:mvn/version "31.1-jre"} to deps, 25 | ; see https://hf-inc.slack.com/archives/C04TBSDFAM6/p1692636958361199 26 | (shadow-server/start!) 27 | (as-> 28 | (shadow-api/release :prod 29 | {:debug debug, 30 | :verbose verbose, 31 | :config-merge 32 | [{:compiler-options {:optimizations (if optimize :advanced :simple)} 33 | :closure-defines {'hyperfiddle.electric-client3/ELECTRIC_USER_VERSION version}}]}) 34 | shadow-status (assert (= shadow-status :done) "shadow-api/release error")) ; fail build on error 35 | (shadow-server/stop!) 36 | (log/info "client built")) 37 | 38 | (def class-dir "target/classes") 39 | 40 | (defn uberjar 41 | [{:keys [optimize debug verbose ::jar-name, ::skip-client, version, aliases] 42 | :or {optimize true, debug false, verbose false, skip-client false, version electric-user-version, aliases [:prod]} 43 | :as args}] 44 | ; careful, shell quote escaping combines poorly with clj -X arg parsing, strings read as symbols 45 | (log/info 'uberjar (pr-str args)) 46 | (b/delete {:path "target"}) 47 | 48 | (when-not skip-client 49 | (build-client (select-keys args [:optimize :debug :verbose :version]))) 50 | 51 | (b/copy-dir {:target-dir class-dir :src-dirs ["src" "src-prod" "resources"]}) 52 | (let [jar-name (or (some-> jar-name str) ; override for Dockerfile builds to avoid needing to reconstruct the name 53 | (format "hyperfiddle-starter-app-%s.jar" version))] 54 | (b/uber {:class-dir class-dir 55 | :uber-file (str "target/" jar-name) 56 | :basis (b/create-basis {:project "deps.edn" :aliases aliases})}) 57 | (log/info jar-name))) 58 | -------------------------------------------------------------------------------- /src-dev/dev_jetty9.cljc: -------------------------------------------------------------------------------- 1 | (ns dev-jetty9 ; require :jetty9 deps alias 2 | (:require 3 | [dustingetz.hyperfiddle-datomic-browser-demo :refer [hyperfiddle-demo-boot]] 4 | #?(:clj [dustingetz.datomic-contrib2 :refer [datomic-uri-db-name]]) 5 | 6 | #?(:clj [shadow.cljs.devtools.api :as shadow-cljs-compiler]) 7 | #?(:clj [shadow.cljs.devtools.server :as shadow-cljs-compiler-server]) 8 | #?(:clj [clojure.tools.logging :as log]) 9 | 10 | #?(:clj [ring.adapter.jetty :as ring]) 11 | #?(:clj [ring.util.response :as ring-response]) 12 | #?(:clj [ring.middleware.resource :refer [wrap-resource]]) 13 | #?(:clj [ring.middleware.content-type :refer [wrap-content-type]]) 14 | #?(:clj [hyperfiddle.electric-jetty9-ring-adapter3 :refer [electric-jetty9-ws-install]]) ; jetty 9 15 | )) 16 | 17 | (comment (-main)) ; repl entrypoint 18 | 19 | #?(:clj (defn next-available-port-from [start] (first (filter #(try (doto (java.net.ServerSocket. %) .close) % (catch Exception _ (println (format "Port %s already taken" %)) nil)) (iterate inc start))))) 20 | 21 | #?(:clj ; server entrypoint 22 | (defn -main [& args] 23 | (let [{:keys [datomic-uri http-port]} (first args) 24 | http-port (or http-port (next-available-port-from 8080)) 25 | datomic-uri (or datomic-uri "datomic:dev://localhost:4334/*")] ; dev only default 26 | (assert (some? datomic-uri) "Missing `:datomic-uri`. See README.md") 27 | (assert (string? datomic-uri) "Invalid `:datomic-uri`. See README.md") 28 | (assert (= "*" (datomic-uri-db-name datomic-uri)) "`:datomic-uri`. Must be a transactor URI (must ends with \"/*\")") 29 | 30 | (shadow-cljs-compiler-server/start!) 31 | (shadow-cljs-compiler/watch :dev) 32 | 33 | (def server (ring/run-jetty 34 | (-> ; ring middlewares – applied bottom up: 35 | (fn [ring-request] ; 3. index page fallback 36 | (-> (ring-response/resource-response "index.dev.html" {:root "public/hyperfiddle-starter-app"}) 37 | (ring-response/content-type "text/html"))) 38 | (wrap-resource "public") ; 2. serve assets from disk. 39 | (wrap-content-type)) ; 1. boilerplate – to server assets with correct mime/type. 40 | {:host "0.0.0.0", :port http-port, :join? false 41 | :configurator (fn [server] ; tune jetty server – larger websocket messages, longer timeout – this is a temporary tweak 42 | (electric-jetty9-ws-install server "/" (fn [ring-request] (hyperfiddle-demo-boot ring-request datomic-uri))) ; jetty 9 43 | )})) 44 | (log/info (format "👉 http://0.0.0.0:%s" http-port))))) 45 | 46 | (declare browser-process) 47 | #?(:cljs ; client entrypoint 48 | (defn ^:dev/after-load ^:export -main [] 49 | (set! browser-process 50 | ((hyperfiddle-demo-boot nil nil) ; boot client-side Electric process 51 | #(js/console.log "Reactor success:" %) 52 | #(js/console.error "Reactor failure:" %))))) 53 | 54 | #?(:cljs 55 | (defn ^:dev/before-load stop! [] ; for hot code reload at dev time 56 | (when browser-process (browser-process)) ; tear down electric browser process 57 | (set! browser-process nil))) 58 | 59 | (comment 60 | (shadow-cljs-compiler-server/stop!) 61 | (.stop server) ; stop jetty server 62 | ) 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Datomic entity browser 2 | 3 | This app is an easy way to get a generic web-based support/diagnostics UI for any production Datomic service, with the ability to extend using Clojure to add custom queries, routes, and views. 4 | 5 | This app is **150 LOC** + datomic helpers! See: [datomic-browser2.cljc](https://github.com/hyperfiddle/datomic-browser/blob/main/src/dustingetz/datomic_browser2.cljc) 6 | 7 | 8 | 9 | [![20250627_datomic_entity_browser.png](./docs/20250627_datomic_entity_browser.png)](./docs/20250627_datomic_entity_browser.png) 10 | 11 | ## Getting started 12 | 13 | Prerequisites 14 | * `java -version` modern version, we use `openjdk version "23.0.2"` 15 | * Clojure CLI https://clojure.org/guides/install_clojure 16 | 17 | ```shell 18 | git clone git@gitlab.com:hyperfiddle/datomic-browser.git 19 | cd datomic-browser 20 | ./datomic_fixtures.sh # Download Datomic w/ mbrainz dataset 21 | ./run_datomic.sh 22 | clj -X:dev dev/-main 23 | # Please sign up or login to activate: ... 24 | # INFO dev: 👉 http://0.0.0.0:8080 25 | 26 | # boot with Datomic transactor URI 27 | clj -X:dev dev/-main :datomic-uri '"'datomic:dev://localhost:4334/*'"' 28 | ``` 29 | 30 | Repl: jack-in with `:dev` alias, then eval `(dev/-main)` 31 | 32 | ## Features 33 | 34 | * **large Datomic databases** and large query results (50k+ result count) 35 | * **monitor and kill slow queries from very large databases** -- coming very soon, currently in test 36 | * entity navigation, automatic reverse attribute display and navigation 37 | * entity preview tooltip on all IDs and refs 38 | * query perf diagnostics (io-stats, query-stats etc) 39 | * classpath connected for custom queries (direct classpath linking to any function) 40 | * ORM-compatible, query Datomic however you want with Clojure functions 41 | * fluid virtual scroll over 50k record collections 42 | * automatic filtering and sort for queries returning < 10k records 43 | * supports large queries > 10k records performantly (bring your own sublinear sort/filter logic) 44 | * streaming lazy queries e.g. qseq – we have a prototype, contact us 45 | * pull human readable idents on low level IDs such as datom tuples 46 | * tables have column selection and inference 47 | * derived fields and "virtual attributes" (functions over entities) 48 | * built-in schema browser with attribute counts, docstrings, search, avet index 49 | * built-in entity history view 50 | * easy to integrate ring middleware - embed in your at-work httpkit or jetty services 51 | * enterprise SSO (contact us) 52 | 53 | [![20250627_datomic_schema_app.png](./docs/20250627_datomic_schema_app.png)](./docs/20250627_datomic_schema_app.png) 54 | 55 | **FAQ: Which Datomic product lines are supported?** 56 | * Datomic Onprem, Peer API: supported 57 | * Datomic Onprem, Client API: possible, contact us 58 | * Datomic Cloud, Client API: possible, contact us 59 | * Datomic Cloud, Ions: unsupported, Electric uses a websocket, afaik nobody has attempted running Electric in an Ion yet. 60 | 61 | ## License 62 | * free for individual use on local dev machines, mandatory runtime login (we are a business) 63 | * using in prod requires a license, contact us. 64 | * still working out the details 65 | -------------------------------------------------------------------------------- /src/dustingetz/learn_hfql_datomic.clj: -------------------------------------------------------------------------------- 1 | (ns dustingetz.learn-hfql-datomic 2 | "teaching namespace, unused by datomic-browser app" 3 | (:require 4 | [datomic.api :as d] 5 | dustingetz.datomic-contrib2 ; install HFQL protocols on EntityMap 6 | [hyperfiddle.hfql2 :as hfql :refer [hfql]] 7 | [hyperfiddle.hfql2.protocols :refer [Identifiable hfql-resolve Suggestable]])) 8 | 9 | (comment 10 | "datomic entity" 11 | (d/get-database-names "datomic:dev://localhost:4334/*") 12 | (require '[dustingetz.mbrainz :refer [test-db lennon]]) 13 | (def !lennon (d/entity @test-db lennon)) 14 | (def q (time ; "Elapsed time: 0.14775 msecs" 15 | (hfql {!lennon [:db/id ; careful of ref lifting, it lifts to EntityMap and REPL prints the map 16 | :artist/name 17 | :artist/type]}))) 18 | (def x (time ; "Elapsed time: 5.310125 msecs" 19 | (hfql/pull q))) 20 | x ; inspect it 21 | 22 | ; symbolic refs are lifted to object type by the HFQL protocols 23 | (-> x (get-in ['!lennon :db/id]) type) := datomic.query.EntityMap 24 | (-> x (get-in ['!lennon :artist/type]) type) := datomic.query.EntityMap 25 | 26 | (def q (hfql {!lennon 27 | [:artist/name 28 | {:track/_artists [count]} ; note: no *, we count the collection not the entity 29 | type]})) 30 | (time (hfql/pull q)) ; "Elapsed time: 1.64375 msecs" 31 | := {'!lennon {:artist/name "Lennon", :track/_artists {'count 30}, 'type datomic.query.EntityMap}} 32 | 33 | ; pull * 34 | (hfql/pull (hfql {!lennon [*]})) ; careful: * is not quoted 35 | := {'!lennon 36 | {:artist/gid {:db/id 17592186066840}, ; EntityMap 37 | :artist/name "Lennon", 38 | :artist/sortName "Lennon", 39 | :artist/type {:db/id 17592186045421}}} ; EntityMap 40 | 41 | ; empty pull [] implies [*] 42 | (hfql/pull (hfql {(d/entity @test-db :db/doc) 43 | []}))) 44 | 45 | (def ^:dynamic *app-db*) 46 | (defn entity-exists? [db eid] (and (some? eid) (seq (d/datoms db :eavt eid)))) 47 | (defmethod hfql-resolve `d/entity [[_ eid]] (when (entity-exists? *app-db* eid) (d/entity *app-db* eid))) 48 | 49 | (comment 50 | "the HFQL protocols" 51 | ; C.f. the extend-type on datomic.query.EntityMap in dustingetz.datomic-contrib2 52 | ; We use a few protocols and/or multimethods for dependency injection. 53 | ; First is `identify` and `resolve`, their goal is to be able to route to 54 | ; ANY object that you can name, while respecting that certain dependencies, 55 | ; like the application database, must be securely injected (as implied by the 56 | ; application entrypoint) and not part of thier public name. 57 | 58 | ; Identifiable - used to serialize the constructor to put in a URL 59 | (hfql/identify !lennon) := `(datomic.api/entity ~lennon) ; note no db, this is symbolic 60 | 61 | ; resolve - used to rehydrate the object from the public name, with secure application deps in scope 62 | (binding [*app-db* @test-db] 63 | (hfql-resolve `(datomic.api/entity ~lennon))) 64 | (type *1) := datomic.query.EntityMap 65 | 66 | ; Suggestable - used by [*] and also the column picker to sample what columns are available 67 | ; this is dynamic, and the interface is low level revealing hfql internals - todo improve 68 | (def star-q (hfql/suggest !lennon)) 69 | (hfql/pull (hfql/seed {'% !lennon} star-q)) 70 | (keys *1) := [:db/id 71 | :artist/sortName 72 | :artist/name 73 | :artist/type 74 | :artist/gid 75 | :abstractRelease/_artists ; reverse refs 76 | :release/_artists 77 | :track/_artists] 78 | ) 79 | -------------------------------------------------------------------------------- /src-dev/dev.cljc: -------------------------------------------------------------------------------- 1 | (ns dev ; jetty 10+ – the default 2 | (:require 3 | [dustingetz.hyperfiddle-datomic-browser-demo :refer [hyperfiddle-demo-boot]] 4 | #?(:clj [dustingetz.datomic-contrib2 :refer [datomic-uri-db-name]]) 5 | 6 | #?(:clj [shadow.cljs.devtools.api :as shadow-cljs-compiler]) 7 | #?(:clj [shadow.cljs.devtools.server :as shadow-cljs-compiler-server]) 8 | #?(:clj [clojure.tools.logging :as log]) 9 | 10 | #?(:clj [ring.adapter.jetty :as ring]) 11 | #?(:clj [ring.util.response :as ring-response]) 12 | #?(:clj [ring.middleware.params :refer [wrap-params]]) 13 | #?(:clj [ring.middleware.resource :refer [wrap-resource]]) 14 | #?(:clj [ring.middleware.content-type :refer [wrap-content-type]]) 15 | #?(:clj [hyperfiddle.electric-ring-adapter3 :refer [wrap-electric-websocket]]) ; jetty 10+ 16 | )) 17 | 18 | (comment (-main)) ; repl entrypoint 19 | 20 | #?(:clj (defn next-available-port-from [start] (first (filter #(try (doto (java.net.ServerSocket. %) .close) % (catch Exception _ (println (format "Port %s already taken" %)) nil)) (iterate inc start))))) 21 | 22 | #?(:clj ; server entrypoint 23 | (defn -main [& args] 24 | (let [{:keys [datomic-uri http-port]} (first args) 25 | http-port (or http-port (next-available-port-from 8080)) 26 | datomic-uri (or datomic-uri "datomic:dev://localhost:4334/*")] ; dev only default 27 | (assert (some? datomic-uri) "Missing `:datomic-uri`. See README.md") 28 | (assert (string? datomic-uri) "Invalid `:datomic-uri`. See README.md") 29 | (assert (= "*" (datomic-uri-db-name datomic-uri)) "`:datomic-uri`. Must be a transactor URI (must ends with \"/*\")") 30 | 31 | (shadow-cljs-compiler-server/start!) 32 | (shadow-cljs-compiler/watch :dev) 33 | 34 | (def server (ring/run-jetty 35 | (-> ; ring middlewares – applied bottom up: 36 | (fn [ring-request] ; 5. index page fallback 37 | (-> (ring-response/resource-response "index.dev.html" {:root "public/hyperfiddle-starter-app"}) 38 | (ring-response/content-type "text/html"))) 39 | (wrap-resource "public") ; 4. serve assets from disk. 40 | (wrap-content-type) ; 3. boilerplate – to server assets with correct mime/type. 41 | (wrap-electric-websocket ; 2. install Electric server. 42 | (fn [ring-request] (hyperfiddle-demo-boot ring-request datomic-uri))) ; boot server-side Electric process 43 | (wrap-params)) ; 1. boilerplate – parse request URL parameters. 44 | {:host "0.0.0.0", :port http-port, :join? false 45 | :configurator (fn [server] ; tune jetty server – larger websocket messages, longer timeout – this is a temporary tweak 46 | (org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer/configure 47 | (.getHandler server) 48 | (reify org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer$Configurator 49 | (accept [_this _servletContext wsContainer] 50 | (.setIdleTimeout wsContainer (java.time.Duration/ofSeconds 60)) ; default is 30 51 | (.setMaxBinaryMessageSize wsContainer (* 100 1024 1024)) ; typical compressed message size is of a few KBs. Set to 100M for demo. 52 | (.setMaxTextMessageSize wsContainer (* 100 1024 1024))))))})) ; 100M - for demo. 53 | (log/info (format "👉 http://0.0.0.0:%s" http-port))))) 54 | 55 | (declare browser-process) 56 | #?(:cljs ; client entrypoint 57 | (defn ^:dev/after-load ^:export -main [] 58 | (set! browser-process 59 | ((hyperfiddle-demo-boot nil nil) ; boot client-side Electric process 60 | #(js/console.log "Reactor success:" %) 61 | #(js/console.error "Reactor failure:" %))))) 62 | 63 | #?(:cljs 64 | (defn ^:dev/before-load stop! [] ; for hot code reload at dev time 65 | (when browser-process (browser-process)) ; tear down electric browser process 66 | (set! browser-process nil))) 67 | 68 | (comment 69 | (shadow-cljs-compiler-server/stop!) 70 | (.stop server) ; stop jetty server 71 | ) 72 | -------------------------------------------------------------------------------- /src-prod/prod.cljc: -------------------------------------------------------------------------------- 1 | (ns prod 2 | #?(:cljs (:require-macros [prod :refer [comptime-resource]])) 3 | (:require 4 | [dustingetz.hyperfiddle-datomic-browser-demo :refer [hyperfiddle-demo-boot]] 5 | 6 | #?(:clj [ring.adapter.jetty :as ring]) 7 | #?(:clj [ring.util.response :as ring-response]) 8 | #?(:clj [ring.middleware.not-modified :refer [wrap-not-modified]]) 9 | #?(:clj [ring.middleware.params :refer [wrap-params]]) 10 | #?(:clj [ring.middleware.resource :refer [wrap-resource]]) 11 | #?(:clj [ring.middleware.content-type :refer [wrap-content-type]]) 12 | ;; #?(:clj [hyperfiddle.electric-ring-adapter3 :as electric-ring]) ; jetty 10+ 13 | #?(:clj [hyperfiddle.electric-jetty9-ring-adapter3 :refer [electric-jetty9-ws-install]]) ; jetty 9 14 | 15 | #?(:clj clojure.edn) 16 | #?(:clj clojure.java.io) 17 | #?(:clj [clojure.tools.logging :as log]) 18 | )) 19 | 20 | (defmacro comptime-resource [filename] (some-> filename clojure.java.io/resource slurp clojure.edn/read-string)) 21 | 22 | (declare wrap-prod-index-page wrap-ensure-cache-bust-on-server-deployment) 23 | 24 | #?(:clj ; server entrypoint 25 | (defn -main [& {:strs [datomic-uri http-port] :as args}] ; clojure.main entrypoint, args are strings 26 | (let [config 27 | ;; Client and server versions must match in prod (dev is not concerned) 28 | ;; `src-build/build.clj` will compute the common version and store it in `resources/electric-manifest.edn` 29 | ;; On prod boot, `electric-manifest.edn`'s content is injected here. 30 | ;; Server is therefore aware of the program version. 31 | ;; The client's version is injected in the compiled .js file. 32 | (merge 33 | (comptime-resource "electric-manifest.edn") 34 | {:host "0.0.0.0", :port (or (some-> http-port parse-long) 8080), 35 | :resources-path "public" 36 | ;; shadow-cljs build manifest path, to get the fingerprinted main.sha1.js file to ensure cache invalidation 37 | :manifest-path "public/hyperfiddle-starter-app/js/manifest.edn"})] 38 | (log/info (pr-str config)) 39 | (assert (string? (:hyperfiddle/electric-user-version config))) 40 | (ring/run-jetty 41 | (-> (fn [ring-request] (-> (ring-response/not-found "Page not found") (ring-response/content-type "text/plain"))) 42 | (wrap-prod-index-page config) ; defined below 43 | (wrap-resource (:resources-path config)) 44 | (wrap-content-type) 45 | (wrap-not-modified) 46 | (wrap-ensure-cache-bust-on-server-deployment) 47 | #_(electric-ring/wrap-electric-websocket (fn [ring-request] (hyperfiddle-demo-boot ring-request datomic-uri))) ; jetty 10+ 48 | #_(electric-ring/wrap-reject-stale-client config) ; ensures electric client and servers stays in sync. – jetty 10+ 49 | #_(wrap-params) ; jetty 10+ 50 | ) 51 | {:host (:host config), :port (:port config), :join? false 52 | :configurator (fn [server] ; Tune limits 53 | (electric-jetty9-ws-install server "/" (fn [ring-request] (hyperfiddle-demo-boot ring-request datomic-uri))) ; jetty 9 54 | ;; jetty 10+ 55 | #_(org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer/configure 56 | (.getHandler server) 57 | (reify org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer$Configurator 58 | (accept [_this _servletContext wsContainer] 59 | (.setIdleTimeout wsContainer (java.time.Duration/ofSeconds 60)) 60 | (.setMaxBinaryMessageSize wsContainer (* 100 1024 1024)) ; 100M - for demo 61 | (.setMaxTextMessageSize wsContainer (* 100 1024 1024))))) ; 100M - for demo 62 | ;; Gzip served assets – jetty 10+ 63 | #_(.setHandler server (doto (new org.eclipse.jetty.server.handler.gzip.GzipHandler) 64 | (.setMinGzipSize 1024) 65 | (.setHandler (.getHandler server)))))})))) 66 | 67 | #?(:cljs ; client entrypoint 68 | (defn ^:export -main [] 69 | ;; client-side electric process boot happens here 70 | ((electric-client/reload-when-stale ; hard-reload the page to fetch new assets when a new server version is deployed 71 | (hyperfiddle-demo-boot nil nil)) ; boot client-side Electric process 72 | #(js/console.log "Reactor success:" %) 73 | #(js/console.error "Reactor failure:" %)))) 74 | 75 | 76 | #?(:clj 77 | (defn template 78 | "In string template `\"
$:foo/bar$
\"`, replace all instances of $key$ 79 | with target specified by map `m`. Target values are coerced to string with `str`. 80 | E.g. (template \"
$:foo$
\" {:foo 1}) => \"
1
\" - 1 is coerced to string." 81 | [t m] (reduce-kv (fn [acc k v] (clojure.string/replace acc (str "$" k "$") (str v))) t m))) 82 | 83 | #?(:clj 84 | (defn get-compiled-javascript-modules [manifest-path] 85 | (when-let [manifest (clojure.java.io/resource manifest-path)] 86 | (let [manifest-folder (when-let [folder-name (second (rseq (clojure.string/split manifest-path #"\/")))] 87 | (str folder-name "/"))] 88 | (->> (slurp manifest) 89 | (clojure.edn/read-string) 90 | (reduce (fn [r module] (assoc r (keyword "hyperfiddle.client.module" (name (:name module))) 91 | (str manifest-folder (:output-name module)))) {})))))) 92 | 93 | #?(:clj 94 | (defn wrap-ensure-cache-bust-on-server-deployment [next-handler] 95 | (fn [ring-req] 96 | (-> (next-handler ring-req) 97 | (ring-response/update-header "Cache-Control" (fn [cache-control] (or cache-control "public, max-age=0, must-revalidate"))))))) 98 | 99 | #?(:clj 100 | (defn wrap-prod-index-page 101 | "Serves `index.prod.html` with injected javascript modules from `manifest.edn`. 102 | `manifest.edn` is generated at client build time and contains javascript modules 103 | information (e.g. file location and file hash)." 104 | [next-handler config] 105 | (fn [ring-req] 106 | (assert (string? (:resources-path config))) 107 | (assert (string? (:manifest-path config))) 108 | (if-let [response (ring-response/resource-response (str (:resources-path config) "/hyperfiddle-starter-app/index.prod.html"))] 109 | (if-let [module (get-compiled-javascript-modules (:manifest-path config))] 110 | (-> (ring-response/response (template (slurp (:body response)) (merge config module))) 111 | (ring-response/content-type "text/html") 112 | (ring-response/header "Cache-Control" "no-store")) ; never cache – this is dynamically generated content. 113 | (-> (ring-response/not-found (pr-str ::missing-shadow-build-manifest)) ; can't inject js modules 114 | (ring-response/content-type "text/plain"))) 115 | ;; else – index.prod.html wasn't not found on classpath 116 | (next-handler ring-req))))) 117 | -------------------------------------------------------------------------------- /src/dustingetz/datomic_browser2.cljc: -------------------------------------------------------------------------------- 1 | (ns dustingetz.datomic-browser2 2 | (:require [contrib.data :refer [get-with-residual-meta]] 3 | [hyperfiddle.electric3 :as e] 4 | ;; [hyperfiddle.hfql0 #?(:clj :as :cljs :as-alias) hfql] 5 | [hyperfiddle.hfql2 :as hfql :refer [hfql]] 6 | [hyperfiddle.hfql2.protocols :refer [Identifiable hfql-resolve Navigable Suggestable ComparableRepresentation]] 7 | [hyperfiddle.navigator6 :as navigator :refer [HfqlRoot]] 8 | [hyperfiddle.navigator6.search :refer [*local-search]] 9 | [hyperfiddle.router5 :as r] 10 | [hyperfiddle.electric-dom3 :as dom] 11 | [hyperfiddle.electric-forms5 :refer [Checkbox*]] 12 | [dustingetz.loader :refer [Loader]] 13 | [dustingetz.str :refer [pprint-str blank->nil #?(:cljs format-number-human-friendly)]] 14 | [clojure.string :as str] 15 | #?(:clj [datomic.api :as d]) 16 | #?(:clj [datomic.lucene]) 17 | #?(:clj [dustingetz.datomic-contrib2 :as dx]))) 18 | 19 | (e/declare ^:dynamic *uri*) ; current Datomic URI. Available when injected. Not available when browsing a Datomic connection. 20 | (e/declare ^:dynamic *db-name*) ; current Datomic database name. Available when *uri* is available. 21 | (e/declare ^:dynamic *conn*) ; current Datomic connection. Always available. Either injected by browsing a connection object or derived from *uri* when browsing by URI. 22 | (e/declare ^:dynamic *db*) ; current Datomic database reference. Always available. 23 | (e/declare ^:dynamic *db-stats*) ; shared for perfs – safe to compute only once per *db* value. 24 | (e/declare ^:dynamic *filter-predicate*) ; for injecting predicates for d/filter 25 | (e/declare ^:dynamic *allow-listing-and-browsing-all-dbs*) ; when browsing by Datomic URI, allow listing and browsing other databases than the currently selected one. Default to false (disallowed). 26 | 27 | #?(:clj (defn databases [] ; only meaningful when browsing by Datomic URI. Browsing a Datomic connection object doesn't allow listing databases. 28 | (when (some? *uri*) 29 | (let [database-names-list (cond 30 | (= "*" (dx/datomic-uri-db-name *uri*)) ; Injected *uri* is a wildcard Datomic URI, allowing database listing. 31 | (d/get-database-names *uri*) 32 | 33 | *allow-listing-and-browsing-all-dbs* ; defaults to false – must be explicitly allowed at the entrypoint. 34 | (d/get-database-names (dx/set-db-name-in-datomic-uri *uri* "*")) 35 | 36 | :else (list *db-name*))] ; only list current db. 37 | (->> database-names-list 38 | (hfql/navigable (fn [_index db-name] (hfql-resolve `(d/db ~db-name))))))))) 39 | 40 | #?(:clj (defn attributes "Datomic schema, with Datomic query diagnostics" 41 | [] 42 | (let [x (d/query {:query '[:find [?e ...] :in $ :where [?e :db/valueType]] :args [*db*] 43 | :io-context ::attributes, :query-stats ::attributes}) 44 | x (get-with-residual-meta x :ret)] 45 | (hfql/navigable (fn [_index ?e] (d/entity *db* ?e)) x)))) 46 | 47 | #?(:clj (defn attribute-count "hello" 48 | [!e] (-> *db-stats* :attrs (get (:db/ident !e)) :count))) 49 | 50 | #?(:clj (defn indexed-attribute? [db ident] (true? (:db/index (dx/query-schema db ident))))) 51 | #?(:clj (defn fulltext-attribute? [db ident] (true? (:db/fulltext (dx/query-schema db ident))))) 52 | #?(:clj (defn fulltext-prefix-query [input] (some-> input (str) (datomic.lucene/escape-query) (str/replace #"^(\\\*)+" "") (blank->nil) (str "*")))) 53 | 54 | #?(:clj (defn attribute-detail [!e] 55 | (let [ident (:db/ident !e) 56 | search (not-empty (str/trim (str *local-search))) ; capture dynamic for lazy take-while/filter 57 | fulltext-query (fulltext-prefix-query search) 58 | entids (cond 59 | ;; prefer fulltext search, when available 60 | (and (fulltext-attribute? *db* ident) fulltext-query) 61 | (d/q '[:find [?e ...] :in $ ?a ?search :where [(fulltext $ ?a ?search) [[?e]]]] *db* ident fulltext-query) 62 | ;; indexed prefix search, when available 63 | (indexed-attribute? *db* ident) 64 | (->> (d/index-range *db* ident search nil) ; end is exclusive, can't pass *search twice 65 | (take-while #(if search (str/starts-with? (str (:v %)) search) true)) 66 | (map :e)) 67 | :else ; no available index 68 | (->> (d/datoms *db* :aevt ident) ; e.g. :language/name is a string but neither indexed nor fulltext 69 | (filter #(if search (str/starts-with? (str (:v %)) search) true)) ; can't use take-while – datoms are not ordered by v 70 | (map :e)))] 71 | (->> entids 72 | (hfql/filtered) ; optimisation – tag as already filtered, disable auto in-memory search 73 | (hfql/navigable (fn [_index ?e] (d/entity *db* ?e))))))) 74 | 75 | #?(:clj (defn summarize-attr [db k] (->> (dx/easy-attr db k) (remove nil?) (map name) (str/join " ")))) 76 | #?(:clj (defn summarize-attr* [?!a] (when ?!a (summarize-attr *db* (:db/ident ?!a))))) 77 | 78 | #?(:clj (defn tx-detail [!e] (mapcat :data (d/tx-range (d/log *conn*) (:db/id !e) (inc (:db/id !e)))))) 79 | 80 | #?(:clj (def entity-detail identity)) 81 | #?(:clj (def attribute-entity-detail identity)) 82 | 83 | #?(:clj (defn entity-history 84 | "history datoms in connection with a Datomic entity, both inbound and outbound statements." 85 | [!e] 86 | (let [history (d/history *db*)] 87 | (concat 88 | (d/datoms history :eavt (:db/id !e !e)) 89 | (d/datoms history :vaet (:db/id !e !e)) ; reverse index 90 | )))) 91 | 92 | (e/defn ^::e/export EntityTooltip [entity edge value] ; FIXME edge is a custom hyperfiddle type 93 | (e/server (pprint-str (into {} (d/touch value)) :print-length 10 :print-level 2))) ; force conversion to map for pprint to wrap lines 94 | 95 | (e/defn ^::e/export SemanticTooltip [entity edge value] ; FIXME edge is a custom hyperfiddle type 96 | (e/server 97 | (let [attribute (hfql/symbolic-edge edge)] 98 | (e/Reconcile 99 | (cond (= :db/id attribute) (EntityTooltip entity edge value) 100 | (qualified-keyword? value) 101 | (let [[typ _ unique?] (dx/easy-attr *db* attribute)] 102 | (e/Reconcile 103 | (cond 104 | (= :db/id attribute) (EntityTooltip entity edge value) 105 | (= :ref typ) (pprint-str (d/pull *db* ['*] value) :print-length 10 :print-level 2) 106 | (= :identity unique?) (pprint-str (d/pull *db* ['*] [attribute #_(:db/ident (d/entity db a)) value]) ; resolve lookup ref 107 | :print-length 10 :print-level 2) 108 | () nil)))))))) 109 | 110 | (e/defn ^::e/export SummarizeDatomicAttribute [_entity edge _value] ; FIXME props is a custom hyperfiddle type 111 | (e/server 112 | ((fn [] ; IIFE for try/catch support – Electric 3 doesn't have try/catch yet. 113 | (try (str/trim (str (hfql/describe-formatted edge) " " (summarize-attr *db* (hfql/symbolic-edge edge)))) 114 | (catch Throwable _)))))) 115 | 116 | (e/defn ^::e/export EntityDbidCell [entity edge value] ; FIXME edge is a custom hyperfiddle type 117 | (dom/span (dom/text (e/server (hfql/identify value)) " ") (r/link ['. [`(~'entity-history ~(hfql/identify entity))]] (dom/text "entity history")))) 118 | 119 | (e/defn ^::e/export HumanFriendlyAttributeCount [entity edge value] 120 | (e/client (dom/span (dom/props {:title (format-number-human-friendly value)}) 121 | (dom/text (format-number-human-friendly value :notation :compact :maximumFractionDigits 1))))) 122 | 123 | ; these depend on *app-db* 124 | #?(:clj (defn- entity-exists? [db eid] (and (some? eid) (seq (d/datoms db :eavt eid))))) ; d/entity always return an EntityMap, even for a non-existing :db/id 125 | #?(:clj (defmethod hfql-resolve `d/entity [[_ eid]] (when (entity-exists? *db* eid) (d/entity *db* eid)))) 126 | 127 | #?(:clj ; list all attributes of an entity – including reverse refs. 128 | (extend-type datomic.db.Datum 129 | Identifiable 130 | (identify [datum] `(datomic.db/datum ~@(dx/datom-identity datum))) 131 | Navigable 132 | (nav [[e a v tx added] k _] 133 | (case k 134 | :e (d/entity *db* e) 135 | :a (d/entity *db* a) 136 | :v (if (= :db.type/ref (:value-type (d/attribute *db* a))) (d/entity *db* v) v) 137 | :tx (d/entity *db* tx) 138 | :added added)) 139 | Suggestable 140 | (suggest [_] (hfql [:e :a :v :tx :added])) 141 | ComparableRepresentation 142 | (comparable [datum] (into [] datum)))) 143 | 144 | #?(:clj (defmethod hfql-resolve `datomic.db/datum [[_ e a serialized-v tx added]] (dx/resolve-datom *db* e a serialized-v tx added))) 145 | 146 | (defn db-name [db] (::db-name (meta db))) 147 | 148 | #?(:clj 149 | (extend-type datomic.db.Db 150 | Identifiable 151 | (identify [db] (when-let [nm (db-name db)] 152 | (let [id `(d/db ~nm) 153 | ;; following db transformations are commutative, they can be applied in any order. 154 | id (if (d/is-history db) `(d/history ~id) id) 155 | id (if (d/is-filtered db) `(d/filter ~id) id) ; resolving will require DI to reconstruct the predicate 156 | id (if (d/since-t db) `(d/since ~id ~(d/since-t db)) id) 157 | id (if (d/as-of-t db) `(d/as-of ~id ~(d/as-of-t db)) id) 158 | ;; datomic-uri is security-sensitive and is not part of db's identity. Resolving will required DI. 159 | ] 160 | id))) 161 | ComparableRepresentation 162 | (comparable [db] (db-name db)))) 163 | 164 | #?(:clj (defmethod hfql-resolve `d/db [[_ db-name]] ; resolve a Datomic database by name. 165 | (when *uri* ; Resolving a Datomic database by name is only possible when browsing by Datomic URI. But it isn't always allowed. 166 | (let [datomic-uri-db-name (dx/datomic-uri-db-name *uri*)] 167 | (when (or (= "*" datomic-uri-db-name) ; Injected Datomic URI allows listing databases and connecting to other databases. 168 | (= db-name datomic-uri-db-name) ; Injected Datomic URI has a pinned database name, and doesn't allow connecting to other databases, but we are trying to resolve the current pinned one, which is always allowed. 169 | *allow-listing-and-browsing-all-dbs*) ; defaults to false – must be explicitly allowed at the entrypoint. 170 | (with-meta (d/db (d/connect (dx/set-db-name-in-datomic-uri *uri* db-name))) 171 | {::db-name db-name})))))) 172 | 173 | #?(:clj (defmethod hfql-resolve `d/history [[_ db]] (d/history (hfql-resolve db)))) 174 | #?(:clj (defmethod hfql-resolve `d/filter [[_ db]] (d/filter (hfql-resolve db) *filter-predicate*))) 175 | #?(:clj (defmethod hfql-resolve `d/since [[_ db t]] (d/since (hfql-resolve db) t))) 176 | #?(:clj (defmethod hfql-resolve `d/as-of [[_ db t]] (d/as-of (hfql-resolve db) t))) 177 | 178 | (e/defn ConnectDatomic [datomic-uri] 179 | (e/server 180 | (Loader #(d/connect datomic-uri) 181 | {:Busy (e/fn [] (dom/h1 (dom/text "Waiting for Datomic connection ..."))) 182 | :Failed (e/fn [error] 183 | (dom/h1 (dom/text "Datomic transactor not found, see Readme.md")) 184 | (dom/pre (dom/text (pr-str error))))}))) 185 | 186 | ;; #?(:clj (defn slow-query [] (Thread/sleep 5000) (d/entity *db* @(requiring-resolve 'dustingetz.mbrainz/lennon)))) 187 | 188 | #?(:clj 189 | (def sitemap 190 | { 191 | 'databases (hfql {(databases) {* [^{::hfql/link ['.. [`(DatomicBrowser ~'%v) 'attributes]]} db-name d/db-stats]}}) ; TODO use '% instead of '%v and wire hf/resolve 192 | 'attributes 193 | (hfql {(attributes) 194 | {* ^{::hfql/ColumnHeaderTooltip `SummarizeDatomicAttribute 195 | ::hfql/select '(attribute-entity-detail %)} 196 | [^{::hfql/link '(attribute-detail %) 197 | ::hfql/Tooltip `EntityTooltip} 198 | #(:db/ident %) 199 | ^{::hfql/Render `HumanFriendlyAttributeCount} 200 | attribute-count 201 | summarize-attr* 202 | #_:db/doc]}}) 203 | 204 | 'attribute-entity-detail 205 | (hfql {attribute-entity-detail ^{::hfql/Tooltip `SemanticTooltip 206 | ::hfql/ColumnHeaderTooltip `SummarizeDatomicAttribute} 207 | [^{::hfql/Render `EntityDbidCell} 208 | #(:db/id %) 209 | attribute-count 210 | summarize-attr* 211 | *]}) 212 | 213 | 'attribute-detail 214 | (hfql {attribute-detail 215 | {* ^{::hfql/ColumnHeaderTooltip `SummarizeDatomicAttribute 216 | ::hfql/Tooltip `SemanticTooltip} 217 | [^{::hfql/link '(entity-detail %)} 218 | #(:db/id %)]}}) 219 | 220 | 'tx-detail 221 | (hfql {tx-detail 222 | {* [^{::hfql/link '(entity-detail :e) 223 | ::hfql/Tooltip `EntityTooltip} 224 | #(:e %) 225 | ^{::hfql/link '(attribute-detail %) 226 | ::hfql/Tooltip `EntityTooltip} 227 | ^{::hfql/label :db/ident} 228 | {:a :db/ident} ; FIXME 229 | :v]}}) 230 | 231 | 'entity-detail 232 | (hfql {entity-detail ^{::hfql/Tooltip `SemanticTooltip} ; TODO want link and Tooltip instead 233 | [^{::hfql/Render `EntityDbidCell} 234 | #(:db/id %) 235 | *]}) 236 | 237 | 'entity-history 238 | (hfql {entity-history 239 | {* [^{::hfql/link '(entity-detail :e) 240 | ::hfql/Tooltip `EntityTooltip} ; No need for a link on :e, it would always point to the same page. 241 | #(:e %) 242 | ^{::hfql/link '(attribute-detail :a) 243 | ::hfql/Tooltip `EntityTooltip} 244 | {:a :db/ident} ; FIXME 245 | :v 246 | ^{::hfql/link '(tx-detail %v) 247 | ::hfql/Tooltip `EntityTooltip} 248 | #(:tx %) 249 | :added]}})})) 250 | 251 | (e/defn InjectStyles [] 252 | (e/client 253 | (dom/link (dom/props {:rel :stylesheet :href "/hyperfiddle/electric-forms.css"})) 254 | (dom/link (dom/props {:rel :stylesheet :href "/hyperfiddle/datomic-browser2.css"})) 255 | (Checkbox* false {:class "data-loader__enabled" :style {:position :absolute, :inset-block-start "1dvw", :inset-inline-end "1dvw"}}))) 256 | 257 | (e/defn BrowseDatomicDatabase [sitemap entrypoints db] 258 | (InjectStyles) 259 | (e/server 260 | (binding [e/*exports* (e/exports) 261 | hyperfiddle.navigator6.rendering/*server-pretty {datomic.query.EntityMap (fn [entity] (str "EntityMap[" (dx/best-human-friendly-identity entity) "]"))}] 262 | (let [db-stats (e/server (e/Offload #(d/db-stats db)))] 263 | (binding [*db* db 264 | *db-stats* db-stats 265 | e/*bindings* (e/server (merge e/*bindings* {#'*db* db, #'*db-stats* db-stats}))] 266 | (HfqlRoot sitemap entrypoints)))))) 267 | 268 | (e/defn BrowseDatomicByConnection [sitemap entrypoints datomic-conn] 269 | (e/server 270 | (binding [*conn* datomic-conn 271 | e/*bindings* (merge e/*bindings* {#'*conn* datomic-conn})] 272 | (BrowseDatomicDatabase sitemap entrypoints (e/Offload #(d/db datomic-conn)))))) 273 | 274 | (e/defn BrowseDatomicByURI [sitemap entrypoints datomic-uri] 275 | (e/server 276 | (let [db-name (dx/datomic-uri-db-name datomic-uri)] 277 | (binding [*uri* datomic-uri 278 | *db-name* db-name 279 | e/*bindings* (e/server (merge e/*bindings* {#'*uri* datomic-uri, #'*db-name* db-name}))] 280 | (if (= "*" db-name) 281 | (do (InjectStyles) 282 | (HfqlRoot sitemap [^{::r/link ['..]} 'databases])) 283 | (BrowseDatomicByConnection sitemap entrypoints (e/server (ConnectDatomic datomic-uri)))))))) 284 | --------------------------------------------------------------------------------