yamine 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,27 +116,29 @@ module Yamine
116
116
  say opts, "yamine (#{service})"
117
117
  say opts, "--"
118
118
 
119
- # Per-worktree database: one database per directory so concurrent
120
- # agents never share tables. Resolved once, injected into every
121
- # process as DATABASE_URL, created on demand with the app's own
122
- # schema-load command. SQLite needs nothing (relative paths
123
- # already isolate); the branch plays no part (dirs are stable,
124
- # branches hop).
119
+ # Per-worktree databases: one isolated set per directory so
120
+ # concurrent agents never share tables — every database the app
121
+ # declares (a Rails multi-database app may have five). The
122
+ # claim records the whole set; setup creates what's missing,
123
+ # loads schemas on first creation, and hands back the env to
124
+ # inject into every process. SQLite needs nothing (relative
125
+ # paths already isolate); the branch plays no part (dirs are
126
+ # stable, branches hop).
125
127
  db_name = Database.name_for(Dir.pwd, env: rails_env,
126
128
  state_dir: ctx.store.dir)
127
- db_created, db_url = setup_database(ctx, resolved, db_name, events: events)
128
- events&.note("[db] #{db_name}#{db_created ? " (created)" : ""}") if db_url
129
+ db_created, db_env = setup_database(ctx, resolved, db_name, events: events)
130
+ events&.note("[db] #{db_name}#{db_created ? " (created)" : ""}") if db_env && !db_env.empty?
129
131
 
130
132
  with_wait = !opts[:no_wait]
131
- spawn_plan = collect_spawns(ctx, runner, resolved, opts, db_url, children)
133
+ spawn_plan = collect_spawns(ctx, runner, resolved, opts, db_env, children)
132
134
 
133
135
  begin
134
136
  if with_wait
135
137
  boot_concurrent(ctx, runner, resolved, opts, spawn_plan, children,
136
- routes_registered, db_url, db_name, db_created, events: events)
138
+ routes_registered, db_env, db_name, db_created, events: events)
137
139
  else
138
140
  boot_sequential(ctx, runner, resolved, opts, spawn_plan, children,
139
- routes_registered, db_url, events: events)
141
+ routes_registered, db_env, events: events)
140
142
  end
141
143
  rescue StandardError
142
144
  # A raise mid-boot (route conflict, quota, unexpected error)
@@ -198,14 +200,14 @@ module Yamine
198
200
  # (no route to wait on), HTTP processes become a spawn plan the
199
201
  # boot mode executes. Compound-line refusal happens here so both
200
202
  # paths share one rule.
201
- def collect_spawns(ctx, runner, resolved, opts, db_url, children)
203
+ def collect_spawns(ctx, runner, resolved, opts, db_env, children)
202
204
  plan = []
203
205
  processes = resolved.processes
204
206
  processes.each do |proc_name, entry|
205
207
  hostname = Resolver.hostname_for(resolved, proc_name)
206
208
  if entry["proxy"] == false
207
209
  boot_background(ctx, runner, resolved, proc_name, entry, opts,
208
- children, db_url)
210
+ children, db_env)
209
211
  next
210
212
  end
211
213
  next unless hostname
@@ -226,19 +228,20 @@ module Yamine
226
228
 
227
229
  # Default: sequential boot, route registered per process as before.
228
230
  def boot_sequential(ctx, runner, resolved, opts, plan, children,
229
- routes_registered, db_url, events: nil)
231
+ routes_registered, db_env, events: nil)
230
232
  plan.each do |item|
231
233
  say opts, " [#{item[:name]}] #{item[:url]}"
232
234
  # Always allow the proxied hostname in Rails dev (Rails ignores
233
235
  # this env var when not a Rails app — safe for every framework).
234
- # DATABASE_URL points at this worktree's own database; non-Ruby
235
- # frameworks that honor it get isolation for free, others ignore it.
236
- # boot_run registers the route + backend sidecar itself
237
- # (register: true default); no separate adopt step needed.
236
+ # db_env points every process at this worktree's own databases;
237
+ # non-Ruby frameworks that honor DATABASE_URL get isolation for
238
+ # free, others ignore it. boot_run registers the route + backend
239
+ # sidecar itself (register: true default); no separate adopt
240
+ # step needed.
238
241
  app = runner.boot_run(name: item[:name], hostname: item[:hostname],
239
242
  url: item[:url], dir: Dir.pwd, command: item[:command],
240
243
  port: item[:port], force: opts[:force],
241
- rails_dev_host: item[:hostname], database_url: db_url,
244
+ rails_dev_host: item[:hostname], database_env: db_env,
242
245
  subdomains: resolved.subdomains,
243
246
  extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
244
247
  routes_registered << { hostnames: item[:hostnames], app: app }
@@ -253,14 +256,14 @@ module Yamine
253
256
  # never leak into the proxy. A backend that dies before its route
254
257
  # registers is cleaned up like any other failed process.
255
258
  def boot_concurrent(ctx, runner, resolved, opts, plan, children,
256
- routes_registered, db_url, db_name, db_created, events: nil)
259
+ routes_registered, db_env, db_name, db_created, events: nil)
257
260
  apps = {}
258
261
  plan.each do |item|
259
262
  say opts, " [#{item[:name]}] #{item[:url]}"
260
263
  app = runner.spawn_http(name: item[:name], hostname: item[:hostname],
261
264
  url: item[:url], dir: Dir.pwd, command: item[:command],
262
265
  port: item[:port], rails_dev_host: item[:hostname],
263
- database_url: db_url, force: opts[:force],
266
+ database_env: db_env, force: opts[:force],
264
267
  extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
265
268
  # Tracked before registration so any later failure (a raise
266
269
  # during adopt, a crash while another process waits) can
@@ -285,7 +288,7 @@ module Yamine
285
288
  end
286
289
  end
287
290
  finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
288
- children, db_name, db_url, db_created, json: opts[:json])
291
+ children, db_name, db_env, db_created, json: opts[:json])
289
292
  end
290
293
 
291
294
  # --wait epilogue. Success: the summary line and, with --json, the
@@ -294,13 +297,14 @@ module Yamine
294
297
  # and removed, the failed phase + that process's log tail is
295
298
  # reported, and start exits 1 — no half-booted routes left behind.
296
299
  def finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
297
- children, db_name, db_url, db_created, json: false)
300
+ children, db_name, db_env, db_created, json: false)
298
301
  urls = apps.transform_values { |slot| slot[:item][:url] }
299
302
  summary = "ready: #{urls.map { |n, u| "#{n}=#{u}" }.join(" ")}"
300
303
 
301
304
  if failed.empty?
302
305
  payload = Yamine::WaitPayload.success(resolved, wait_result, urls: urls,
303
- db_name: db_name, db_url: db_url, created: db_created)
306
+ db_name: db_name, db_url: db_env && db_env["DATABASE_URL"],
307
+ created: db_created)
304
308
  if json
305
309
  $stderr.puts summary
306
310
  puts JSON.generate(payload)
@@ -349,10 +353,10 @@ module Yamine
349
353
 
350
354
  # A background process (proxy: false) is spawned, logged, and
351
355
  # supervised exactly like an HTTP one — it just gets no route.
352
- def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
356
+ def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_env)
353
357
  cmd = entry["cmd"].to_s
354
358
  if cmd.match?(Yamine::Procfile::COMPOUND)
355
- $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — split it into separate processes, or wrap in a script and point cmd: at it"
359
+ $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — split it into separate processes, or wrap it in a script and point cmd: at it"
356
360
  return
357
361
  end
358
362
  port = opts[:app_port] || Ports.find_free
@@ -360,7 +364,7 @@ module Yamine
360
364
  app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
361
365
  url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
362
366
  force: opts[:force], rails_dev_host: nil, register: false,
363
- database_url: db_url,
367
+ database_env: db_env,
364
368
  extra_env: build_env(resolved, entry, proc_name: proc_name))
365
369
  children << { name: proc_name, pid: app.pid }
366
370
  say opts, " [#{proc_name}] background (pid #{app.pid})"
@@ -447,23 +451,41 @@ module Yamine
447
451
  ENV.fetch("RAILS_ENV", "development")
448
452
  end
449
453
 
450
- # Resolve this worktree's database, create it if missing, and run
451
- # the app's schema-load command on first creation. Returns
452
- # [created, url]: created is true only when this boot created the
454
+ # Resolve this checkout's databases, create what's missing, and
455
+ # run the app's schema-load command on first creation. Returns
456
+ # [created, env]: created is true only when this boot created a
453
457
  # database (so --wait can report provenance without a second
454
- # probe), url is nil when there is nothing to inject.
458
+ # probe), env is the hash of variables to inject into every
459
+ # process (DATABASE_URL for primary, NAME_DATABASE_URL for the
460
+ # rest — Rails' own convention) or nil when there is nothing to
461
+ # inject.
455
462
  #
456
- # Template sources: ENV DATABASE_URL, then config env.clear
457
- # DATABASE_URL (top-level env, falling back to per-process env
458
- # for configs written before top-level env existed). Injected
459
- # DATABASE_URL overrides database.yml/credentials at connection
460
- # time (ActiveRecord merges env over file config), so credentials
461
- # apps work — they just need the template declared in config.
463
+ # Three shapes, decided in order:
464
+ # * a multi-database claim (worktree recorded at add time) —
465
+ # the whole set is ensured and injected;
466
+ # * the main checkout with no template — nothing to isolate:
467
+ # the app's own databases ARE its databases. Silence, not
468
+ # the old false alarm;
469
+ # * a DATABASE_URL template (ENV or config env.clear) — the
470
+ # single-database path, unchanged. Injected DATABASE_URL
471
+ # overrides database.yml/credentials at connection time
472
+ # (ActiveRecord merges env over file config), so credentials
473
+ # apps work — they just need the template declared in config.
462
474
  def setup_database(ctx, resolved, db_name, events: nil)
463
475
  return [false, nil] if db_opted_out?(resolved)
464
476
 
477
+ claim = Database.multi_claim(ctx.store.dir, db_name)
478
+ return setup_multidb(resolved, claim, events: events) if claim
479
+
465
480
  template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
466
481
  if template.nil? || template.strip.empty?
482
+ if Database.main_checkout?(Dir.pwd) && rails_app?
483
+ Readiness.phase(:db, "main checkout — the app's own databases", sink: events) do
484
+ "unsuffixed; worktrees get isolated sets at `yamine worktree add`"
485
+ end
486
+ return [false, nil]
487
+ end
488
+
467
489
  warn_missing_template(resolved)
468
490
  return [false, nil]
469
491
  end
@@ -483,15 +505,258 @@ module Yamine
483
505
  end
484
506
  created = db_event.detail.to_s.start_with?("created")
485
507
 
508
+ db_env = { "DATABASE_URL" => url }
486
509
  schema_event = Readiness.phase(:schema, "load #{db_name}", sink: events) do
487
510
  next "skipped (already exists)" unless created
488
511
 
489
- run_schema_load(resolved, url)
512
+ run_schema_load(resolved, db_env)
490
513
  end
491
514
  unless schema_event.status == "ok"
492
515
  $stderr.puts " [db] WARNING: schema load #{schema_event.status}: #{schema_event.detail}."
493
516
  end
494
- [created, url]
517
+ [created, db_env]
518
+ end
519
+
520
+ # Multi-database worktree: the claim carries every database name
521
+ # plus the app's own URL for each. Create what's missing, hand
522
+ # back the env for the whole set, load schemas once on first
523
+ # creation.
524
+ #
525
+ # On creation failure the env is STILL handed back. A boot
526
+ # pointed at missing databases fails loudly at connect time,
527
+ # while a boot falling back to the shared base databases would
528
+ # silently run another checkout's tables — the exact bug
529
+ # isolation exists to kill. Loud and broken beats quiet and
530
+ # wrong.
531
+ def setup_multidb(resolved, claim, events: nil)
532
+ names = claim["names"]
533
+ bases = claim["bases"] || {}
534
+ env = db_env_for(names, bases)
535
+ created = false
536
+
537
+ db_event = Readiness.phase(:db, "ensure #{names.size} databases", sink: events) do
538
+ missing = names.reject { |cfg, actual| Database.exists?(actual, base_for(bases, cfg)) }
539
+ failed = missing.filter_map do |cfg, actual|
540
+ actual unless Database.ensure_exists(actual, base_for(bases, cfg))
541
+ end
542
+ raise "could not create #{failed.join(', ')} — is postgres/mysql running?" unless failed.empty?
543
+
544
+ created = missing.any?
545
+ created ? "created #{missing.size}" : "already exist"
546
+ end
547
+ unless db_event.status == "ok"
548
+ $stderr.puts " [db] WARNING: #{db_event.detail}."
549
+ $stderr.puts " The isolated URLs stay in effect — the app cannot connect until they exist."
550
+ $stderr.puts " Start the database server and re-run `yamine start`."
551
+ end
552
+
553
+ if created
554
+ schema_event = Readiness.phase(:schema, "load #{names.size} schemas", sink: events) do
555
+ run_schema_load(resolved, env)
556
+ end
557
+ unless schema_event.status == "ok"
558
+ $stderr.puts " [db] WARNING: schema load #{schema_event.status}: #{schema_event.detail}."
559
+ end
560
+ end
561
+ [created, env]
562
+ end
563
+
564
+ # The env for a whole claim: DATABASE_URL for primary (the name
565
+ # every non-Rails framework reads too), NAME_DATABASE_URL per
566
+ # config — Rails resolves those over database.yml all by
567
+ # themselves, so even an app whose database.yml never heard of
568
+ # yamine gets full isolation in supervised processes.
569
+ def db_env_for(names, bases)
570
+ names.each_with_object({}) do |(cfg, actual), env|
571
+ url = Database.url_for(actual, base_for(bases, cfg))
572
+ next unless url
573
+
574
+ env["DATABASE_URL"] = url if cfg == "primary"
575
+ env["#{cfg.upcase}_DATABASE_URL"] = url
576
+ end
577
+ end
578
+
579
+ # All of an app's databases live on one server in every layout
580
+ # yamine has seen; a per-config base with a missing entry falls
581
+ # back to the first.
582
+ def base_for(bases, cfg)
583
+ bases[cfg] || bases.values.first
584
+ end
585
+
586
+ # Worktree-add (and `yamine db create` in a worktree): turn a
587
+ # probe of the app's real databases into a provisioned,
588
+ # self-describing claim. Names come from the app, the suffix
589
+ # from the directory; claim + .env files are written BEFORE any
590
+ # database is created, so a failure mid-way leaves a state the
591
+ # next `yamine db create` or boot can finish from — never a
592
+ # half-named set with no record of it.
593
+ #
594
+ # Returns the final names; raises Error only for conditions the
595
+ # user must fix (name budget, server down, .env breaking the
596
+ # app's boot) — those keep the worktree and the claim.
597
+ def provision_multidb(ctx, dir, claim_key, rows)
598
+ env_name = rails_env
599
+ # Re-provisioning a worktree whose .env exists: the app (via
600
+ # dotenv) resolves SUFFIXED names — strip the claim's stored
601
+ # suffix before fitting, or this pass would double it and
602
+ # yamine would provision a set the app never connects to.
603
+ prior = Database.load_map(ctx.store.dir)[claim_key]
604
+ prior_suffix = prior.is_a?(Hash) ? prior["suffix"] : nil
605
+ rows = strip_suffix(rows, prior_suffix)
606
+ # The test environment's database joins the claim: worktree
607
+ # tests run against it (.env.test carries its URL), and
608
+ # teardown must drop it — a leaked test database per worktree
609
+ # is exactly the leftover this exists to prevent. Test configs
610
+ # are flat (implicitly named "primary"), so the claim key is
611
+ # namespaced under "test" to not collide with the dev primary.
612
+ # A test env the app cannot answer (missing test credentials,
613
+ # say) degrades to a dev-only claim; `yamine db create` heals.
614
+ test_rows = Probe.rails_databases(dir, env: "test")
615
+ test_rows = strip_suffix(test_rows, prior_suffix)
616
+ test_pairs = (test_rows && Probe.server_backed(test_rows) || [])
617
+ .map { |r| [env_namespaced_key(r["name"], "test"), r] }
618
+
619
+ bases = rows.to_h { |r| [r["name"], r["url"]] }
620
+ .merge(test_pairs.to_h { |key, r| [key, r["url"]] })
621
+ suffix = Database.suffix_for(claim_key, env_name)
622
+ all_bases = rows.map { |r| r["database"] } + test_pairs.map { |_, r| r["database"] }
623
+ fitted = Database.fit_suffix(suffix, dir, all_bases)
624
+ unless fitted
625
+ longest = all_bases.max_by(&:to_s)&.bytesize
626
+ raise Error,
627
+ "a database name of #{longest} bytes leaves no room for a worktree " \
628
+ "suffix (#{Database::MAX_IDENTIFIER_BYTES}-byte limit): #{all_bases.inspect}\n" \
629
+ " Shorten the base names in the app's database configuration."
630
+ end
631
+ names = rows.to_h { |r| [r["name"], Database.multiname(r["database"], fitted)] }
632
+ .merge(test_pairs.to_h { |key, r| [key, Database.multiname(r["database"], fitted)] })
633
+
634
+ map = Database.load_map(ctx.store.dir)
635
+ map[claim_key] = (map[claim_key] || {}).merge(
636
+ "dir" => File.expand_path(dir),
637
+ "claimed_at" => Time.now.utc.iso8601,
638
+ "suffix" => fitted, "names" => names, "bases" => bases)
639
+ Database.save_map(ctx.store.dir, map)
640
+ # The environment files: what hand-run commands read. Written
641
+ # before creation so the verify probe below can see them.
642
+ dev_env = db_env_for(
643
+ names.reject { |cfg, _| cfg == "test" || cfg.start_with?("test_") }, bases)
644
+ test_url = names["test"] &&
645
+ Database.url_for(names["test"], base_for(bases, "test"))
646
+ Database.write_env_files(dir, dev_env, test_url: test_url)
647
+ Database.exclude_files(dir, Database::ENV_FILES)
648
+
649
+ # An empty test database is worse than none: Rails won't
650
+ # auto-load schema into it (verified — first `rails test`
651
+ # fails on missing schema_migrations, not a clear
652
+ # NoDatabaseError). So when THIS pass created the test DB,
653
+ # prepare it too — after that, tests just run.
654
+ test_needs_schema = names["test"] &&
655
+ !Database.exists?(names["test"], base_for(bases, "test"))
656
+
657
+ Dir.chdir(dir) { setup_database(ctx, Resolver.resolve(dir), claim_key) }
658
+
659
+ missing = names.reject { |cfg, actual| Database.exists?(actual, base_for(bases, cfg)) }
660
+ unless missing.empty?
661
+ raise Error,
662
+ "could not create #{missing.values.join(', ')} — is the database server running?\n" \
663
+ " The worktree and its claim are kept; start postgres/mysql and run `yamine db create` here."
664
+ end
665
+
666
+ if test_needs_schema
667
+ # RAILS_ENV=test, not development: dotenv must load .env.test
668
+ # (PRIMARY_DATABASE_URL = this worktree's test URL) AND the
669
+ # environment-override merge only applies to the CURRENT
670
+ # env's configs — under development the flat test config
671
+ # would resolve to the base name and purge+load the MAIN
672
+ # checkout's test database instead of this worktree's.
673
+ prepared = Dir.chdir(dir) do
674
+ system({ "RAILS_ENV" => "test" }, "sh", "-c", "bin/rails db:test:prepare",
675
+ out: File::NULL, err: File::NULL)
676
+ end
677
+ if prepared
678
+ puts " test database schema ready — tests run as-is"
679
+ else
680
+ warn " WARNING: could not prepare the test database — run `bin/rails db:test:prepare` in #{dir} before running tests"
681
+ end
682
+ end
683
+
684
+ verify_env_loading(dir, names)
685
+ names
686
+ end
687
+
688
+ def strip_suffix(rows, suffix)
689
+ return rows if rows.nil? || suffix.nil? || suffix.empty?
690
+
691
+ rows.map do |r|
692
+ if r["database"].end_with?("_#{suffix}")
693
+ r.merge("database" => r["database"].delete_suffix("_#{suffix}"))
694
+ else
695
+ r
696
+ end
697
+ end
698
+ end
699
+
700
+ # Flat configs (a lone `test:` section) resolve as "primary" —
701
+ # the claim key must not collide with the development primary.
702
+ def env_namespaced_key(cfg_name, env)
703
+ cfg_name == "primary" ? env : "#{env}_#{cfg_name}"
704
+ end
705
+
706
+ # The proof, not the promise: re-probe now that .env exists.
707
+ # Matching names mean something inside the app loads .env
708
+ # (dotenv-rails or any dotenv loader) — hand-run console/test/
709
+ # migrate commands are isolated too. Mismatch is not fatal
710
+ # (supervised boots isolate via injected env regardless), but
711
+ # it is the one gap worth shouting about at the exact moment of
712
+ # creation. A probe that cannot run at all after writing .env
713
+ # IS fatal: the app no longer boots, and .env's own authorship
714
+ # is the likely reason.
715
+ def verify_env_loading(dir, names)
716
+ rows = Probe.rails_databases(dir)
717
+ unless rows
718
+ raise Error,
719
+ "the app no longer boots after writing .env — " \
720
+ "check the generated .env files in #{dir}"
721
+ end
722
+
723
+ actual = rows.to_h { |r| [r["name"], r["database"]] }
724
+ # Keys absent from this probe are env-namespaced (the test
725
+ # database, probed separately) — the dev probe cannot vouch
726
+ # for them either way; skip, don't flag.
727
+ mismatched = names.reject { |cfg, want| !actual.key?(cfg) || actual[cfg] == want }
728
+ if mismatched.empty?
729
+ puts " .env loaded — hand-run commands are isolated too"
730
+ else
731
+ cfg, want = mismatched.first
732
+ env_url = rows.first["env_database_url"]
733
+ if env_url && !env_url.empty?
734
+ # The loader DID run — DATABASE_URL reached the app — yet
735
+ # resolution ignored it. database.yml supplies `url:` keys
736
+ # (credentials-style), and for those Rails gives the FILE
737
+ # precedence: merge_db_environment_variables skips configs
738
+ # that are already URL-shaped. No environment channel,
739
+ # spawned or loaded, can redirect this app in that form.
740
+ warn " WARNING: #{cfg} resolves to #{actual[cfg].inspect} even though " \
741
+ "DATABASE_URL says #{env_url.split("/").last.inspect}."
742
+ warn " database.yml's `url:` keys take precedence over the environment in this configuration —"
743
+ warn " neither injected env nor .env can redirect them. Switch development/test to"
744
+ warn " component form (database:, host:, username:) so DATABASE_URL and .env apply —"
745
+ warn " see the yamine README's multi-database section."
746
+ else
747
+ warn " WARNING: this app does not load .env " \
748
+ "(#{cfg} resolves to #{actual[cfg].inspect}, expected #{want.inspect})."
749
+ warn " Supervised boots are still isolated via injected env, but `rails console`, " \
750
+ "`rails test`, and `db:migrate` run by hand in this worktree would use the main checkout's databases."
751
+ warn " Fix once: add `gem \"dotenv-rails\", groups: [:development, :test]` to the Gemfile — " \
752
+ "any dotenv loader works."
753
+ end
754
+ end
755
+ rescue StandardError => e
756
+ raise Error, e.message if e.is_a?(Error)
757
+
758
+ raise Error,
759
+ "the app no longer boots after writing .env: #{e.message}"
495
760
  end
496
761
 
497
762
  # Top-level `db: false` opts out of per-worktree databases.
@@ -548,13 +813,17 @@ module Yamine
548
813
  # Schema-load on first boot uses the app's own command when declared
549
814
  # (top-level db.schema_load in config/local.yml), else a Rails
550
815
  # default when a Rails app is detected, else nothing (frameworks
551
- # without a schema concept need no step). Returns a detail string.
552
- def run_schema_load(resolved, url)
816
+ # without a schema concept need no step). db_env is the whole
817
+ # injection hash — with a multi-database claim, Rails' schema
818
+ # tasks iterate every config and each resolves to its own
819
+ # isolated database. Returns a detail string.
820
+ def run_schema_load(resolved, db_env)
553
821
  cmd = db_schema_load(resolved) ||
554
822
  ("bin/rails db:schema:load" if rails_app?)
555
823
  return "no schema-load command" unless cmd
556
824
 
557
- ok = system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
825
+ env = { "RAILS_ENV" => rails_env }.merge(db_env || {})
826
+ ok = system(env,
558
827
  "sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
559
828
  ok ? "loaded schema via #{cmd}" : raise("schema-load exited non-zero: #{cmd}")
560
829
  end