├── .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 | [](./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 | [](./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 |
--------------------------------------------------------------------------------