yamine 0.15.1 → 0.16.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,229 @@ 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 + marker 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, app broken by its own
596
+ # suffix hook) — those keep the worktree and the claim.
597
+ def provision_multidb(ctx, dir, claim_key, rows)
598
+ env_name = rails_env
599
+ # A marker from a previous run means the probe answered with
600
+ # names the app already suffixed — strip that suffix before
601
+ # fitting, or this pass would double it and yamine would
602
+ # provision a set the app never connects to.
603
+ marker = Database.read_marker(dir)
604
+ if marker && !marker.empty?
605
+ rows = strip_marker_suffix(rows, marker)
606
+ end
607
+ # The test environment's database joins the claim: worktree
608
+ # tests run against it (the same marker suffixes it), and
609
+ # teardown must drop it — a leaked test database per worktree
610
+ # is exactly the leftover this exists to prevent. Test configs
611
+ # are flat (implicitly named "primary"), so the claim key is
612
+ # namespaced under "test" to not collide with the dev primary.
613
+ # A test env the app cannot answer (missing test credentials,
614
+ # say) degrades to a dev-only claim; `yamine db create` heals.
615
+ test_rows = Probe.rails_databases(dir, env: "test")
616
+ test_rows = strip_marker_suffix(test_rows, marker) if test_rows && marker && !marker.empty?
617
+ test_pairs = (test_rows && Probe.server_backed(test_rows) || [])
618
+ .map { |r| [env_namespaced_key(r["name"], "test"), r] }
619
+
620
+ bases = rows.to_h { |r| [r["name"], r["url"]] }
621
+ .merge(test_pairs.to_h { |key, r| [key, r["url"]] })
622
+ suffix = Database.suffix_for(claim_key, env_name)
623
+ all_bases = rows.map { |r| r["database"] } + test_pairs.map { |_, r| r["database"] }
624
+ fitted = Database.fit_suffix(suffix, dir, all_bases)
625
+ unless fitted
626
+ longest = all_bases.max_by(&:to_s)&.bytesize
627
+ raise Error,
628
+ "a database name of #{longest} bytes leaves no room for a worktree " \
629
+ "suffix (#{Database::MAX_IDENTIFIER_BYTES}-byte limit): #{all_bases.inspect}\n" \
630
+ " Shorten the base names in the app's database configuration."
631
+ end
632
+ names = rows.to_h { |r| [r["name"], Database.multiname(r["database"], fitted)] }
633
+ .merge(test_pairs.to_h { |key, r| [key, Database.multiname(r["database"], fitted)] })
634
+
635
+ map = Database.load_map(ctx.store.dir)
636
+ map[claim_key] = (map[claim_key] || {}).merge(
637
+ "dir" => File.expand_path(dir),
638
+ "claimed_at" => Time.now.utc.iso8601,
639
+ "suffix" => fitted, "names" => names, "bases" => bases)
640
+ Database.save_map(ctx.store.dir, map)
641
+ Database.write_marker(dir, fitted)
642
+ Database.exclude_marker(dir)
643
+
644
+ # An empty test database is worse than none: Rails won't
645
+ # auto-load schema into it (verified — first `rails test`
646
+ # fails on missing schema_migrations, not a clear
647
+ # NoDatabaseError). So when THIS pass created the test DB,
648
+ # prepare it too — after that, tests just run.
649
+ test_needs_schema = names["test"] &&
650
+ !Database.exists?(names["test"], base_for(bases, "test"))
651
+
652
+ Dir.chdir(dir) { setup_database(ctx, Resolver.resolve(dir), claim_key) }
653
+
654
+ missing = names.reject { |cfg, actual| Database.exists?(actual, base_for(bases, cfg)) }
655
+ unless missing.empty?
656
+ raise Error,
657
+ "could not create #{missing.values.join(', ')} — is the database server running?\n" \
658
+ " The worktree and its claim are kept; start postgres/mysql and run `yamine db create` here."
659
+ end
660
+
661
+ if test_needs_schema
662
+ prepared = Dir.chdir(dir) do
663
+ system({ "RAILS_ENV" => rails_env }, "sh", "-c", "bin/rails db:test:prepare",
664
+ out: File::NULL, err: File::NULL)
665
+ end
666
+ if prepared
667
+ puts " test database schema ready — tests run as-is"
668
+ else
669
+ warn " WARNING: could not prepare the test database — run `bin/rails db:test:prepare` in #{dir} before running tests"
670
+ end
671
+ end
672
+
673
+ verify_marker_conformance(dir, names)
674
+ names
675
+ end
676
+
677
+ def strip_marker_suffix(rows, marker)
678
+ return rows if marker.nil? || marker.empty?
679
+
680
+ rows.map do |r|
681
+ if r["database"].end_with?("_#{marker}")
682
+ r.merge("database" => r["database"].delete_suffix("_#{marker}"))
683
+ else
684
+ r
685
+ end
686
+ end
687
+ end
688
+
689
+ # Flat configs (a lone `test:` section) resolve as "primary" —
690
+ # the claim key must not collide with the development primary.
691
+ def env_namespaced_key(cfg_name, env)
692
+ cfg_name == "primary" ? env : "#{env}_#{cfg_name}"
693
+ end
694
+
695
+ # The proof, not the promise: re-probe now that the marker file
696
+ # is in place. Matching names mean database.yml reads it —
697
+ # hand-run console/test/migrate commands are isolated too.
698
+ # Mismatch is not fatal (supervised boots still isolate via
699
+ # env), but it is the one gap worth shouting about at the exact
700
+ # moment of creation. A probe that cannot run at all after
701
+ # writing the marker IS fatal: the app no longer boots, and the
702
+ # marker's own authorship is the likely reason.
703
+ def verify_marker_conformance(dir, names)
704
+ rows = Probe.rails_databases(dir)
705
+ unless rows
706
+ raise Error,
707
+ "the app no longer boots after writing #{Database::MARKER_FILE} — " \
708
+ "check the suffix hook at the top of config/database.yml"
709
+ end
710
+
711
+ actual = rows.to_h { |r| [r["name"], r["database"]] }
712
+ # Keys absent from this probe are env-namespaced (the test
713
+ # database, probed separately) — the dev probe cannot vouch
714
+ # for them either way; skip, don't flag.
715
+ mismatched = names.reject { |cfg, want| !actual.key?(cfg) || actual[cfg] == want }
716
+ if mismatched.empty?
717
+ puts " database.yml reads #{Database::MARKER_FILE} — hand-run commands are isolated too"
718
+ else
719
+ cfg, want = mismatched.first
720
+ warn " WARNING: config/database.yml does not read #{Database::MARKER_FILE} " \
721
+ "(#{cfg} resolves to #{actual[cfg].inspect}, expected #{want.inspect})."
722
+ warn " Supervised boots are still isolated via env, but `rails console`, " \
723
+ "`rails test`, and `db:migrate` run by hand in this worktree would use the main checkout's databases."
724
+ warn " Add the suffix hook from the yamine README to database.yml, then re-run `yamine db create`."
725
+ end
726
+ rescue StandardError => e
727
+ raise Error, e.message if e.is_a?(Error)
728
+
729
+ raise Error,
730
+ "the app no longer boots after writing #{Database::MARKER_FILE}: #{e.message}"
495
731
  end
496
732
 
497
733
  # Top-level `db: false` opts out of per-worktree databases.
@@ -548,13 +784,17 @@ module Yamine
548
784
  # Schema-load on first boot uses the app's own command when declared
549
785
  # (top-level db.schema_load in config/local.yml), else a Rails
550
786
  # 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)
787
+ # without a schema concept need no step). db_env is the whole
788
+ # injection hash — with a multi-database claim, Rails' schema
789
+ # tasks iterate every config and each resolves to its own
790
+ # isolated database. Returns a detail string.
791
+ def run_schema_load(resolved, db_env)
553
792
  cmd = db_schema_load(resolved) ||
554
793
  ("bin/rails db:schema:load" if rails_app?)
555
794
  return "no schema-load command" unless cmd
556
795
 
557
- ok = system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
796
+ env = { "RAILS_ENV" => rails_env }.merge(db_env || {})
797
+ ok = system(env,
558
798
  "sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
559
799
  ok ? "loaded schema via #{cmd}" : raise("schema-load exited non-zero: #{cmd}")
560
800
  end
@@ -861,18 +861,24 @@ module Yamine
861
861
  puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
862
862
  end
863
863
 
864
- # Per-worktree databases: list/create/drop, plus orphan cleanup.
865
- # `db list` shows every claimed database with its worktree dir.
866
- # `db create` resolves this worktree's name and creates it.
867
- # `db drop <name>` drops one database (refuses without --force
868
- # when its worktree still exists).
864
+ # Per-worktree databases: list/create/drop/describe.
865
+ # `db list` shows every claim and every database under it.
866
+ # `db create` asks the app what it has and provisions it — the
867
+ # suffixed set in a worktree, the base set in the main checkout
868
+ # (self-healing: run it after adding a database to the app).
869
+ # `db drop <name>` drops a claim's whole set — by claim key or
870
+ # by any actual database name (refuses without --force when its
871
+ # worktree still exists).
872
+ # `db describe` asks the app what it has and prints it — the
873
+ # probe behind create, exposed for debugging.
869
874
  def db(ctx, args)
870
875
  sub = args.first
871
876
  case sub
872
877
  when "list", nil then db_list(ctx)
873
878
  when "create" then db_create(ctx)
874
879
  when "drop" then db_drop(ctx, args[1..] || [])
875
- else raise Error, "Usage: yamine db [list|create|drop <name> [--force]]"
880
+ when "describe" then db_describe(ctx)
881
+ else raise Error, "Usage: yamine db [list|create|drop <name> [--force]|describe]"
876
882
  end
877
883
  end
878
884
 
@@ -880,32 +886,64 @@ module Yamine
880
886
  map = Database.load_map(ctx.store.dir)
881
887
  if map.empty?
882
888
  puts "No worktree databases claimed yet."
883
- puts "Boot an app with a DATABASE_URL template to create one."
889
+ puts "Run `yamine worktree add <branch>` — or `yamine db create` — to claim one."
884
890
  return
885
891
  end
886
892
  map.each do |name, info|
887
893
  alive = File.directory?(info["dir"]) ? "" : " (worktree gone)"
888
894
  puts " #{name} -> #{info["dir"]}#{alive}"
895
+ names = info.is_a?(Hash) ? info["names"] : nil
896
+ next unless names.is_a?(Hash) && !names.empty?
897
+
898
+ names.each_value { |db| puts " db: #{db}" }
889
899
  end
890
900
  end
891
901
 
892
902
  def db_create(ctx)
893
- resolved = Resolver.resolve(Dir.pwd)
894
- name = Database.name_for(Dir.pwd, env: boot_env_name,
903
+ claim_key = Database.name_for(Dir.pwd, env: boot_env_name,
895
904
  state_dir: ctx.store.dir)
905
+ rows = Probe.rails_databases(Dir.pwd)
906
+ server = rows && Probe.server_backed(rows)
907
+
908
+ if server && !server.empty?
909
+ if Database.main_checkout?(Dir.pwd)
910
+ db_create_main(server)
911
+ else
912
+ names = BootCommand.provision_multidb(ctx, Dir.pwd, claim_key, server)
913
+ puts "Database set ready (#{names.size}):"
914
+ names.each_value { |db| puts " #{db}" }
915
+ end
916
+ return
917
+ end
918
+
896
919
  template = drop_template
897
920
  unless template && !template.strip.empty?
898
921
  $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml)."
899
922
  exit 1
900
923
  end
901
- if Database.ensure_exists(name, template)
902
- puts "Database #{name} ready."
924
+ if Database.ensure_exists(claim_key, template)
925
+ puts "Database #{claim_key} ready."
903
926
  else
904
- $stderr.puts "Error: could not create #{name}. Check the database server is running."
927
+ $stderr.puts "Error: could not create #{claim_key}. Check the database server is running."
905
928
  exit 1
906
929
  end
907
930
  end
908
931
 
932
+ # Main checkout: the databases ARE the app's own — no suffix, no
933
+ # marker, no claim names. Just make sure they exist.
934
+ def db_create_main(rows)
935
+ missing = rows.reject { |r| Database.exists?(r["database"], r["url"]) }
936
+ failed = missing.filter_map do |r|
937
+ r["database"] unless Database.ensure_exists(r["database"], r["url"])
938
+ end
939
+ unless failed.empty?
940
+ $stderr.puts "Error: could not create #{failed.join(', ')}. Check the database server."
941
+ exit 1
942
+ end
943
+ puts "Base databases ready (#{rows.size}):"
944
+ rows.each { |r| puts " #{r["database"]}" }
945
+ end
946
+
909
947
  def db_drop(ctx, args)
910
948
  force = args.delete("--force")
911
949
  name = args.first
@@ -913,28 +951,69 @@ module Yamine
913
951
 
914
952
  map = Database.load_map(ctx.store.dir)
915
953
  info = map[name]
916
- if info && File.directory?(info["dir"]) && !force
954
+ unless info
955
+ # A name from inside a claim identifies the claim — the set
956
+ # is the unit; half a suffixed set is orphan territory.
957
+ key, found = map.find { |_k, i| i.is_a?(Hash) && i.dig("names")&.value?(name) }
958
+ name, info = key, found if info.nil? && found
959
+ end
960
+ raise Error, "no claim named #{name.inspect} here — `yamine db list` shows what exists" unless info
961
+
962
+ if File.directory?(info["dir"]) && !force
917
963
  $stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
918
964
  exit 1
919
965
  end
966
+
967
+ pairs = Database.claim_pairs(info) || begin
968
+ template = drop_template
969
+ unless template && !template.strip.empty?
970
+ $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot reach the server to drop #{name}."
971
+ exit 1
972
+ end
973
+ [[name, template]]
974
+ end
920
975
  template = drop_template
921
- unless template && !template.strip.empty?
922
- $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot reach the server to drop #{name}."
976
+ pairs = pairs.map { |db, tmpl| [db, (tmpl || template)] }
977
+
978
+ failed = false
979
+ pairs.each do |db, tmpl|
980
+ case Database.drop(db, tmpl)
981
+ when :dropped then puts " dropped #{db}"
982
+ when :missing then puts " #{db} did not exist — nothing to drop"
983
+ when :failed then failed = true
984
+ end
985
+ end
986
+ if failed
987
+ $stderr.puts "Error: could not drop part of #{name} — is the database server running? The claim is kept."
923
988
  exit 1
924
989
  end
925
- case Database.drop(name, template)
926
- when :dropped
927
- map.delete(name)
928
- Database.save_map(ctx.store.dir, map)
929
- puts "Dropped #{name}."
930
- when :missing
931
- map.delete(name)
932
- Database.save_map(ctx.store.dir, map)
933
- puts "#{name} did not exist — nothing to drop (claim removed)."
934
- when :failed
935
- $stderr.puts "Error: could not drop #{name} — is the database server running? The claim is kept."
990
+
991
+ map.delete(name)
992
+ Database.save_map(ctx.store.dir, map)
993
+ puts "Dropped #{name}."
994
+ end
995
+
996
+ # The probe exposed: what THIS app, right here, resolves to —
997
+ # database.yml + credentials and all, asked inside the app so no
998
+ # key or parser is needed outside it. Passwords masked; this
999
+ # prints to terminals and CI logs.
1000
+ def db_describe(_ctx)
1001
+ rows = Probe.rails_databases(Dir.pwd)
1002
+ unless rows
1003
+ $stderr.puts "Error: could not ask this app for its databases."
1004
+ $stderr.puts " Is it a Rails app with a working `bin/rails runner`? (probe timed out or exited non-zero)"
936
1005
  exit 1
937
1006
  end
1007
+ rows.each do |r|
1008
+ kind = %i[postgres mysql].include?(Database.adapter_for(r["url"])) ? "server" : "local"
1009
+ puts " #{r["name"]} (#{kind})"
1010
+ puts " database: #{r["database"]}"
1011
+ puts " url: #{mask_url(r["url"])}"
1012
+ end
1013
+ end
1014
+
1015
+ def mask_url(url)
1016
+ url.to_s.sub(%r{\A([a-z0-9+]+://[^:/@]+):[^@]*@}, '\1:***@')
938
1017
  end
939
1018
 
940
1019
  # Template for reaching the database server outside of boot: