Side A adds substantive production infrastructure (Dockerfile, fly.toml, supervisord, entrypoint script) plus real bugfixes to JDBC URL parsing (handling postgresql:// scheme and query params), SSL config, and DB connection retry logic in Rust—concrete lasting deployment capability. Side B is a small, somewhat regressive UI tweak that removes pin-form functionality for unpinned items and only adds one CSS line, offering far less durable value.
constitution · epochs · watch · epoch 3
c_a4b313331fd6 (tommy-mor) vs c_7074f7a7bb57 (tommy-mor)
download prompt · raw event · cmp_f527472a931d60
council reasoning
A ships lasting production infrastructure (multi-stage Dockerfile, fly.toml, supervisord) plus real compatibility fixes: JDBC URL/SSL parsing, smaller pools, PORT env handling, and rustsorter DB connect retries. B only tweaks CSS (display:flex) and removes pin/vote-preview UI surface area—small polish with little enduring project value.
Side A adds substantial production infrastructure and runtime robustness: a Fly.io deployment (Dockerfile, supervisor, entrypoint, fly.toml), support for managed PostgreSQL with SSL URL handling, PORT environment support, and retry logic for Rust database connections. Side B is a small UI cleanup, removing pin controls and a preview section while adding a single CSS display rule, with far less lasting architectural impact.
sides
A — c_a4b313331fd6 (tommy-mor)
message
[f6e0be88] add fly.io deployment with external postgres support Ship a production docker image and fly config so sorter can run on a single fly machine against managed postgres, including ssl url parsing and db connection retries. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/borter/src/app/database.clj b/borter/src/app/database.clj
index 9a7251a94d3b237bc640541cdeb1845ef3669738..7d829634869ca4d66038d7ceeaf248fd488a566c 100644
--- a/borter/src/app/database.clj
+++ b/borter/src/app/database.clj
@@ -16,13 +16,21 @@
(defn parse-jdbc [jdbc-url]
(try
- (drop 1 (re-matches
- #"postgres://(.*):(.*)@(.*):(\d*)/(.*)"
- jdbc-url))
+ (let [normalized (-> jdbc-url
+ (clojure.string/replace #"^postgresql://" "postgres://")
+ (clojure.string/replace #"\?.*$" ""))]
+ (drop 1 (re-matches
+ #"postgres://(.*):(.*)@(.*):(\d*)/(.*)"
+ normalized)))
(catch Exception e
(log/error "Failed to parse JDBC URL:" (.getMessage e))
nil)))
+(defn ssl-config [jdbc-url]
+ (when (or (clojure.string/includes? (or jdbc-url "") "sslmode=require")
+ (= "require" (System/getenv "PGSSLMODE")))
+ {:ssl true :sslmode "require"}))
+
(defn build-datasource []
;; Read DATABASE_URL *at runtime*
(try
@@ -39,22 +47,23 @@
(swap! db-state assoc :last-error "Invalid DATABASE_URL format")
nil)
(let [[username password host port database] parsed
- cfg {:auto-commit true
- :read-only false
- :connection-timeout 30000
- :validation-timeout 5000
- :idle-timeout 600000
- :max-lifetime 1800000
- :minimum-idle 10
- :maximum-pool-size 10
- :pool-name "db-pool"
- :register-mbeans false
- :adapter "postgresql"
- :username username
- :password password
- :server-name host
- :port-number (Integer/parseInt port)
- :database-name database}]
+ cfg (merge {:auto-commit true
+ :read-only false
+ :connection-timeout 30000
+ :validation-timeout 5000
+ :idle-timeout 600000
+ :max-lifetime 1800000
+ :minimum-idle 2
+ :maximum-pool-size 5
+ :pool-name "db-pool"
+ :register-mbeans false
+ :adapter "postgresql"
+ :username username
+ :password password
+ :server-name host
+ :port-number (Integer/parseInt port)
+ :database-name database}
+ (ssl-config database-url))]
(try
(log/info "Attempting to create database connection pool")
(make-datasource cfg)
diff --git a/borter/src/prod.clj b/borter/src/prod.clj
index 1b541a0628494a4e3bf6b339ebd543b8aaec25b8..746763bb14170b172d11060965a0abf511305891 100644
--- a/borter/src/prod.clj
+++ b/borter/src/prod.clj
@@ -81,23 +81,24 @@
(defn -main
"Run with `clj -M -m prod` or with optional port override {:port 3000}"
[& {:keys [port] :or {port 8080}}]
- (log/info "\n ______ _____ ______ _______ _______ ______\n |_____] | | |_____/ | |______ |_____/\n |_____] |_____| | \\_ | |______ | \\_\n\n📦️" version "\n")
- (log/info "starting borter" version "in" environment "environment")
- (log/debug "server configuration:" electric-server-config)
- (log/info "starting repl with config" (dissoc nrepl-config :auth)) ; don't log password
- (reset! server-instance (nrepl-server/start-server nrepl-config))
- (try
- (db/run-migrations)
- (log/info "database migrations completed successfully")
- (catch Exception e
- (log/error e "failed to run database migrations")))
- (try
- (http/start {:port port})
- (log/info "http server started successfully on port" port)
- (catch Exception e
- (log/error e "failed to start http server")))
- ;; Start the database health checker
- (start-db-health-checker))
+ (let [port (or (some-> (System/getenv "PORT") Integer/parseInt)
+ port)]
+ (log/info "\n ______ _____ ______ _______ _______ ______\n |_____] | | |_____/ | |______ |_____/\n |_____] |_____| | \\_ | |______ | \\_\n\n📦️" version "\n")
+ (log/info "starting borter" version "in" environment "environment")
+ (log/debug "server configuration:" electric-server-config)
+ (log/info "starting repl with config" (dissoc nrepl-config :auth)) ; don't log password
+ (reset! server-instance (nrepl-server/start-server nrepl-config))
+ (try
+ (db/run-migrations)
+ (log/info "database migrations completed successfully")
+ (catch Exception e
+ (log/error e "failed to run database migrations")))
+ (try
+ (http/start {:port port})
+ (log/info "http server started successfully on port" port)
+ (catch Exception e
+ (log/error e "failed to start http server")))
+ (start-db-health-checker)))
(defn -shutdown-hook []
(log/info "Shutdown hook called, cleaning up resources")
diff --git a/docker/fly/Dockerfile b/docker/fly/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..47b3346f72a3cb4285cdf185003641729518f2d0
--- /dev/null
+++ b/docker/fly/Dockerfile
@@ -0,0 +1,50 @@
+# Stage 1: build frontend assets into borter/resources/public
+FROM node:20-bookworm-slim AS forter-build
+WORKDIR /app
+COPY forter/package.json forter/package-lock.json ./forter/
+RUN cd forter && npm ci
+COPY forter/ ./forter/
+COPY borter/resources/public ./borter/resources/public/
+RUN mkdir -p borter/resources/public && cd forter && ENVIRONMENT=staging npm run staging
+
+# Stage 2: build borter uberjar
+FROM zudsniper/clojure:openjdk-17-node AS borter-build
+WORKDIR /app/borter
+COPY borter/deps.edn borter/build.sh borter/version ./
+COPY borter/src ./src/
+COPY borter/src-build ./src-build/
+COPY borter/resources ./resources/
+COPY --from=forter-build /app/borter/resources/public ./resources/public/
+RUN clojure -P && VERSION=$(cat version) && clojure -X:build uberjar :version "\"$VERSION\""
+
+# Stage 3: build rustsorter
+FROM rust:1-bookworm AS rustsorter-build
+WORKDIR /app
+RUN apt-get update && apt-get install -y libpq-dev pkg-config && rm -rf /var/lib/apt/lists/*
+COPY rustsorter/Cargo.toml rustsorter/Cargo.lock ./
+COPY rustsorter/.sqlx ./.sqlx
+COPY rustsorter/src ./src
+ENV SQLX_OFFLINE=true
+RUN cargo build --release
+
+# Stage 4: runtime
+FROM eclipse-temurin:17-jre-jammy
+RUN apt-get update && apt-get install -y supervisor curl && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY --from=borter-build /app/borter/target/sorter-*-standalone.jar /app/borter/sorter.jar
+COPY --from=rustsorter-build /app/target/release/rustsorter /app/rustsorter/rustsorter
+COPY docker/fly/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
+COPY docker/fly/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh && mkdir -p /app/borter/logs
+
+ENV JAVA_OPTS="-Xmx256m -Xms64m"
+ENV ENVIRONMENT=staging
+ENV PORT=8080
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
+ CMD curl -f http://127.0.0.1:${PORT}/api/health || exit 1
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/docker/fly/entrypoint.sh b/docker/fly/entrypoint.sh
new file mode 100755
index 0000000000000000000000000000000000000000..cb210a8c2612370c8c8768029cc761ce9a591b1b
--- /dev/null
+++ b/docker/fly/entrypoint.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+set -euo pipefail
+
+if [ -z "${DATABASE_URL:-}" ]; then
+ echo "DATABASE_URL is required"
+ exit 1
+fi
+
+# normalize postgresql:// URLs and keep ssl params for downstream consumers
+export DATABASE_URL="${DATABASE_URL/postgresql:\/\//postgres:\/\/}"
+export PGSSLMODE="${PGSSLMODE:-require}"
+export PORT="${PORT:-8080}"
+export ENVIRONMENT="${ENVIRONMENT:-staging}"
+export REPL_PASSWORD="${REPL_PASSWORD:-fly-repl-password}"
+export HOSTNAME="${HOSTNAME:-localhost}"
+export BORTER_LOG_PATH="${BORTER_LOG_PATH:-/app/borter/logs}"
+export BORTER_LOG_LEVEL="${BORTER_LOG_LEVEL:-info}"
+export AUTO_LOGIN="${AUTO_LOGIN:-true}"
+export NO_RATE_LIMITS="${NO_RATE_LIMITS:-true}"
+export RUSTSORTER_URL="${RUSTSORTER_URL:-http://127.0.0.1:8081}"
+
+exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
diff --git a/docker/fly/supervisord.conf b/docker/fly/supervisord.conf
new file mode 100644
index 0000000000000000000000000000000000000000..5e2c67a1cffe379b0c53bd3ce2ec6b7184e789e5
--- /dev/null
+++ b/docker/fly/supervisord.conf
@@ -0,0 +1,28 @@
+[supervisord]
+nodaemon=true
+loglevel=info
+
+[program:rustsorter]
+command=/app/rustsorter/rustsorter
+directory=/app/rustsorter
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+priority=10
+environment=DATABASE_URL="%(ENV_DATABASE_URL)s",ENVIRONMENT="%(ENV_ENVIRONMENT)s"
+
+[program:borter]
+command=java %(ENV_JAVA_OPTS)s -jar /app/borter/sorter.jar
+directory=/app/borter
+autostart=true
+autorestart=true
+startsecs=15
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+priority=20
+environment=DATABASE_URL="%(ENV_DATABASE_URL)s",ENVIRONMENT="%(ENV_ENVIRONMENT)s",PORT="%(ENV_PORT)s",REPL_PASSWORD="%(ENV_REPL_PASSWORD)s",HOSTNAME="%(ENV_HOSTNAME)s",BORTER_LOG_PATH="%(ENV_BORTER_LOG_PATH)s",BORTER_LOG_LEVEL="%(ENV_BORTER_LOG_LEVEL)s",AUTO_LOGIN="%(ENV_AUTO_LOGIN)s",NO_RATE_LIMITS="%(ENV_NO_RATE_LIMITS)s",RUSTSORTER_URL="%(ENV_RUSTSORTER_URL)s",PGSSLMODE="%(ENV_PGSSLMODE)s"
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000000000000000000000000000000000000..0ea6b263bdff021981dfda9aa6c48f9a4ba2f3a9
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,32 @@
+app = 'sorter-fly-app'
+primary_region = 'ewr'
+
+[build]
+ dockerfile = 'docker/fly/Dockerfile'
+
+[env]
+ ENVIRONMENT = 'staging'
+ PORT = '8080'
+ PGSSLMODE = 'require'
+ AUTO_LOGIN = 'true'
+ NO_RATE_LIMITS = 'true'
+
+[http_service]
+ internal_port = 8080
+ force_https = true
+ auto_stop_machines = 'stop'
+ auto_start_machines = true
+ min_machines_running = 1
+ processes = ['app']
+
+ [[http_service.checks]]
+ interval = '30s'
+ timeout = '10s'
+ grace_period = '180s'
+ method = 'GET'
+ path = '/index.html'
+
+[[vm]]
+ size = 'shared-cpu-1x'
+ memory = '512mb'
+ cpus = 1
diff --git a/rustsorter/src/main.rs b/rustsorter/src/main.rs
index 2dc534ca99566b170e948d11396baa95b91b5870..c4af5978509afe04a2ab940be0d3c96bbaef0571 100644
--- a/rustsorter/src/main.rs
+++ b/rustsorter/src/main.rs
@@ -8,7 +8,7 @@ pub struct AppState {
pub db: sqlx::PgPool,
}
-use std::{env, sync::Arc};
+use std::{env, sync::Arc, time::Duration};
use actix_web::{App, HttpServer, middleware::Logger, web, HttpResponse, get};
use dotenv::dotenv;
@@ -33,12 +33,21 @@ async fn main() -> Result<(), anyhow::Error> {
// Retrieve the database URL from the environment
let database_string = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
- // Create the database pool
- let pool = sqlx::postgres::PgPoolOptions::new()
- .max_connections(5)
- .connect(&database_string)
- .await
- .expect("Unable to connect to database");
+ // Create the database pool with retries for external databases
+ let pool = loop {
+ match sqlx::postgres::PgPoolOptions::new()
+ .max_connections(5)
+ .acquire_timeout(Duration::from_secs(10))
+ .connect(&database_string)
+ .await
+ {
+
… preview truncated; 339 characters omittedB — c_7074f7a7bb57 (tommy-mor)
message
[f7eb8b76] css
diff preview
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 41f9e9c64a80a55a9d3ece2a6a1f592cf5e8d8dd..8fc6be1de8dd975f9547de615809222236be4b70 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -362,7 +362,6 @@ fn child_row_pin_or_vote(
row_item: &ItemId,
pinned_room_and_item: Option<&(String, ItemId)>,
scope_content: &ContentState,
- next_path: &str,
) -> maud::Markup {
let pin_matches_scope = pinned_room_and_item
.map(|(r, _)| r == nav.room_wire.as_str())
@@ -371,21 +370,9 @@ fn child_row_pin_or_vote(
.filter(|_| pin_matches_scope)
.map(|(_, i)| i);
- let pin_rpc = template_json_compact(
- &json!({
- "action": "set_garden_pin",
- "clear": false,
- "room_wire": nav.room_wire.clone(),
- "item_storage": row_item.as_str(),
- "next": next_path,
- "form_action": "/ui",
- }),
- )
- .expect("child pin rpc json");
-
html! {
- span class="ont-garden-child-actions" data-garden-room=(nav.room_wire.as_str()) {
- @if let Some(pi) = pinned_item {
+ @if let Some(pi) = pinned_item {
+ span class="ont-garden-child-actions" data-garden-room=(nav.room_wire.as_str()) {
@if pi == row_item {
span class="ont-garden-pinned-here" title="Pinned" aria-label="Pinned" { "📌" }
} @else {
@@ -400,11 +387,6 @@ fn child_row_pin_or_vote(
span class="ont-garden-vote-count" { (format!("{}", nv)) }
}
}
- } @else {
- form method="POST" action="/ui" data-navigate="full" class="ont-pin-form ont-garden-pin-form" {
- input type="hidden" name=(UI_RPC_FIELD) value=(pin_rpc);
- button type="submit" class="ont-garden-pin-ico" title="Pin" aria-label="Pin" { "📌" }
- }
}
}
}
@@ -1190,7 +1172,7 @@ async fn render_scope_view(
@let item_url = item_href(r.item.as_str(), &nav);
@let score_str = format!("{:.3}", r.score);
li data-garden-item=(r.item.as_str()) {
- (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content, &next_for_pin))
+ (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content))
a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } }
span class="ont-rank-score" { (score_str) }
}
@@ -1206,7 +1188,7 @@ async fn render_scope_view(
ul class="ont-group-list" {
@for name in &model.child_rankings.unranked_items {
li data-garden-item=(name.as_str()) {
- (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content, &next_for_pin))
+ (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content))
@let href = item_href(name.as_str(), &nav);
a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } }
}
@@ -1353,10 +1335,6 @@ async fn vote_compare_inner(
div id="vote-edge-history-region" {
(edge_history)
}
- div class="vote-compare-preview-wrap" {
- h3 { "your vote (after post)" }
- div id="vote-compare-preview" class="vote-compare-preview" {}
- }
@if can_post {
form id="vote-compare-form" method="POST" action="/ui" {
input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json);
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index 0871e3126c656ba534306cdc3f405f802d10b589..6eb9222184a8795d67a5d09d41de08c8ac1b148f 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -638,6 +638,7 @@ body.view-ontology .ont-item-meta {
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
+ display: flex;
}
body.view-ontology .ont-item-title {
flex: 1 1 auto;
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.