You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit 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 Side A — unified diff (full patch): 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 + { + Ok(pool) => break pool, + Err(e) => { + eprintln!("unable to connect to database: {e}, retrying in 5s..."); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + }; // Shared application state let app_state = Arc::new(AppState { db: pool.clone() }); Side B — contributor: tommy-mor Side B — commit message: [f7eb8b76] css Side B — unified diff (full patch): 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;