Drop App wrapper for conventional Domus::DB access
The Domus.config writer and Web.opts[:app] injection made every entry
point replay an order-sensitive boot. A plain Domus::DB plus env-driven
config restores the conventional Roda/Sequel shape.

Assisted-by: Claude Opus 4.8 via Claude Code
change tqpozqsyovylkmvzzkpwwlpnnvlllxps
commit 5b8b0d7c718c2703fbe3434734394f4e74d33d85
author Alpha Chen <alpha@kejadlen.dev>
date
parent mqtzukok
diff --git a/Rakefile b/Rakefile
index 2a9ea7c..d8a6709 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,15 +1,15 @@
 # Put lib/ on the load path so plain requires and Sequel's extension loader
 # (db.extension(:sole) -> require "sequel/extensions/sole") resolve. The test
-# task adds lib/ for the spawned test run, but this Rakefile also builds an App
-# at load time for the db: tasks, which runs in the bare rake process.
+# task adds lib/ for the spawned test run, but this Rakefile also opens the
+# database connection at load time for the db: tasks, which runs in the bare
+# rake process.
 $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
 
 require "minitest/test_task"
-require "domus/app"
+require "domus/db"
 require "sequel/extensions/migration"
 
-DOMUS_APP = Domus::App.new
-DB = DOMUS_APP.db
+DB = Domus::DB
 
 desc "Create bundler binstubs (runs when Gemfile.lock changes)"
 file ".direnv/.bundled" => ["Gemfile.lock"] do
@@ -61,7 +61,7 @@ namespace :db do
 
   desc "Seed the database with development data"
   task :seed do
-    require "seeds"
-    puts Domus::Seeds.call(DOMUS_APP) ? "Seeded." : "Already seeded."
+    require "domus/seeds"
+    puts Domus::Seeds.call ? "Seeded." : "Already seeded."
   end
 end
diff --git a/Steepfile b/Steepfile
index bbed059..9f86720 100644
--- a/Steepfile
+++ b/Steepfile
@@ -1,7 +1,7 @@
 target :lib do
   signature "sig"
   check "lib/domus/config.rb"
-  check "lib/domus/app.rb"
+  check "lib/domus/db.rb"
   check "lib/domus/models.rb"
   check "lib/domus/relative_time.rb"
   check "lib/domus/views/layout.rb"
diff --git a/config.ru b/config.ru
index 8f5de56..3ada7ed 100644
--- a/config.ru
+++ b/config.ru
@@ -2,17 +2,13 @@
 # (Sequel::Model.plugin :sole -> require "sequel/plugins/sole") resolve.
 $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
 
-require "domus/app"
-require "domus/web"
+require "domus/db"
 require "sequel/extensions/migration"
 
-app = Domus::App.new
-Sequel::Migrator.run(app.db, "db/migrate") unless Dir.empty?("db/migrate")
+Sequel::Migrator.run(Domus::DB, "db/migrate") unless Dir.empty?("db/migrate")
 
 # Models reflect on their tables at class-load time, so they must be required
-# only after migrations have run.
-require "domus/models"
-
-Domus::Web.opts[:app] = app
+# only after migrations have run. Requiring the web app pulls them in.
+require "domus/web"
 
 run Domus::Web
diff --git a/lib/domus/app.rb b/lib/domus/app.rb
deleted file mode 100644
index c5d2b35..0000000
--- a/lib/domus/app.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# rbs_inline: enabled
-
-require "sequel"
-require_relative "config"
-
-module Domus
-  class App
-    attr_reader :config, :db
-
-    # : (Config) -> void
-    def initialize(config = Domus.config)
-      @config = config
-      @db = Sequel.sqlite(config.database_url)
-      Sequel::Model.db = @db
-    end
-
-    # : (Hash[Symbol, untyped]) -> Pathname
-    def file_path(record)
-      uploads_root / "#{record[:id]}#{record[:extension]}"
-    end
-
-    # : () -> Pathname
-    def uploads_root = config.storage_path / "uploads"
-  end
-end
diff --git a/lib/domus/config.rb b/lib/domus/config.rb
index 3c68f29..c3a7d6e 100644
--- a/lib/domus/config.rb
+++ b/lib/domus/config.rb
@@ -14,15 +14,18 @@ module Domus
       database_url: ENV.fetch("DATABASE_URL") { "db/domus.db" },
       storage_path: Pathname(ENV.fetch("STORAGE_PATH") { "storage" }),
     )
+
+    # Where stored upload blobs live on disk, derived from the storage root.
+    #: () -> Pathname
+    def uploads_root = storage_path / "uploads"
   end
 
   class << self
     # The process-wide config, resolved from the environment the first time
     # it's read. Loaded code reads this single instance: Web's :static plugin
-    # needs the storage path at load, and App stores files there. Tests assign
-    # a Config here before requiring the app to point it at a temp dir.
-    attr_writer :config
-
+    # needs the storage path at load, and uploads are stored under it. Tests
+    # point it at a temp dir by setting DATABASE_URL / STORAGE_PATH before the
+    # config is first read.
     #: () -> Config
     def config = @config ||= Config.env
   end
diff --git a/lib/domus/db.rb b/lib/domus/db.rb
new file mode 100644
index 0000000..f97ed75
--- /dev/null
+++ b/lib/domus/db.rb
@@ -0,0 +1,13 @@
+# rbs_inline: enabled
+
+require "sequel"
+require_relative "config"
+
+module Domus
+  # The process-wide database connection, opened once from the environment
+  # config. Requiring this file connects as a side effect, so models and the
+  # web app can require it and reach a live Sequel::Database via Domus::DB.
+  DB = Sequel.sqlite(config.database_url) #: Sequel::Database
+end
+
+Sequel::Model.db = Domus::DB
diff --git a/lib/domus/models.rb b/lib/domus/models.rb
index 1a7513a..91df0a0 100644
--- a/lib/domus/models.rb
+++ b/lib/domus/models.rb
@@ -1,6 +1,7 @@
 # rbs_inline: enabled
 
 require "sequel"
+require_relative "db"
 
 Sequel::Model.plugin :sole
 
@@ -33,6 +34,10 @@ module Domus
       validates_presence :extension
       validates_includes IMAGE_EXTENSIONS, :extension
     end
+
+    # Where this upload's blob lives on disk.
+    #: () -> Pathname
+    def file_path = Domus.config.uploads_root / "#{id}#{extension}"
   end
 
   class Document < Sequel::Model
diff --git a/lib/domus/seeds.rb b/lib/domus/seeds.rb
index 088e9f4..7d1252a 100644
--- a/lib/domus/seeds.rb
+++ b/lib/domus/seeds.rb
@@ -3,6 +3,7 @@
 require "fileutils"
 require "open-uri"
 require "pathname"
+require_relative "models"
 
 module Domus
   # Development and test seed data. Populates a few sample assets so a fresh
@@ -80,21 +81,20 @@ module Domus
     # Seeds the database when it's empty, returning true when it inserted data
     # and false when assets already exist. The empty check keeps repeated dev
     # runs and CI idempotent.
-    # : (App) -> bool
-    def self.call(app)
-      db = app.db
-      return false unless db[:assets].empty?
+    # : () -> bool
+    def self.call
+      return false unless Domus::DB[:assets].empty?
 
       # Warm the cache outside the transaction so a slow download doesn't hold
       # the write lock open.
       ASSETS.flat_map(&:photos).uniq.each(&:fetch)
 
-      db.transaction do
+      Domus::DB.transaction do
         ASSETS.each do |seed_asset|
           asset_record = Domus::Asset.create(name: seed_asset.name, description: seed_asset.description)
           seed_asset.photos.each do |photo|
             upload = Domus::Upload.create(extension: ".jpg")
-            dest = app.file_path(id: upload.id, extension: ".jpg")
+            dest = upload.file_path
             FileUtils.mkdir_p(dest.dirname)
             FileUtils.cp(photo.cache_path, dest)
             asset_record.add_upload(upload)
diff --git a/lib/domus/web.rb b/lib/domus/web.rb
index 5a23542..d9801f2 100644
--- a/lib/domus/web.rb
+++ b/lib/domus/web.rb
@@ -2,7 +2,7 @@
 
 require "roda"
 require "fileutils"
-require_relative "config"
+require_relative "models"
 require_relative "views/layout"
 require_relative "views/home"
 require_relative "views/asset"
@@ -25,7 +25,7 @@ module Domus
 
     # Serve stored uploads (storage/uploads/{id}{ext}) at /uploads/ straight
     # off disk. The :static plugin needs a concrete root at load time, so it
-    # reads the shared Domus.config (the same instance App uses). Uploads are
+    # reads the shared Domus.config resolved from the environment. Uploads are
     # immutable once stored, so they cache indefinitely.
     plugin :static, ["/uploads/"],
       root: Domus.config.storage_path.to_s,
@@ -90,11 +90,6 @@ module Domus
 
     private
 
-    # : () -> App
-    def app = opts.fetch(:app)
-    # : () -> Sequel::Database
-    def db = app.db
-
     # Persists an uploaded image, raising ClientError when the upload is
     # rejected so the error_handler plugin can render the right status.
     # : (Hash[String, untyped]) -> void
@@ -111,9 +106,9 @@ module Domus
 
       asset_names = Array(params["asset_names"]).flatten.map(&:strip).reject(&:empty?)
 
-      db.transaction do
+      DB.transaction do
         upload_record = Upload.create(extension: ext)
-        dest = app.file_path(id: upload_record.id, extension: ext)
+        dest = upload_record.file_path
         FileUtils.mkdir_p(::File.dirname(dest))
         FileUtils.cp(upload[:tempfile].path, dest)
         asset_names.each { |name| Asset.create(name:).add_upload(upload_record) }
diff --git a/sig/generated/domus/app.rbs b/sig/generated/domus/app.rbs
deleted file mode 100644
index d0bd611..0000000
--- a/sig/generated/domus/app.rbs
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated from lib/domus/app.rb with RBS::Inline
-
-module Domus
-  class App
-    attr_reader config: untyped
-
-    attr_reader db: untyped
-
-    # : (Config) -> void
-    def initialize: (?untyped config) -> untyped
-
-    # : (Hash[Symbol, untyped]) -> Pathname
-    def file_path: (untyped record) -> untyped
-
-    # : () -> Pathname
-    def uploads_root: () -> untyped
-  end
-end
diff --git a/sig/generated/domus/config.rbs b/sig/generated/domus/config.rbs
index 7a54d3c..24aa58f 100644
--- a/sig/generated/domus/config.rbs
+++ b/sig/generated/domus/config.rbs
@@ -17,14 +17,17 @@ module Domus
   class Config
     # : () -> Config
     def self.env: () -> Config
+
+    # Where stored upload blobs live on disk, derived from the storage root.
+    # : () -> Pathname
+    def uploads_root: () -> Pathname
   end
 
   # The process-wide config, resolved from the environment the first time
   # it's read. Loaded code reads this single instance: Web's :static plugin
-  # needs the storage path at load, and App stores files there. Tests assign
-  # a Config here before requiring the app to point it at a temp dir.
-  attr_writer config: untyped
-
+  # needs the storage path at load, and uploads are stored under it. Tests
+  # point it at a temp dir by setting DATABASE_URL / STORAGE_PATH before the
+  # config is first read.
   # : () -> Config
   def self.config: () -> Config
 end
diff --git a/sig/generated/domus/db.rbs b/sig/generated/domus/db.rbs
new file mode 100644
index 0000000..42dbe11
--- /dev/null
+++ b/sig/generated/domus/db.rbs
@@ -0,0 +1,8 @@
+# Generated from lib/domus/db.rb with RBS::Inline
+
+module Domus
+  # The process-wide database connection, opened once from the environment
+  # config. Requiring this file connects as a side effect, so models and the
+  # web app can require it and reach a live Sequel::Database via Domus::DB.
+  DB: Sequel::Database
+end
diff --git a/sig/generated/domus/models.rbs b/sig/generated/domus/models.rbs
index 74c2337..ab44f58 100644
--- a/sig/generated/domus/models.rbs
+++ b/sig/generated/domus/models.rbs
@@ -9,6 +9,10 @@ module Domus
     IMAGE_EXTENSIONS: untyped
 
     def validate: () -> untyped
+
+    # Where this upload's blob lives on disk.
+    # : () -> Pathname
+    def file_path: () -> Pathname
   end
 
   class Document < Sequel::Model
diff --git a/sig/generated/domus/seeds.rbs b/sig/generated/domus/seeds.rbs
index 7adbf59..7aa57e2 100644
--- a/sig/generated/domus/seeds.rbs
+++ b/sig/generated/domus/seeds.rbs
@@ -62,8 +62,8 @@ module Domus
     # Seeds the database when it's empty, returning true when it inserted data
     # and false when assets already exist. The empty check keeps repeated dev
     # runs and CI idempotent.
-    # : (App) -> bool
-    def self.call: (untyped app) -> untyped
+    # : () -> bool
+    def self.call: () -> untyped
 
     # The XDG cache directory for downloaded seed photos.
     # : () -> Pathname
diff --git a/sig/generated/domus/web.rbs b/sig/generated/domus/web.rbs
index cc235a7..a8bc69a 100644
--- a/sig/generated/domus/web.rbs
+++ b/sig/generated/domus/web.rbs
@@ -15,12 +15,6 @@ module Domus
 
     private
 
-    # : () -> App
-    def app: () -> untyped
-
-    # : () -> Sequel::Database
-    def db: () -> untyped
-
     # Persists an uploaded image, raising ClientError when the upload is
     # rejected so the error_handler plugin can render the right status.
     # : (Hash[String, untyped]) -> void
diff --git a/test/test_app.rb b/test/test_app.rb
index 5695fe3..9a7e609 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -10,18 +10,16 @@ class TestApp < Minitest::Test
     Domus::Web
   end
 
-  def domus = Domus::Web.opts.fetch(:app)
-
   # Every test starts from the shared seed baseline, so dev and the suite
   # exercise the same Domus::Seeds path. Tests that need a clean slate call
   # #wipe; tests that assert on counts compare deltas around the action.
   def setup
     wipe
-    Domus::Seeds.call(domus)
+    Domus::Seeds.call
   end
 
   def wipe
-    domus.db[:asset_attachments].delete
+    Domus::DB[:asset_attachments].delete
     Asset.dataset.delete
     Upload.dataset.delete
   end
@@ -153,7 +151,7 @@ class TestApp < Minitest::Test
     assert_equal before + 1, Upload.count
     upload_record = Upload.order(:id).last
     assert_equal ".png", upload_record.extension
-    assert_equal "fake-png-bytes", File.read(domus.file_path(id: upload_record.id, extension: upload_record.extension))
+    assert_equal "fake-png-bytes", File.read(upload_record.file_path)
   end
 
   def test_upload_without_file_is_rejected
diff --git a/test/test_helper.rb b/test/test_helper.rb
index a1e5d8b..85d0f23 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -2,27 +2,21 @@ require "minitest/autorun"
 require "sequel"
 require "sequel/extensions/migration"
 require "fileutils"
-require "pathname"
-
-require "domus/app"
+require "tmpdir"
 
 storage = Dir.mktmpdir("domus-test")
 at_exit { FileUtils.rm_rf(storage) }
 
-# Web reads Domus.config at load to configure static upload serving, so inject
-# a temp-dir config before requiring it.
-Domus.config = Domus::Config.new(database_url: ":memory:", storage_path: Pathname(storage))
+# Drive config through the environment, the same way production does, before
+# anything reads Domus.config: an in-memory database and a temp storage dir.
+ENV["DATABASE_URL"] = ":memory:"
+ENV["STORAGE_PATH"] = storage
 
-app = Domus::App.new
+require "domus/db"
 migrate_dir = File.expand_path("../db/migrate", __dir__)
-Sequel::Migrator.run(app.db, migrate_dir) unless Dir.empty?(migrate_dir)
+Sequel::Migrator.run(Domus::DB, migrate_dir) unless Dir.empty?(migrate_dir)
 
 # Models reflect on their tables at class-load time, so they must be required
-# only after migrations have run. App#initialize sets Sequel::Model.db, so
-# models can be defined once the app exists.
-require "domus/models"
-
+# only after migrations have run. Requiring the web app pulls them in.
 require "domus/web"
 require "domus/seeds"
-
-Domus::Web.opts[:app] = app