feat: extend cli with db_migrate and db_rollback

This commit is contained in:
dualfroz committed 2026-09-06 18:44:08 +02:00
1 parent f8f799f38f
commit c3ff02a741
1 file changed
+181
+181
View File
@@ -23,10 +23,191 @@ module Prism
case command
when 'new' then new_app(rest.first || raise(UsageError, 'usage: prism new APP_NAME'))
when 'generate', 'g' then generate(rest)
when 'jobs' then jobs(rest)
when 'assets:precompile' then assets_precompile(rest)
when 'console', 'c' then console(rest)
when 'routes' then routes(rest)
when 'db:migrate' then db_migrate(rest)
when 'db:rollback' then db_rollback(rest)
when 'db:status' then db_status(rest)
when 'db:seed' then db_seed(rest)
else raise UsageError, "unknown command #{command.inspect}"
end
end
# Applies every pending migration directly against a PStore file, the
# same work `Orm.migrator(...).migrate!` does when a generated app.rb
# boots - but as a standalone step that does not require loading the
# rest of the application (no controllers, no server), mirroring `rails
# db:migrate`. Useful on its own for apps that do not auto-migrate at
# boot, and it is the only way to inspect/rewind migrations (db:status,
# db:rollback below) without booting an app that would immediately
# re-apply everything pending anyway.
def db_migrate(args, out: $stdout)
migrator = build_migrator(parse_db_options(args))
applied = migrator.migrate!
if applied.empty?
out.puts '[prism db:migrate] no pending migrations'
else
applied.each { |version| out.puts "[prism db:migrate] migrated #{version}" }
end
applied
end
# Reverts the most recently applied migration(s), like `rails
# db:rollback STEP=n` - `--step=N` picks how many to undo (default 1).
def db_rollback(args, out: $stdout)
options = parse_db_options(args)
migrator = build_migrator(options)
reverted = migrator.rollback!(options[:step])
if reverted.empty?
out.puts '[prism db:rollback] nothing to roll back'
else
reverted.each { |version| out.puts "[prism db:rollback] rolled back #{version}" }
end
reverted
end
# Lists every migration file found under --migrations next to whether
# it has been applied to --database, like `rails db:migrate:status`.
def db_status(args, out: $stdout)
migrator = build_migrator(parse_db_options(args))
status = migrator.status
rows = status.map { |entry, applied| [applied ? 'up' : 'down', entry.version, entry.name] }
header = %w[STATUS VERSION NAME]
widths = header.each_index.map { |i| ([header[i].length] + rows.map { |row| row[i].length }).max }
out.puts format_row(header, widths)
rows.each { |row| out.puts format_row(row, widths) }
status
end
# Loads the application (via --require, same flag shape as `jobs work`/
# `console`) and then `load`s --file (default db/seeds.rb) as plain Ruby,
# like `rails db:seed`. Deliberately `load` rather than `require`: a
# seeds file has no meaningful "already loaded" state of its own (unlike
# a model or controller file, it is not a class/module definition but a
# sequence of side effects), so a second `db:seed` run - or a console/
# test process that seeds more than once - must re-execute it, not
# silently no-op the way `require` would after the first call already
# loaded that exact path.
#
# Idempotency of what the seeds file actually does (find_or_create_by
# vs. create!, etc.) is the app's concern, not this command's - same
# division of responsibility Rails draws. A missing seeds file is not an
# error: plenty of apps never have one, and `prism new` does not
# generate a stub, so failing here would make db:seed unusable as a
# step in a deploy script unless every app remembered to create an empty
# placeholder first.
def db_seed(args, out: $stdout)
options = parse_seed_options(args)
options[:require].each { |path| require File.expand_path(path, @root) }
seeds_path = File.expand_path(options[:file], @root)
unless File.exist?(seeds_path)
out.puts "[prism db:seed] no seeds file at #{options[:file]}"
return false
end
load seeds_path
out.puts "[prism db:seed] seeded from #{options[:file]}"
true
end
# Lists every route registered on a loaded Application subclass, like
# `rails routes`: NAME, VERB, PATH and the controller#action (or
# "(block)" for routes defined inline rather than via `to:`) it
# dispatches to, in registration order (the same order the router tries
# them in, which matters when patterns overlap). Mounted Rack apps
# (`mount`) are listed separately underneath since they do not carry a
# single verb/action - only a prefix and whatever app answers under it.
def routes(args, out: $stdout)
options = parse_routes_options(args)
loaded_before = Application.subclasses
options[:require].each { |path| require File.expand_path(path, @root) }
newly_loaded = Application.subclasses - loaded_before
app_class = resolve_app_class(options[:app], newly_loaded)
print_routes(app_class.router, out)
app_class
end
# A minimal `rails console`-style REPL: loads the application (via
# --require, same flag shape as `jobs work`) and evaluates one line of
# Ruby at a time against a binding that survives across lines, so a
# local variable assigned on one line is still visible on the next -
# e.g. `post = Post.first` then `post.title`. Deliberately single-line
# (no heredoc/multi-line statement support) and has no history/tab
# completion, unlike a real irb - this is a REPL, not a terminal editor;
# it reads whatever `input` yields line by line, which makes it just as
# easy to drive from a real TTY as from a StringIO in tests.
def console(args, input: $stdin, out: $stdout)
options = parse_console_options(args)
options[:require].each { |path| require File.expand_path(path, @root) }
session = Object.new
session_binding = session.instance_eval { binding }
prompt = options[:prompt]
out.print(prompt)
input.each_line do |line|
line = line.chomp
stripped = line.strip
if %w[exit quit].include?(stripped)
break
elsif stripped.empty?
out.print(prompt)
next
end
begin
result = session_binding.eval(line, '(console)')
out.puts "=> #{result.inspect}"
rescue StandardError, ScriptError => e
out.puts "Error: #{e.class}: #{e.message}"
end
out.print(prompt)
end
out.puts
nil
end
# Runs Assets::Compiler directly - fingerprints every file under
# --source into --output plus a manifest.json - without booting the
# rest of the application (no Orm.connect, no migrations, no server).
# This is the missing deploy-time step Application#enable_assets never
# covered on its own: enable_assets compiles as a side effect of
# loading app.rb, which also means the ONLY way to precompile assets
# before this was to boot the entire app (database connection and all)
# just to get the compiled files onto disk. Mirrors `rake
# assets:precompile` in Rails: a standalone step meant to run once
# during deploy, before the server process starts.
def assets_precompile(args, out: $stdout)
options = parse_assets_precompile_options(args)
source = File.expand_path(options[:source], @root)
output = File.expand_path(options[:output], @root)
manifest = Assets::Compiler.new(source: source, output: output).compile!
out.puts "[prism assets:precompile] compiled #{manifest.to_h.size} file(s) into #{output}"
manifest
end
# Runs a Jobs::Worker as a long-lived daemon: polls the queue, drains
# every ready job, sleeps `interval` seconds, repeats until SIGINT/SIGTERM
# (or forever with --once/--max-cycles for scripted/finite runs). Returns
# the number of poll cycles executed, mainly so callers/tests can assert
# on it without needing a real infinite loop.
def jobs_work(args, out: $stdout)
options = parse_jobs_work_options(args)
options[:require].each { |path| require File.expand_path(path, @root) }
pidfile = options[:pidfile] && File.expand_path(options[:pidfile], @root)
write_pidfile(pidfile) if pidfile
queue = Jobs::Queue.new(File.expand_path(options[:file], @root))
worker = Jobs::Worker.new(
queue,
queue_name: options[:queue_name],
end
def new_app(name)
app_dir = File.join(@root, name)
raise UsageError, "#{app_dir} already exists" if Dir.exist?(app_dir)