From 086bf72615d827146e8ad1c11cbbbef50f458e01 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 06:41:18 +0200 Subject: [PATCH 1/9] ci: refresh apt index and fix parser schedule - install-crystal v1.9.2 installs packages without apt-get update, so stale runner indexes 404 on libevent-dev (crystal-lang/install-crystal#82) - cron ORs day-of-month with weekday, so '1-7,15-21 * 0' ran daily --- .github/workflows/tests.yml | 2 ++ .github/workflows/update-parsers.yml | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1cbfbe6..088ab9d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Download source uses: actions/checkout@v4 + - name: Refresh apt index + run: sudo apt-get update - name: Install Crystal uses: crystal-lang/install-crystal@v1 - name: Install dependencies diff --git a/.github/workflows/update-parsers.yml b/.github/workflows/update-parsers.yml index 528dd40..08f55bb 100644 --- a/.github/workflows/update-parsers.yml +++ b/.github/workflows/update-parsers.yml @@ -2,8 +2,8 @@ name: Update Parsers on: schedule: - # Run every two weeks on Sunday at 00:00 UTC (1st and 3rd Sunday of each month) - - cron: '0 0 1-7,15-21 * 0' + # Run on the 1st and 15th of each month at 00:00 UTC + - cron: '0 0 1,15 * *' workflow_dispatch: # Allow manual trigger jobs: @@ -17,6 +17,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Refresh apt index + run: sudo apt-get update + - name: Install Crystal uses: crystal-lang/install-crystal@v1 @@ -52,7 +55,7 @@ jobs: - User Agent parsing regexes - GeoLite2 database - **Triggered by**: Scheduled workflow (runs every 2 weeks) + **Triggered by**: Scheduled workflow (runs on the 1st and 15th of each month) **Date**: ${{ github.event.repository.updated_at }} Please review the changes and merge if everything looks good. From 188271af94b7546ff36231a44f6934bbd32470f6 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 06:41:18 +0200 Subject: [PATCH 2/9] build: use official Crystal 1.21.1 image Alpine 3.23 ships Crystal 1.18.2, which lacks the HTTP::Server request smuggling fixes in 1.19.2 to 1.21.1. The official image is multi-arch; the runtime stays on its Alpine 3.22 base. libevent is unused since the epoll event loop. --- Dockerfile | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index dca06b9..b2cdeef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,10 @@ -FROM alpine:3.23 AS build +FROM crystallang/crystal:1.21.1-alpine AS build ENV ENV=production WORKDIR /usr/src/app RUN apk add --no-cache \ - crystal \ - shards \ - openssl-dev \ - yaml-dev \ sqlite-dev \ - libevent-dev \ tzdata COPY . . @@ -17,7 +12,7 @@ COPY . . RUN shards install --production RUN shards build --release --no-debug --progress --stats -FROM alpine:3.23 AS runtime +FROM alpine:3.22 AS runtime ENV ENV=production WORKDIR /usr/src/app @@ -25,7 +20,6 @@ WORKDIR /usr/src/app RUN apk add --no-cache \ gc-dev \ pcre2 \ - libevent \ sqlite-libs \ openssl \ yaml \ From f2490aff9989ead8dc39a9bdacee93c3550bef41 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 06:41:18 +0200 Subject: [PATCH 3/9] build(deps): upgrade kemal 1.14 and sqlite3 0.23, drop micrate - kemal 1.13 and 1.14 carry security fixes; 1.14 discards bodies written before an error, so error bodies travel with the exception and are printed by error handlers - unmatched routes answer JSON 404, wrong methods 405 with Allow - micrate pinned db 0.11 and sqlite3 0.19; app/lib/migrator.cr replaces it, keeps the micrate_db_version table, and fails boot on a broken migration - require Crystal >= 1.21.1 --- app/lib/database.cr | 9 +--- app/lib/errors.cr | 51 ++++++++----------- app/lib/migrator.cr | 72 +++++++++++++++++++++++++++ app/routes.cr | 15 +++++- docs/SETUP.md | 8 +-- scripts/benchmark.cr | 34 +++---------- shard.lock | 18 ++----- shard.yml | 7 +-- spec/integration/security_spec.cr | 16 ++++++ spec/lib/migrator_spec.cr | 83 +++++++++++++++++++++++++++++++ spec/spec_helper.cr | 4 +- 11 files changed, 222 insertions(+), 95 deletions(-) create mode 100644 app/lib/migrator.cr create mode 100644 spec/lib/migrator_spec.cr diff --git a/app/lib/database.cr b/app/lib/database.cr index d484a33..8822b8a 100644 --- a/app/lib/database.cr +++ b/app/lib/database.cr @@ -1,6 +1,6 @@ require "sqlite3" require "crecto" -require "micrate" +require "./migrator" module App::Lib class Database @@ -24,11 +24,6 @@ module App::Lib Crecto::DbLogger.set_handler(STDOUT) end - def self.run_migrations - Micrate::DB.connection_url = ENV["DATABASE_URL"] - Micrate::Cli.run_up - end - - run_migrations + Migrator.up(ENV["DATABASE_URL"]) end end diff --git a/app/lib/errors.cr b/app/lib/errors.cr index 7f300a5..2d16bec 100644 --- a/app/lib/errors.cr +++ b/app/lib/errors.cr @@ -1,57 +1,50 @@ require "kemal" module App - class InternalServerErrorException < Kemal::Exceptions::CustomException - def initialize(context) + # Kemal discards a body written before raising, so the body travels with the exception and the `error` handlers in routes.cr print it. + abstract class HttpException < Kemal::Exceptions::CustomException + getter body : String + + def initialize(context, status_code : Int32, @body : String) context.response.content_type = "application/json" - context.response.status_code = 500 - context.response.print({ "error" => "Internal Server Error" }.to_json) + context.response.status_code = status_code super(context) end end - class BadRequestException < Kemal::Exceptions::CustomException + class InternalServerErrorException < HttpException + def initialize(context) + super(context, 500, { "error" => "Internal Server Error" }.to_json) + end + end + + class BadRequestException < HttpException def initialize(context, message : String) - context.response.content_type = "application/json" - context.response.status_code = 400 - context.response.print({ "error" => message }.to_json) - super(context) + super(context, 400, { "error" => message }.to_json) end end - class UnauthorizedException < Kemal::Exceptions::CustomException + class UnauthorizedException < HttpException def initialize(context) - context.response.content_type = "application/json" - context.response.status_code = 401 - context.response.print({ "error" => "Unauthorized access" }.to_json) - super(context) + super(context, 401, { "error" => "Unauthorized access" }.to_json) end end - class ForbiddenException < Kemal::Exceptions::CustomException + class ForbiddenException < HttpException def initialize(context) - context.response.content_type = "application/json" - context.response.status_code = 403 - context.response.print({ "error" => "Access not allowed" }.to_json) - super(context) + super(context, 403, { "error" => "Access not allowed" }.to_json) end end - class NotFoundException < Kemal::Exceptions::CustomException + class NotFoundException < HttpException def initialize(context) - context.response.content_type = "application/json" - context.response.status_code = 404 - context.response.print({ "error" => "Resource not found" }.to_json) - super(context) + super(context, 404, { "error" => "Resource not found" }.to_json) end end - class UnprocessableEntityException < Kemal::Exceptions::CustomException + class UnprocessableEntityException < HttpException def initialize(context, message : Hash(String, Array(String))) - context.response.content_type = "application/json" - context.response.status_code = 422 - context.response.print({ "errors" => message }.to_json) - super(context) + super(context, 422, { "errors" => message }.to_json) end end end diff --git a/app/lib/migrator.cr b/app/lib/migrator.cr new file mode 100644 index 0000000..943c416 --- /dev/null +++ b/app/lib/migrator.cr @@ -0,0 +1,72 @@ +require "db" +require "log" + +module App::Lib + # Applies pending `db/migrations/*.sql` files in version order, one transaction each. Only the `-- +micrate Up` section runs; statements end at a line ending with `;` (as in micrate, `--` always starts a comment, even inside a string literal). State lives in micrate's `micrate_db_version` table, so databases migrated by micrate keep their history. + module Migrator + Log = ::Log.for(self) + DIR = "db/migrations" + + def self.up(url : String) + DB.open(url) { |db| up(db) } + end + + def self.up(db : DB::Database, dir = DIR) + db.exec <<-SQL + CREATE TABLE IF NOT EXISTS micrate_db_version ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + is_applied INTEGER NOT NULL, + tstamp TIMESTAMP + ) + SQL + + # The latest row per version says whether it is applied or rolled back. + applied = {} of Int64 => Bool + db.query_each("SELECT version_id, is_applied FROM micrate_db_version ORDER BY id") do |rs| + applied[rs.read(Int64)] = rs.read(Bool) + end + + Dir.glob("#{dir}/*.sql").sort.each do |path| + name = File.basename(path) + next unless version = name[/^(\d+)_/, 1]?.try(&.to_i64) + next if applied[version]? + + db.transaction do |tx| + statements(File.read(path)).each { |sql| tx.connection.exec(sql) } + tx.connection.exec( + "INSERT INTO micrate_db_version (version_id, is_applied, tstamp) VALUES (?, ?, ?)", + version, true, Time.local + ) + end + Log.info { "Applied #{name}" } + end + end + + def self.statements(source : String) : Array(String) + statements = [] of String + buffer = String::Builder.new + active = false + + source.each_line do |line| + case directive = line.strip + when "-- +micrate Up" then active = true + when "-- +micrate Down" then active = false + when .starts_with?("-- +micrate") + raise "Unsupported migration directive: #{directive}" + else + next unless active + buffer << line << '\n' + if line.split("--", 2).first.rstrip.ends_with?(';') + statements << buffer.to_s + buffer = String::Builder.new + end + end + end + + leftover = buffer.to_s.lines.map(&.split("--", 2).first.strip).reject(&.empty?) + raise "Migration statement missing trailing ';'" unless leftover.empty? + statements + end + end +end diff --git a/app/routes.cr b/app/routes.cr index 97fb5d9..aaf2ec1 100644 --- a/app/routes.cr +++ b/app/routes.cr @@ -37,8 +37,19 @@ module App Controllers::LinkController.new(env).delete end + {% for status in [400, 401, 403, 404, 405, 413, 422] %} + error {{status}} do |env, ex| + next ex.body if ex.is_a?(HttpException) + {% if status == 404 %} + NotFoundException.new(env).body + {% else %} + env.response.content_type = "application/json" + { "error" => HTTP::Status.new({{status}}).description }.to_json + {% end %} + end + {% end %} + error 500 do |env| - App::InternalServerErrorException.new(env) - "" + App::InternalServerErrorException.new(env).body end end diff --git a/docs/SETUP.md b/docs/SETUP.md index b4712e7..06ed801 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -12,7 +12,7 @@ Options: ## Local Development ### Requirements -- Crystal 1.18+ +- Crystal 1.21.1+ - Shards package manager - SQLite3 @@ -22,12 +22,6 @@ Options: sudo apt-get update && sudo apt-get install -y crystal libssl-dev libsqlite3-dev ``` -- macos -```bash -brew tap amberframework/micrate -brew install micrate -``` - ### Install Shards and Run ```bash diff --git a/scripts/benchmark.cr b/scripts/benchmark.cr index 596165e..f2818aa 100644 --- a/scripts/benchmark.cr +++ b/scripts/benchmark.cr @@ -2,6 +2,8 @@ require "http/client" require "json" +require "sqlite3" +require "../app/lib/migrator" PORT = "4001" APP_URL = "http://localhost:#{PORT}" @@ -150,7 +152,7 @@ def stop_application(process : Process) end def check_dependencies - {"bombardier", "sqlite3", "micrate"}.each do |cmd| + {"bombardier", "sqlite3"}.each do |cmd| process = Process.run("which", [cmd], output: Process::Redirect::Close) unless process.success? puts "Error: #{cmd} is not installed. Please install it to proceed." @@ -159,8 +161,6 @@ def check_dependencies puts " brew install bombardier" when "sqlite3" puts " brew install sqlite3" - when "micrate" - puts " shards install" end exit(1) end @@ -276,12 +276,12 @@ def click_count(link_id : Int64) : Int64 end def wait_for_clicks(link_id : Int64, expected : Int64) : Int64 - deadline = Time.monotonic + 30.seconds + deadline = Time.instant + 30.seconds loop do count = click_count(link_id) return count if count >= expected - break if Time.monotonic >= deadline + break if Time.instant >= deadline sleep 0.5.seconds end @@ -311,28 +311,8 @@ end def run_migrations puts "Running database migrations..." - - process = Process.run("which", ["micrate"], output: Process::Redirect::Close) - unless process.success? - puts "Error: micrate is not installed. Please install it to proceed." - puts " shards install" - exit(1) - end - - process = Process.run( - "micrate", - ["up"], - env: {"DATABASE_URL" => DATABASE_URL}, - output: Process::Redirect::Inherit, - error: Process::Redirect::Inherit - ) - - if process.success? - puts "Migrations completed successfully." - else - puts "Error: Migrations failed." - exit(1) - end + App::Lib::Migrator.up(DATABASE_URL) + puts "Migrations completed successfully." end def seed_database diff --git a/shard.lock b/shard.lock index ce66d7b..96be5d9 100644 --- a/shard.lock +++ b/shard.lock @@ -10,7 +10,7 @@ shards: db: git: https://github.com/crystal-lang/crystal-db.git - version: 0.11.0 + version: 0.14.0 dotenv: git: https://github.com/gdotdesign/cr-dotenv.git @@ -26,24 +26,12 @@ shards: kemal: git: https://github.com/kemalcr/kemal.git - version: 1.12.0 + version: 1.14.0 maxminddb: git: https://github.com/delef/maxminddb.cr.git version: 1.5.0 - micrate: - git: https://github.com/amberframework/micrate.git - version: 0.15.1 - - mysql: - git: https://github.com/crystal-lang/crystal-mysql.git - version: 0.14.0 - - pg: - git: https://github.com/will/crystal-pg.git - version: 0.26.0 - radix: git: https://github.com/luislavena/radix.git version: 0.4.1 @@ -54,5 +42,5 @@ shards: sqlite3: git: https://github.com/crystal-lang/crystal-sqlite3.git - version: 0.19.0 + version: 0.23.0 diff --git a/shard.yml b/shard.yml index 281d711..5906edd 100644 --- a/shard.yml +++ b/shard.yml @@ -15,14 +15,11 @@ targets: dependencies: kemal: github: kemalcr/kemal - version: 1.12.0 + version: 1.14.0 sqlite3: github: crystal-lang/crystal-sqlite3 crecto: github: fridgerator/crecto - micrate: - github: amberframework/micrate - version: 0.15.1 maxminddb: github: delef/maxminddb.cr @@ -33,6 +30,6 @@ development_dependencies: github: kemalcr/spec-kemal version: 1.3.0 -crystal: ">= 1.18.2" +crystal: ">= 1.21.1" license: MIT diff --git a/spec/integration/security_spec.cr b/spec/integration/security_spec.cr index 696f1ce..903ae5d 100644 --- a/spec/integration/security_spec.cr +++ b/spec/integration/security_spec.cr @@ -6,10 +6,26 @@ describe "API security and edge cases" do get("/api/links", headers: HTTP::Headers{"X-Api-Key" => api_key}) response.status_code.should eq(401) + response.headers["Content-Type"].should eq("application/json") response.body.should eq({"error" => "Unauthorized access"}.to_json) end end + it "answers unmatched routes and methods with JSON" do + headers = HTTP::Headers{"X-Api-Key" => create_test_user().api_key.to_s} + + get("/a/b", headers: headers) + response.status_code.should eq(404) + response.headers["Content-Type"].should eq("application/json") + response.body.should eq({"error" => "Resource not found"}.to_json) + + post("/api/ping", headers: headers) + response.status_code.should eq(405) + response.headers["Allow"].should eq("GET, HEAD") + response.headers["Content-Type"].should eq("application/json") + response.body.should eq({"error" => "Method Not Allowed"}.to_json) + end + it "rejects malformed IDs" do user = create_test_user() headers = HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => user.api_key.to_s} diff --git a/spec/lib/migrator_spec.cr b/spec/lib/migrator_spec.cr new file mode 100644 index 0000000..2aad3f8 --- /dev/null +++ b/spec/lib/migrator_spec.cr @@ -0,0 +1,83 @@ +require "../spec_helper" + +private def with_migrations(files : Hash(String, String), &) + dir = File.tempname("migrations") + Dir.mkdir(dir) + files.each { |name, sql| File.write(File.join(dir, name), sql) } + db_file = File.tempname("migrator", ".db") + yield "sqlite3://#{db_file}", dir +ensure + FileUtils.rm_rf(dir) if dir + File.delete?(db_file) if db_file +end + +private def table_names(db) + db.query_all("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 't_'", as: String).sort +end + +describe "App::Lib::Migrator" do + it "returns only the Up statements" do + source = <<-SQL + -- +micrate Up + CREATE TABLE a (id INTEGER); -- trailing comment + CREATE INDEX a_id + ON a (id); + -- done + + -- +micrate Down + DROP TABLE a; + SQL + + App::Lib::Migrator.statements(source).map(&.strip).should eq([ + "CREATE TABLE a (id INTEGER); -- trailing comment", + "CREATE INDEX a_id\n ON a (id);", + ]) + end + + it "rejects unsupported directives and unterminated statements" do + expect_raises(Exception, /Unsupported migration directive/) do + App::Lib::Migrator.statements("-- +micrate Up\n-- +micrate StatementBegin\nSELECT 1;\n") + end + expect_raises(Exception, /missing trailing ';'/) do + App::Lib::Migrator.statements("-- +micrate Up\nSELECT 1\n") + end + end + + it "applies pending and rolled back versions from micrate history" do + with_migrations({ + "1_a.sql" => "-- +micrate Up\nCREATE TABLE t1 (id INTEGER);\n", + "2_b.sql" => "-- +micrate Up\nCREATE TABLE t2 (id INTEGER);\n", + "3_c.sql" => "-- +micrate Up\nCREATE TABLE t3 (id INTEGER);\n", + "notes.sql" => "not a migration", + }) do |url, dir| + DB.open(url) do |db| + db.exec "CREATE TABLE micrate_db_version (id INTEGER PRIMARY KEY AUTOINCREMENT, version_id INTEGER NOT NULL, is_applied INTEGER NOT NULL, tstamp TIMESTAMP)" + db.exec "CREATE TABLE t2 (id INTEGER)" + [{0, true}, {1, true}, {2, true}, {1, false}].each do |version, applied| + db.exec "INSERT INTO micrate_db_version (version_id, is_applied) VALUES (?, ?)", version, applied + end + + App::Lib::Migrator.up(db, dir) + App::Lib::Migrator.up(db, dir) + + table_names(db).should eq(["t1", "t2", "t3"]) + db.scalar("SELECT COUNT(*) FROM micrate_db_version").should eq(6) + end + end + end + + it "rolls back and raises when a migration fails" do + with_migrations({ + "1_a.sql" => "-- +micrate Up\nCREATE TABLE t1 (id INTEGER);\nCREATE TABLE t1 (id INTEGER);\n", + }) do |url, dir| + expect_raises(Exception, /already exists/) do + DB.open(url) { |db| App::Lib::Migrator.up(db, dir) } + end + + DB.open(url) do |db| + table_names(db).should be_empty + db.scalar("SELECT COUNT(*) FROM micrate_db_version").should eq(0) + end + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 0e0f307..d7914cf 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,7 +1,6 @@ require "file_utils" require "spec-kemal" -require "micrate" require "dotenv" Dotenv.load ".env.#{ENV["ENV"]}" @@ -15,8 +14,7 @@ Spec.before_suite do File.delete(db_file_path) end - Micrate::DB.connection_url = ENV["DATABASE_URL"] - Micrate::Cli.run_up + App::Lib::Migrator.up(ENV["DATABASE_URL"]) Kemal.config.logging = false end From f250618deb61804acfe94a580f955c44a2af6406 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 08:01:07 +0200 Subject: [PATCH 4/9] docs: update benchmark numbers --- README.md | 2 +- docs/SETUP.md | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6ae4162..e55780b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## Why bit? -**Fast:** **4.1k req/sec**, latency 31ms, 41MiB avg memory usage with all clicks recorded (100k requests using 125 connections, [benchmark](docs/SETUP.md#benchmark)). +**Fast:** **21k req/sec**, latency 6ms, 32MiB avg memory usage with all clicks recorded (100k requests using 125 connections, [benchmark](docs/SETUP.md#benchmark)). **Lightweight:** Minimal dependencies, image size under 20 MiB, memory usage under 60 MiB at peak. diff --git a/docs/SETUP.md b/docs/SETUP.md index 06ed801..23e0e20 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -48,7 +48,7 @@ crystal spec e2e ### Run ``` -shards build --release --no-debug +ENV=production shards build --release --no-debug ./bin/benchmark ``` @@ -56,33 +56,33 @@ Optional environment variables: `BENCHMARK_REQUESTS`, `BENCHMARK_CONNECTIONS`, a ### Output -Machine: 10-core Apple M5 MacBook Pro. Memory: 32GB. macOS: 26.5.2. Crystal: 1.21.0. +Machine: 10-core Apple M5 MacBook Pro. Memory: 32GB. macOS: 27.0.1. Crystal: 1.21.1. The run with the median Reqs/sec out of three consecutive runs. ``` Starting benchmark with 100000 requests using 125 connections... Statistics Avg Stdev Max - Reqs/sec 4059.44 1420.08 6791.75 - Latency 30.94ms 3.95ms 92.38ms + Reqs/sec 21141.19 3261.06 26113.29 + Latency 5.91ms 1.41ms 25.77ms Latency Distribution - 50% 31.30ms - 75% 32.39ms - 90% 33.46ms - 95% 34.25ms - 99% 39.22ms + 50% 5.64ms + 75% 6.96ms + 90% 7.83ms + 95% 8.50ms + 99% 12.04ms HTTP codes: 1xx - 0, 2xx - 0, 3xx - 100000, 4xx - 0, 5xx - 0 others - 0 - Throughput: 1.31MB/s + Throughput: 6.87MB/s Click tracking drained: 100000/100000 clicks recorded. Benchmark completed successfully. **** Resource Usage Statistics **** - Measurements: 28 - Average CPU Usage: 68.39% - Average Memory Usage: 40.65 MiB - Peak CPU Usage: 78.2% - Peak Memory Usage: 47.02 MiB + Measurements: 8 + Average CPU Usage: 52.95% + Average Memory Usage: 32.14 MiB + Peak CPU Usage: 90.4% + Peak Memory Usage: 43.75 MiB ``` The benchmark validates that every redirect's asynchronous click record reaches SQLite. Earlier results measured redirect responses without verifying click delivery and are not directly comparable. From 29b515baa57f582c514bf4549c62796520844e56 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 08:01:07 +0200 Subject: [PATCH 5/9] docs: add AGENTS.md with manual verification steps --- AGENTS.md | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..da84e0e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# AGENTS.md + +bit is a self-hosted URL shortener: Crystal, Kemal, SQLite. `docs/openapi.yaml` is the API contract. `docs/SETUP.md` covers deployment and the benchmark. + +## Map + +- `bit.cr`: entry point. `app/routes.cr`: routes and the JSON `error` handlers. +- `app/controllers/`: `link.cr` (API), `click.cr` (redirect with async click tracking via `spawn`), `ping.cr`. +- `app/middlewares/`: `auth.cr` (`X-Api-Key` on every path except `/api/ping` and `/:slug`), `cors.cr`. +- `app/lib/`: `database.cr` (Crecto repo, runs migrations at boot), `migrator.cr`, `errors.cr`, `ua_parser.cr` and `ip_lookup.cr` (bounded caches). +- `app/services/`: `slug.cr`, `cli.cr` (user management, admin bootstrap from `ADMIN_NAME` and `ADMIN_API_KEY`). `app/config/env.cr`: environment defaults. +- `db/migrations/*.sql`: micrate-format files. `app/lib/migrator.cr` applies them at boot and keeps state in `micrate_db_version`. +- `data/`: user-agent regexes and the GeoLite2 database, refreshed by `.github/workflows/update-parsers.yml`. +- `scripts/cli.cr` and `scripts/benchmark.cr`. `spec/` runs in-process; `e2e/` starts `./bin/bit` as a real process. + +## Constraints that are easy to miss + +- `ENV` at compile time decides whether dotenv loads. Build the production binary with `ENV=production`. Any other build loads `.env.` at runtime (default `.env.development`), and that file must exist. +- Migrations: only the `-- +micrate Up` section runs, a statement ends at a line ending with `;`, and any other `-- +micrate` directive raises. A failing migration stops the app at boot. +- Kemal 1.14 discards a response body written before an error. Raise an `App::*Exception` from `app/lib/errors.cr`; do not print a body and then raise. +- Every API error is JSON. Auth runs before routing, so an unmatched route returns 401 without a key and `404 {"error":"Resource not found"}` with one. A wrong method returns 405 with an `Allow` header. +- Updating a link's URL regenerates its slug; the old short URL stops working. +- CI (`.github/workflows/tests.yml`, pull requests only) runs `shards install`, the production build, `crystal spec`, and `crystal spec e2e`. Formatting is not enforced. + +## Checks + +Run from the repository root, cheapest first: + +1. `shards install` +2. `ENV=production shards build --release --no-debug` (builds `bit`, `cli`, `benchmark` into `bin/`) +3. `ENV=test crystal spec` +4. `ENV=test crystal spec e2e` (needs the step 2 binary) +5. `docker build -t bit:local .` (add `--platform linux/amd64` to check the other published architecture) + +## Manual verification + +The specs cover most status codes in-process. The steps below check the published image over a real network, GeoIP, headers, load, and memory. Use them when a change touches a route, the Dockerfile, a dependency, or the redirect path. + +### Run with Docker + +```bash +docker build -t bit:local . +docker run -d --rm --name bit-local -p 4000:4000 \ + -e APP_URL=http://localhost:4000 -e ADMIN_NAME=Admin -e ADMIN_API_KEY=local-key bit:local +docker logs bit-local # expect "New user created" and no errors +``` + +Stop it with `docker stop bit-local`. The database lives in the container and is removed with it, so restart it before repeating the walkthrough. + +### Every endpoint, including edge cases + +Run under bash against a fresh container. Each `c` line prints the status code, the expected code, and the URL. Read the two printed bodies by eye. + +```bash +B=http://localhost:4000; K="X-Api-Key: local-key"; J="Content-Type: application/json" +c() { local want=$1; shift; printf '%s expect %s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "$@")" "$want" "${*: -1}"; } +new() { curl -s -H "$K" -H "$J" -d "{\"url\":\"$1\"}" $B/api/links | sed -E 's/^\{"data":\{"id":([0-9]+).*/\1/'; } +slug() { curl -s -H "$K" $B/api/links/$1 | sed -E 's/.*"refer":"[^"]*\/([^"]+)".*/\1/'; } + +c 200 $B/api/ping +c 200 -X OPTIONS $B/api/links # CORS preflight needs no key +c 401 $B/api/links +c 401 -H "X-Api-Key: wrong" $B/api/links +c 401 $B/missing/path # auth runs before routing +c 404 -H "$K" $B/missing/path +c 405 -X POST -H "$K" $B/api/ping # Allow: GET, HEAD + +ID=$(new https://example.com); S=$(slug $ID) +c 200 -H "$K" -H "$J" -d '{"url":"https://example.com"}' $B/api/links # duplicate returns the existing link +c 422 -H "$K" -H "$J" -d '{"url":"not a url"}' $B/api/links +c 400 -H "$K" -H "$J" -d '{}' $B/api/links +c 400 -H "$K" -H "$J" -d '{bad' $B/api/links +c 400 -H "$K" -H "$J" -d '[1]' $B/api/links +c 200 -H "$K" "$B/api/links?limit=1" +c 400 -H "$K" "$B/api/links?limit=0" +c 400 -H "$K" "$B/api/links?limit=1001" +c 400 -H "$K" "$B/api/links?cursor=0" +c 400 -H "$K" "$B/api/links?cursor=abc" +c 400 -H "$K" $B/api/links/not-a-number +c 404 -H "$K" $B/api/links/999999 + +c 301 -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15" -e https://referrer.example/x $B/$S +c 301 -H "User-Agent:" -H "Cf-Connecting-Ip: 8.8.8.8" "$B/$S?utm_source=newsletter" +sleep 1; curl -s -H "$K" $B/api/links/$ID/clicks; echo +# newest first: country "US", referer "newsletter", null user_agent; then browser "Safari", os "Mac OS X", referer "referrer.example" +curl -s -o /dev/null -D - -H "Cf-Connecting-Ip: 8.8.8.8" $B/$S | grep -iE 'location|cache-control|x-forwarded-for' +# expect Location: https://example.com, Cache-Control: private, no-store, X-Forwarded-For: 8.8.8.8 + +ID2=$(new https://example.net) +c 422 -X PUT -H "$K" -H "$J" -d '{"url":"https://example.net"}' $B/api/links/$ID # URL taken by another link +c 200 -X PUT -H "$K" -H "$J" -d '{"url":"https://example.org"}' $B/api/links/$ID +S2=$(slug $ID) +c 404 $B/$S # old slug stops working +c 301 $B/$S2 + +O="X-Api-Key: $(docker exec bit-local cli --create-user=Other | sed -E 's/.*X-Api-Key: //')" +c 404 -H "$O" $B/api/links/$ID +c 404 -H "$O" $B/api/links/$ID/clicks +c 403 -X PUT -H "$O" -H "$J" -d '{"url":"https://other.example"}' $B/api/links/$ID +c 403 -X DELETE -H "$O" $B/api/links/$ID + +c 204 -X DELETE -H "$K" $B/api/links/$ID +c 404 $B/$S2 # redirect gone after delete +c 204 -X DELETE -H "$K" $B/api/links/$ID2 +``` + +### Load, throughput, and memory + +```bash +ENV=production shards build --release --no-debug +./bin/benchmark # needs bombardier and sqlite3 (brew install bombardier sqlite3) +``` + +The benchmark seeds `sqlite/data.benchmark.db`, starts `./bin/bit` on port 4001, and sends 100,000 redirect requests over 125 connections with keep-alives off. It fails unless every click reaches SQLite, and prints requests per second, the latency distribution, and the average and peak CPU and memory of the app process. Tune it with `BENCHMARK_REQUESTS`, `BENCHMARK_CONNECTIONS`, and `BENCHMARK_DISABLE_KEEP_ALIVES=false`. + +- Run it three times and use the median run. The first run after a build can be far slower. +- Compare against a build of `master` on the same machine in the same session, not only against the numbers in `docs/SETUP.md`. +- When the README or `docs/SETUP.md` numbers change, record the machine, OS, and Crystal version next to them. + +Container footprint, with a fresh container: + +```bash +docker stats --no-stream bit-local # idle memory +S=$(curl -s -H "X-Api-Key: local-key" -H "Content-Type: application/json" -d '{"url":"https://load.example"}' http://localhost:4000/api/links | sed -E 's/.*"refer":"[^"]*\/([^"]+)".*/\1/') +bombardier -c 125 -n 100000 --disableKeepAlives http://localhost:4000/$S # expect only 3xx +docker stats --no-stream bit-local # memory under load +docker save bit:local | gzip | wc -c # compressed size; README claims under 20 MiB +``` + +Docker Desktop adds a network layer, so container throughput is lower than the native benchmark and not comparable to it. From 3e865f6b70db20ce8a22aaddb75457e43f2dce2f Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 09:55:57 +0200 Subject: [PATCH 6/9] fix(db): migrate with foreign keys off and add rollback - DROP TABLE on a parent cascaded into child rows once foreign keys were enforced, deleting clicks when upgrading a July 2024 database - each file runs in BEGIN IMMEDIATE on one connection and fails only on new foreign key violations, so orphans from unenforced years cannot block boot - down rolls back the newest applied version and refuses an empty Down section --- app/lib/migrator.cr | 120 ++++++++++++++++++++++++++++---------- spec/lib/migrator_spec.cr | 109 ++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 31 deletions(-) diff --git a/app/lib/migrator.cr b/app/lib/migrator.cr index 943c416..4e70543 100644 --- a/app/lib/migrator.cr +++ b/app/lib/migrator.cr @@ -2,7 +2,7 @@ require "db" require "log" module App::Lib - # Applies pending `db/migrations/*.sql` files in version order, one transaction each. Only the `-- +micrate Up` section runs; statements end at a line ending with `;` (as in micrate, `--` always starts a comment, even inside a string literal). State lives in micrate's `micrate_db_version` table, so databases migrated by micrate keep their history. + # Applies `db/migrations/*.sql` files in version order, one transaction each: `up` runs the `-- +micrate Up` section of every pending file, `down` runs the `-- +micrate Down` section of the newest applied one. Statements end at a line ending with `;` (as in micrate, `--` always starts a comment, even inside a string literal). State lives in micrate's `micrate_db_version` table, so databases migrated by micrate keep their history. module Migrator Log = ::Log.for(self) DIR = "db/migrations" @@ -11,47 +11,36 @@ module App::Lib DB.open(url) { |db| up(db) } end - def self.up(db : DB::Database, dir = DIR) - db.exec <<-SQL - CREATE TABLE IF NOT EXISTS micrate_db_version ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - version_id INTEGER NOT NULL, - is_applied INTEGER NOT NULL, - tstamp TIMESTAMP - ) - SQL + # Returns the rolled back file name, or nil when nothing is applied. + def self.down(url : String) : String? + DB.open(url) { |db| down(db) } + end - # The latest row per version says whether it is applied or rolled back. - applied = {} of Int64 => Bool - db.query_each("SELECT version_id, is_applied FROM micrate_db_version ORDER BY id") do |rs| - applied[rs.read(Int64)] = rs.read(Bool) + def self.up(db : DB::Database, dir = DIR) + applied = applied_versions(db) + migrations(dir).each do |version, path| + apply(db, path, version, "Up") unless applied.includes?(version) end + end - Dir.glob("#{dir}/*.sql").sort.each do |path| - name = File.basename(path) - next unless version = name[/^(\d+)_/, 1]?.try(&.to_i64) - next if applied[version]? - - db.transaction do |tx| - statements(File.read(path)).each { |sql| tx.connection.exec(sql) } - tx.connection.exec( - "INSERT INTO micrate_db_version (version_id, is_applied, tstamp) VALUES (?, ?, ?)", - version, true, Time.local - ) - end - Log.info { "Applied #{name}" } - end + def self.down(db : DB::Database, dir = DIR) : String? + applied = applied_versions(db) + return if applied.empty? + version = applied.max + path = migrations(dir).to_h[version]? || raise "No migration file for applied version #{version}" + raise "#{File.basename(path)} is irreversible: its Down section is empty" if statements(File.read(path), "Down").empty? + apply(db, path, version, "Down") end - def self.statements(source : String) : Array(String) + def self.statements(source : String, section = "Up") : Array(String) statements = [] of String buffer = String::Builder.new active = false source.each_line do |line| case directive = line.strip - when "-- +micrate Up" then active = true - when "-- +micrate Down" then active = false + when "-- +micrate Up", "-- +micrate Down" + active = directive == "-- +micrate #{section}" when .starts_with?("-- +micrate") raise "Unsupported migration directive: #{directive}" else @@ -68,5 +57,74 @@ module App::Lib raise "Migration statement missing trailing ';'" unless leftover.empty? statements end + + # Versions whose latest row says applied; a later row with is_applied = 0 means rolled back. + private def self.applied_versions(db) : Set(Int64) + db.exec <<-SQL + CREATE TABLE IF NOT EXISTS micrate_db_version ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + is_applied INTEGER NOT NULL, + tstamp TIMESTAMP + ) + SQL + + latest = {} of Int64 => Bool + db.query_each("SELECT version_id, is_applied FROM micrate_db_version ORDER BY id") do |rs| + latest[rs.read(Int64)] = rs.read(Bool) + end + # Version 0 is the row micrate writes when it creates the table. + latest.select { |version, applied| applied && version > 0 }.keys.to_set + end + + private def self.migrations(dir) : Array({Int64, String}) + Dir.glob("#{dir}/*.sql").sort.compact_map do |path| + File.basename(path)[/^(\d+)_/, 1]?.try { |version| {version.to_i64, path} } + end + end + + private def self.apply(db, path, version, section) + name = File.basename(path) + db.using_connection do |cnn| + # PRAGMA foreign_keys is a no-op inside a transaction, so it is turned off before one starts; otherwise DROP TABLE on a parent table cascades into its child rows. + foreign_keys = cnn.scalar("PRAGMA foreign_keys").as(Int64) + # The macOS system SQLite defaults to ON, which leaves REFERENCES pointing at a table's pre-rename name. + legacy_alter_table = cnn.scalar("PRAGMA legacy_alter_table").as(Int64) + cnn.exec "PRAGMA foreign_keys=OFF" + cnn.exec "PRAGMA legacy_alter_table=OFF" + begin + # IMMEDIATE takes the write lock up front, so a concurrent writer makes it wait for busy_timeout instead of failing mid-migration. + cnn.exec "BEGIN IMMEDIATE" + begin + # Rows orphaned while foreign keys were not enforced are tolerated; a migration may not add more. + before = foreign_key_violations(cnn) + statements(File.read(path), section).each { |sql| cnn.exec(sql) } + added = foreign_key_violations(cnn) - before + raise "#{name}: #{added.size} new foreign key violations, first in #{added.first[0]}" unless added.empty? + cnn.exec( + "INSERT INTO micrate_db_version (version_id, is_applied, tstamp) VALUES (?, ?, ?)", + version, section == "Up", Time.local + ) + cnn.exec "COMMIT" + rescue ex + cnn.exec "ROLLBACK" rescue nil + raise ex + end + ensure + cnn.exec "PRAGMA foreign_keys=#{foreign_keys}" + cnn.exec "PRAGMA legacy_alter_table=#{legacy_alter_table}" + end + end + Log.info { "#{section == "Up" ? "Applied" : "Rolled back"} #{name}" } + name + end + + private def self.foreign_key_violations(cnn) : Set({String, Int64?, String}) + violations = Set({String, Int64?, String}).new + cnn.query_each("PRAGMA foreign_key_check") do |rs| + violations << {rs.read(String), rs.read(Int64?), rs.read(String)} + end + violations + end end end diff --git a/spec/lib/migrator_spec.cr b/spec/lib/migrator_spec.cr index 2aad3f8..3aa3b60 100644 --- a/spec/lib/migrator_spec.cr +++ b/spec/lib/migrator_spec.cr @@ -32,6 +32,7 @@ describe "App::Lib::Migrator" do "CREATE TABLE a (id INTEGER); -- trailing comment", "CREATE INDEX a_id\n ON a (id);", ]) + App::Lib::Migrator.statements(source, "Down").map(&.strip).should eq(["DROP TABLE a;"]) end it "rejects unsupported directives and unterminated statements" do @@ -80,4 +81,112 @@ describe "App::Lib::Migrator" do end end end + + it "upgrades a July 2024 database with foreign keys on without cascading deletes" do + files = Dir.glob("db/migrations/*.sql").sort + with_migrations(files.first(5).to_h { |path| {File.basename(path), File.read(path)} }) do |url, dir| + DB.open("#{url}?foreign_keys=true") do |db| + App::Lib::Migrator.up(db, dir) + db.exec "INSERT INTO users (id, name, api_key) VALUES ('u1', 'User', 'key')" + db.exec "INSERT INTO links (id, user_id, slug, url) VALUES ('l1', 'u1', 'abcd', 'https://example.com')" + 3.times { |i| db.exec "INSERT INTO clicks (id, link_id) VALUES (?, 'l1')", "c#{i}" } + + files.skip(5).each { |path| FileUtils.cp(path, dir) } + App::Lib::Migrator.up(db, dir) + + db.scalar("SELECT COUNT(*) FROM clicks").should eq(3) + db.scalar("PRAGMA foreign_keys").should eq(1) + # Foreign keys must point at the renamed tables, not at links_new or users_new. + db.exec "INSERT INTO clicks (link_id) SELECT id FROM links" + db.scalar("SELECT COUNT(*) FROM clicks").should eq(4) + end + end + end + + it "tolerates existing orphan rows but rejects new foreign key violations" do + with_migrations({ + "1_a.sql" => "-- +micrate Up\nCREATE TABLE p (id INTEGER PRIMARY KEY);\nCREATE TABLE c (p_id INTEGER REFERENCES p (id));\n", + }) do |url, dir| + DB.open("#{url}?foreign_keys=true&max_pool_size=1") do |db| + App::Lib::Migrator.up(db, dir) + db.exec "PRAGMA foreign_keys=OFF" + db.exec "INSERT INTO c (p_id) VALUES (1)" + db.exec "PRAGMA foreign_keys=ON" + + File.write(File.join(dir, "2_b.sql"), "-- +micrate Up\nCREATE TABLE t2 (id INTEGER);\n") + App::Lib::Migrator.up(db, dir) + table_names(db).should eq(["t2"]) + + File.write(File.join(dir, "3_c.sql"), "-- +micrate Up\nINSERT INTO c (p_id) VALUES (2);\n") + expect_raises(Exception, /3_c.sql: 1 new foreign key violations/) { App::Lib::Migrator.up(db, dir) } + db.scalar("SELECT COUNT(*) FROM c").should eq(1) + db.scalar("SELECT COUNT(*) FROM micrate_db_version WHERE version_id = 3").should eq(0) + db.scalar("PRAGMA foreign_keys").should eq(1) + end + end + end + + it "rolls back the newest applied migration" do + with_migrations({ + "1_a.sql" => "-- +micrate Up\nCREATE TABLE t1 (id INTEGER);\n-- +micrate Down\nDROP TABLE t1;\n", + "2_b.sql" => "-- +micrate Up\nCREATE TABLE t2 (id INTEGER);\n-- +micrate Down\nDROP TABLE t2;\n", + }) do |url, dir| + DB.open(url) do |db| + App::Lib::Migrator.up(db, dir) + App::Lib::Migrator.down(db, dir).should eq("2_b.sql") + table_names(db).should eq(["t1"]) + + App::Lib::Migrator.up(db, dir) + table_names(db).should eq(["t1", "t2"]) + + App::Lib::Migrator.down(db, dir) + App::Lib::Migrator.down(db, dir).should eq("1_a.sql") + table_names(db).should be_empty + App::Lib::Migrator.down(db, dir).should be_nil + end + end + end + + it "rolls back the newest real migration and refuses an irreversible one" do + files = Dir.glob("db/migrations/*.sql").sort + indexes = ->(db : DB::Database) { db.query_all("SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('idx_clicks_link_id_id', 'idx_links_user_id_id')", as: String) } + with_migrations(files.to_h { |path| {File.basename(path), File.read(path)} }) do |url, dir| + DB.open("#{url}?foreign_keys=true") do |db| + App::Lib::Migrator.up(db, dir) + indexes.call(db).size.should eq(2) + + App::Lib::Migrator.down(db, dir).should eq(File.basename(files.last)) + indexes.call(db).should be_empty + + App::Lib::Migrator.up(db, dir) + indexes.call(db).size.should eq(2) + + App::Lib::Migrator.down(db, dir) + expect_raises(Exception, /20250319192003_convert_all_tables_text_ids_to_integer.sql is irreversible/) do + App::Lib::Migrator.down(db, dir) + end + end + end + end + + it "never rolls back micrate's version 0 row" do + with_migrations({} of String => String) do |url, dir| + DB.open(url) do |db| + App::Lib::Migrator.up(db, dir) + db.exec "INSERT INTO micrate_db_version (version_id, is_applied) VALUES (0, 1)" + App::Lib::Migrator.down(db, dir).should be_nil + end + end + end + + it "refuses to roll back an applied version without a file" do + with_migrations({"1_a.sql" => "-- +micrate Up\nCREATE TABLE t1 (id INTEGER);\n-- +micrate Down\nDROP TABLE t1;\n"}) do |url, dir| + DB.open(url) do |db| + App::Lib::Migrator.up(db, dir) + db.exec "INSERT INTO micrate_db_version (version_id, is_applied) VALUES (2, 1)" + expect_raises(Exception, /No migration file for applied version 2/) { App::Lib::Migrator.down(db, dir) } + table_names(db).should eq(["t1"]) + end + end + end end From 3613c421903658e12c8955b433e1bc461d155b08 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 09:55:57 +0200 Subject: [PATCH 7/9] fix(db): share connection settings and add busy timeout - the migrator and CLI connected with the bare DATABASE_URL and missed WAL, foreign keys, and busy_timeout - busy_timeout stays at 100 ms because SQLite's busy wait blocks the single-threaded scheduler - migrations run from bit.cr and the CLI instead of the Database class body, so cli --migrate-down does not migrate up first --- app/config/env.cr | 3 +-- app/lib/database.cr | 20 +++++++++----------- bit.cr | 1 + scripts/cli.cr | 14 ++++++++++++++ spec/spec_helper.cr | 2 +- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/app/config/env.cr b/app/config/env.cr index f648676..73ab60c 100644 --- a/app/config/env.cr +++ b/app/config/env.cr @@ -2,8 +2,7 @@ require "log" ENV["ENV"] ||= "development" ENV["APP_URL"] ||= "http://localhost:4000" -ENV["DATABASE_URL"] ||= "sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true -" +ENV["DATABASE_URL"] ||= "sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" {% if env("ENV") != "production" %} require "dotenv" diff --git a/app/lib/database.cr b/app/lib/database.cr index 8822b8a..eda36ee 100644 --- a/app/lib/database.cr +++ b/app/lib/database.cr @@ -8,22 +8,20 @@ module App::Lib Query = Crecto::Repo::Query - config do |conf| - base_url = ENV["DATABASE_URL"] - separator = base_url.includes?("?") ? "&" : "?" - - db_url = base_url + separator + - "&journal_mode=WAL" + - "&synchronous=NORMAL" + # Better performance with reasonable safety - "&foreign_keys=true" + # crystal-sqlite3 runs these as PRAGMAs on every connection; the migrator and the CLI use the same URL. + URL = ENV["DATABASE_URL"] + (ENV["DATABASE_URL"].includes?("?") ? "&" : "?") + + "journal_mode=WAL" + + "&synchronous=NORMAL" + # Better performance with reasonable safety + "&foreign_keys=true" + + # SQLite's busy wait blocks the single-threaded scheduler, so it only covers another process's millisecond writes + "&busy_timeout=100" - conf.uri = db_url + config do |conf| + conf.uri = URL end if ENV["ENV"] == "development" Crecto::DbLogger.set_handler(STDOUT) end - - Migrator.up(ENV["DATABASE_URL"]) end end diff --git a/bit.cr b/bit.cr index a2cf06f..e6b0d22 100644 --- a/bit.cr +++ b/bit.cr @@ -10,6 +10,7 @@ require "./app/services/*" require "./app/routes" add_context_storage_type(App::Models::User) +App::Lib::Migrator.up(App::Lib::Database::URL) App::Services::Cli.setup_admin_user Kemal.run diff --git a/scripts/cli.cr b/scripts/cli.cr index a38e32f..20b4cb1 100644 --- a/scripts/cli.cr +++ b/scripts/cli.cr @@ -2,6 +2,8 @@ require "option_parser" require "../app/services/cli" +App::Lib::Migrator.up(App::Lib::Database::URL) unless ARGV.includes?("--migrate-down") + OptionParser.parse do |parser| parser.on("--create-user=NAME", "Create a new user with the given name") do |name| puts App::Services::Cli.create_user(name) @@ -18,6 +20,17 @@ OptionParser.parse do |parser| exit end + parser.on("--migrate-down", "Roll back the newest applied migration") do + if name = App::Lib::Migrator.down(App::Lib::Database::URL) + puts "Rolled back #{name}" + else + puts "No applied migration to roll back" + end + exit + rescue ex + abort "Rollback failed: #{ex.message}" + end + parser.on("--update-parsers", "Download UA regexes and/or GeoLite2 database") do puts "=== Starting data files update ===" App::Services::Cli.update_uap_regexes @@ -32,6 +45,7 @@ OptionParser.parse do |parser| puts " --create-user=NAME Create a new user with the given name" puts " --list-users List all users" puts " --delete-user=USER_ID Delete a user by ID" + puts " --migrate-down Roll back the newest applied migration" puts " --update-parsers Download all required data files" end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index d7914cf..226baf1 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -14,7 +14,7 @@ Spec.before_suite do File.delete(db_file_path) end - App::Lib::Migrator.up(ENV["DATABASE_URL"]) + App::Lib::Migrator.up(App::Lib::Database::URL) Kemal.config.logging = false end From d135d0ec91a817c0583eb19bee7c4dc654bbeed7 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 09:55:57 +0200 Subject: [PATCH 8/9] test: cover Kemal-raised 400 and 413 responses --- spec/integration/security_spec.cr | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spec/integration/security_spec.cr b/spec/integration/security_spec.cr index 903ae5d..1039e95 100644 --- a/spec/integration/security_spec.cr +++ b/spec/integration/security_spec.cr @@ -26,6 +26,28 @@ describe "API security and edge cases" do response.body.should eq({"error" => "Method Not Allowed"}.to_json) end + it "answers Kemal's own 400 and 413 with JSON" do + headers = HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => create_test_user().api_key.to_s} + + # Auth runs first, so only a valid key reaches Kemal's method check. + bad_method = SpecKemal.process_request(HTTP::Request.new("GET/admin", "/api/ping", headers)) + bad_method.status_code.should eq(400) + bad_method.headers["Connection"].should eq("close") + bad_method.headers["Content-Type"].should eq("application/json") + bad_method.body.should eq({"error" => "Bad Request"}.to_json) + + limit = Kemal.config.max_request_body_size + Kemal.config.max_request_body_size = 16 + begin + post("/api/links", headers: headers, body: {"url" => "https://example.com/too-long"}.to_json) + response.status_code.should eq(413) + response.headers["Content-Type"].should eq("application/json") + response.body.should eq({"error" => "Payload Too Large"}.to_json) + ensure + Kemal.config.max_request_body_size = limit + end + end + it "rejects malformed IDs" do user = create_test_user() headers = HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => user.api_key.to_s} From 07711448d03a13f88d1a85e14bc43d894264b7a7 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Donado Date: Sat, 3 Oct 2026 09:55:57 +0200 Subject: [PATCH 9/9] docs: explain migration rules and manual verification scope --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index da84e0e..79c3727 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ bit is a self-hosted URL shortener: Crystal, Kemal, SQLite. `docs/openapi.yaml` ## Constraints that are easy to miss - `ENV` at compile time decides whether dotenv loads. Build the production binary with `ENV=production`. Any other build loads `.env.` at runtime (default `.env.development`), and that file must exist. -- Migrations: only the `-- +micrate Up` section runs, a statement ends at a line ending with `;`, and any other `-- +micrate` directive raises. A failing migration stops the app at boot. +- Migrations: `bit.cr` and every `cli` command except `--migrate-down` apply pending `-- +micrate Up` sections first. `cli --migrate-down` runs the `-- +micrate Down` section of the newest applied version and refuses an empty one (`20250319192003` is irreversible). The current image re-applies a rolled-back file on its next start, so a rollback only sticks when an older image is deployed next. A statement ends at a line ending with `;`, and any other `-- +micrate` directive raises. Each file runs in `BEGIN IMMEDIATE` with foreign keys off, so a `DROP TABLE` on a parent table does not cascade, and it fails if it adds a foreign key violation. Orphan rows from before foreign keys were enforced are tolerated, except that `20250319192003` cannot convert orphan links or clicks. A failing migration stops the app at boot. +- `App::Lib::Database::URL` appends `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=true`, and `busy_timeout=100` to `DATABASE_URL`; the app, the migrator, and the CLI all connect with it, and crystal-sqlite3 runs them as PRAGMAs on every connection. A key already present in `DATABASE_URL` wins, because crystal-sqlite3 reads the first value. WAL mode is what makes the redirect path fast. Foreign keys enforce the `ON DELETE CASCADE` rules. SQLite's busy wait blocks the single-threaded scheduler, so the timeout stays short. - Kemal 1.14 discards a response body written before an error. Raise an `App::*Exception` from `app/lib/errors.cr`; do not print a body and then raise. - Every API error is JSON. Auth runs before routing, so an unmatched route returns 401 without a key and `404 {"error":"Resource not found"}` with one. A wrong method returns 405 with an `Allow` header. - Updating a link's URL regenerates its slug; the old short URL stops working. @@ -36,6 +37,8 @@ Run from the repository root, cheapest first: The specs cover most status codes in-process. The steps below check the published image over a real network, GeoIP, headers, load, and memory. Use them when a change touches a route, the Dockerfile, a dependency, or the redirect path. +The endpoint walkthrough repeats status codes the specs already assert, on purpose. The specs call the handler chain in memory through spec-kemal, with the development build, macOS SQLite, and no socket. The walkthrough sends the same requests to the production binary inside the Docker image, so it also catches what only exists there: the `alpine` runtime libraries, the GeoLite2 file and user-agent regexes packaged in the image, the compile-time `ENV=production` branch, the SQLite settings on a real file, and headers over HTTP. A status code that passes in the specs and fails in the walkthrough points at the image or the build, not the code. + ### Run with Docker ```bash