yiffspace-fixers 0.0.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2deff4a56be5dc1abebbb670f5a509a11f4d257cc2daba5033b9bb87820fb6d4
4
+ data.tar.gz: b19fd72d813bc38fa5a915c876eb4e47f7c15fcf0d051ef3851a7afd9fe3eded
5
+ SHA512:
6
+ metadata.gz: d1225851f6be5102fe8598353e347e1a4cbd2f771c2139e8156c850fb50d8a6c136c51a6da3ef0d8dd9f23fdc4586dbc375d129b32461163fb9bdf7b2182ffa9
7
+ data.tar.gz: 9c1e989b7c8f83314de5b30d6ab7e5b9499611b6bcc5569b9459156fd2761d52dcc8f391c330205c2db29d50f4bb9802209b26f6dd39d4491025d63113bd206d
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## 0.0.1
2
+
3
+ - Initial release, extracted from the `yiffspace` gem's `YiffSpace::FixTracker`/
4
+ `YiffSpace::FixerTemplate`/`YiffSpace::Configuration::FixerTemplates`, the
5
+ `yiffspace:fixer`/`yiffspace:install:fixes` generators, and the `fixes:*`
6
+ rake tasks.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Donovan_DMC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # YiffSpace::Fixers
2
+
3
+ One-time `db/fixes/*.rb` scripts, tracked the same way ActiveRecord's own
4
+ `schema_migrations` tracks migrations - for https://yiff.space and related
5
+ projects. Lives alongside, and depends on, the [`yiffspace`](../yiffspace)
6
+ gem in this repo.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem "yiffspace-fixers"
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ ```bash
19
+ $ bundle install
20
+ $ bin/rails generate yiffspace:install:fixes
21
+ $ bin/rails db:migrate
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ $ bin/rails generate yiffspace:fixer some_description
28
+ $ bin/rails fixes:list
29
+ $ bin/rails fixes:migrate
30
+ ```
31
+
32
+ A fix can require that a migration has already been applied, and a migration can require that a
33
+ fix has already been applied - useful when one depends on schema or data the other provides.
34
+ `bin/rails fixes:migrate_all` runs both `db/migrate` and `db/fixes` together, in whichever order
35
+ those requirements demand:
36
+
37
+ ```ruby
38
+ # db/fixes/12_backfill_widget_type.rb
39
+ YiffSpace::FixTracker.requires_migration!("20260822004454")
40
+
41
+ # db/migrate/20260901000000_remove_legacy_widget_column.rb
42
+ class RemoveLegacyWidgetColumn < ActiveRecord::Migration[8.1]
43
+ requires_fix(12)
44
+
45
+ def change
46
+ ...
47
+ end
48
+ end
49
+ ```
50
+
51
+ See `YiffSpace::FixTracker`, `YiffSpace::FixerTemplate`, `YiffSpace::Fixers::RequiresFix`, and
52
+ `YiffSpace::MigrationSync` for the full API.
53
+
54
+ ## Contributing
55
+
56
+ Go away
57
+
58
+ ## License
59
+
60
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("bundler/setup")
4
+
5
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+ load("rails/tasks/engine.rake")
7
+
8
+ load("rails/tasks/statistics.rake")
9
+
10
+ require("bundler/gem_tasks")
11
+
12
+ # CI releases from an already-tagged, already-pushed commit checked out at a detached HEAD, so
13
+ # the default release task's git tag/push step has nothing to do and fails trying to push a
14
+ # branch ref that does not exist in that checkout.
15
+ Rake::Task["release"].clear
16
+ task("release" => "release:rubygem_push")
@@ -0,0 +1,40 @@
1
+ Description:
2
+ Generates a new one-time fix script tracked by YiffSpace::FixTracker.
3
+
4
+ Example:
5
+ bin/rails generate yiffspace:fixer fixer_name
6
+
7
+ This will create:
8
+ db/fixes/<id>_fixer_name.rb
9
+
10
+ bin/rails generate yiffspace:fixer fixer_name --steps 2
11
+
12
+ This will create:
13
+ db/fixes/<id>_1_fixer_name.rb
14
+ db/fixes/<id>_2_fixer_name.rb
15
+
16
+ A host app can define named templates, auto-discovered from db/fixer_templates/*.rb (see
17
+ YiffSpace::FixerTemplate and YiffSpace.config.fixer_templates_path) - no registration needed,
18
+ just define a subclass. Each `step` block's return value becomes that step's file content:
19
+
20
+ # db/fixer_templates/elasticsearch_template.rb
21
+ class ElasticsearchTemplate < YiffSpace::FixerTemplate
22
+ short("e")
23
+
24
+ step { "# add the elasticsearch index\n" }
25
+ step { "# backfill the elasticsearch data\n" }
26
+ end
27
+
28
+ bin/rails generate yiffspace:fixer fixer_name --template elasticsearch
29
+ bin/rails generate yiffspace:fixer fixer_name -t elasticsearch
30
+ bin/rails generate yiffspace:fixer fixer_name -e
31
+
32
+ All three create the 2 steps above; `short` is optional - a template is always reachable via
33
+ --template/-t, the shortname flag is just a convenience.
34
+
35
+ A host app can also register a preset with just a step count (no per-step content - every
36
+ step falls back to the generic fixer.rb template) directly in an initializer instead:
37
+
38
+ YiffSpace.configure do |config|
39
+ config.fixer_templates.register(:legacy, steps: 3)
40
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yiffspace
4
+ # Scaffolds a new db/fixes/*.rb script for YiffSpace::FixTracker (see `rake fixes:list`).
5
+ #
6
+ # `--steps N` splits the fix into N ordered steps sharing an id, each one a copy of the generic
7
+ # fixer.rb template. A host app can instead define a YiffSpace::FixerTemplate subclass (see that
8
+ # class) to control both the step count and each step's file content, auto-discovered from
9
+ # YiffSpace.config.fixer_templates_path - reachable here with `--template <name>`/`-t <name>`,
10
+ # or with the template's own shortname flag if it registered one (e.g. `-e`).
11
+ class FixerGenerator < Rails::Generators::NamedBase
12
+ source_root(File.expand_path("templates", __dir__))
13
+ # No short alias - -s/-f/-p/-q are already claimed by Rails::Generators::Base's own runtime
14
+ # options (--skip/--force/--pretend/--quiet), and Thor silently lets the last-defined
15
+ # class_option win a collision instead of erroring.
16
+ class_option(:steps, type: :numeric, default: 1,
17
+ desc: "Split the fix into N ordered steps sharing an id, e.g. 12_1_name.rb, 12_2_name.rb")
18
+ class_option(:template, type: :string, default: nil, aliases: ["-t"],
19
+ desc: "Use a fixer template registered with YiffSpace.config.fixer_templates")
20
+
21
+ YiffSpace.config.fixer_templates.each do |template|
22
+ next unless template.short
23
+
24
+ class_option(template.name.to_sym, type: :boolean, default: false, aliases: ["-#{template.short}"],
25
+ desc: "Shortcut for --template #{template.name}")
26
+ end
27
+
28
+ def create_fixer
29
+ content_blocks = resolve_content_blocks
30
+
31
+ id = Dir[File.join(destination_root, "db/fixes/*.rb")].map { |f| File.basename(f, ".rb").split("_").first.to_i }.max.to_i + 1
32
+
33
+ if content_blocks.size == 1
34
+ create_fixer_file("db/fixes/#{id}_#{file_name}.rb", content_blocks.first)
35
+ else
36
+ content_blocks.each_with_index { |block, index| create_fixer_file("db/fixes/#{id}_#{index + 1}_#{file_name}.rb", block) }
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ # One entry per step - a block returning that step's file content, or nil to fall back to
43
+ # copying the generic fixer.rb template (a plain --steps N, or a manually
44
+ # YiffSpace.config.fixer_templates.register'd template, has no per-step content).
45
+ def resolve_content_blocks
46
+ template = resolve_template
47
+ return template.content_blocks if template
48
+
49
+ Array.new(steps_from_flag)
50
+ end
51
+
52
+ def resolve_template
53
+ name = template_name
54
+ return nil if name.nil?
55
+
56
+ template = YiffSpace.config.fixer_templates[name]
57
+ raise(Thor::Error, "no fixer template named #{name.inspect} is registered") unless template
58
+
59
+ template
60
+ end
61
+
62
+ # The template chosen via `--template <name>`, or via a registered template's own shortname
63
+ # flag (e.g. `-e` for a template registered with `short: "e"`) - nil if neither was passed.
64
+ def template_name
65
+ return options["template"] if options["template"]
66
+
67
+ YiffSpace.config.fixer_templates.find { |template| template.short && options[template.name] }&.name
68
+ end
69
+
70
+ def steps_from_flag
71
+ steps = options["steps"].to_i
72
+ raise(Thor::Error, "--steps must be at least 1") if steps < 1
73
+
74
+ steps
75
+ end
76
+
77
+ def create_fixer_file(path, content_block)
78
+ if content_block
79
+ create_file(path, content_block.call)
80
+ chmod(path, "+x")
81
+ else
82
+ copy_file("fixer.rb", path, mode: :preserve)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require(File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "config", "environment")))
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yiffspace
4
+ module Install
5
+ # Installs YiffSpace::FixTracker into a host app: `bin/rails generate yiffspace:install:fixes`
6
+ # copies the `fixes` tracking table migration. Run `bin/rails db:migrate` afterwards.
7
+ class FixesGenerator < Rails::Generators::Base
8
+ include(Rails::Generators::Migration)
9
+
10
+ source_root(File.expand_path("templates", __dir__))
11
+
12
+ def self.next_migration_number(dirname)
13
+ ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
14
+ end
15
+
16
+ def create_migration_file
17
+ migration_template("create_fixes.rb.erb", "db/migrate/create_fixes.rb")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tracks which db/fixes/*.rb scripts have been applied, the same way schema_migrations tracks
4
+ # migrations - see YiffSpace::FixTracker and `rake fixes:migrate`/`rake fixes:list`.
5
+ #
6
+ # `id` is the fix's own leading number (e.g. 12, 23), not a surrogate key, so it isn't unique on
7
+ # its own - a fix can be split into ordered sub-steps (12_1, 12_2, ...) that share an id and are
8
+ # distinguished by `index`. `index` is null for fixes with no subtype.
9
+ class CreateFixes < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
10
+ def change
11
+ create_table(:fixes, id: false) do |t|
12
+ t.integer(:id, null: false) # not a surrogate key, this is the fix's own leading number
13
+ t.integer(:index)
14
+ end
15
+
16
+ add_index(:fixes, %i[id index], unique: true, nulls_not_distinct: true)
17
+ end
18
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Keep structure.sql's fixes rows in sync with the database on every schema dump, the same way
4
+ # ActiveRecord keeps schema_migrations rows in sync - not just when `fixes:migrate` runs.
5
+ Rake::Task["db:schema:dump"].enhance do
6
+ YiffSpace::FixTracker.dump_structure_sql!
7
+ end
8
+
9
+ namespace(:fixes) do
10
+ desc("List available fix scripts in db/fixes, marking which have already been applied")
11
+ task(list: :environment) do
12
+ applied = YiffSpace::FixTracker.applied
13
+ YiffSpace::FixTracker.all_fixes.each do |name|
14
+ puts("#{applied.include?(YiffSpace::FixTracker.key_for(name)) ? '[x]' : '[ ]'} #{name}")
15
+ end
16
+ end
17
+
18
+ desc("Run a fix script from db/fixes by id or name, e.g. `rake fixes:run[1]` - re-runs even if already recorded as applied")
19
+ task(:run, [:name] => :environment) do |_task, args|
20
+ name = args[:name].to_s.delete_suffix(".rb")
21
+ abort("Usage: rake fixes:run[id_or_name] (see `rake fixes:list`)") if name.blank?
22
+
23
+ candidates = Dir["db/fixes/*.rb"]
24
+ exact = candidates.find { |path| File.basename(path, ".rb") == name }
25
+ matches = exact ? [exact] : candidates.select { |path| File.basename(path, ".rb").start_with?("#{name}_") || File.basename(path, ".rb").include?(name) }
26
+
27
+ if matches.empty?
28
+ abort("No fix matches \"#{name}\". Run `rake fixes:list` to see available fixes.")
29
+ elsif matches.size > 1
30
+ abort("Multiple fixes match \"#{name}\", be more specific:\n#{matches.map { |m| " #{File.basename(m, '.rb')}" }.join("\n")}")
31
+ end
32
+
33
+ puts("Running #{File.basename(matches.first, '.rb')}...")
34
+ name = YiffSpace::FixTracker.run!(matches.first)
35
+ puts("Recorded #{name} as applied")
36
+ end
37
+
38
+ desc("Apply all fix scripts not yet recorded as applied, in order, then dump the schema like db:migrate does")
39
+ task(migrate: :environment) do
40
+ pending = YiffSpace::FixTracker.pending
41
+
42
+ if pending.empty?
43
+ puts("No pending fixes.")
44
+ else
45
+ pending.each do |name|
46
+ puts("Running #{name}...")
47
+ YiffSpace::FixTracker.run!(YiffSpace::FixTracker.fix_path(name))
48
+ puts("Recorded #{name} as applied")
49
+ end
50
+ end
51
+
52
+ Rake::Task["db:_dump"].invoke
53
+ end
54
+ end
55
+
56
+ # Split into its own namespace block, rather than growing the one above past
57
+ # Metrics/BlockLength's max - Rake happily reopens a namespace.
58
+ namespace(:fixes) do
59
+ desc("Apply all pending db/migrate migrations and db/fixes fixes together, in whichever order their requires_fix/requires_migration! declarations demand")
60
+ task(migrate_all: :environment) do
61
+ steps = YiffSpace::MigrationSync.plan
62
+
63
+ if steps.empty?
64
+ puts("Nothing to migrate or fix.")
65
+ else
66
+ steps.each do |step|
67
+ puts(YiffSpace::MigrationSync.describe(step))
68
+ YiffSpace::MigrationSync.apply(step)
69
+ end
70
+ end
71
+
72
+ Rake::Task["db:_dump"].invoke
73
+ end
74
+
75
+ desc("Record all of the current fixes as having been applied")
76
+ task(load: :environment) do
77
+ list = YiffSpace::FixTracker.all_fixes.map { YiffSpace::FixTracker.key_for(it) }
78
+ existing = ActiveRecord::Base.connection.select_all('SELECT id, "index" FROM fixes ORDER BY id, "index"').rows
79
+ list.reject! { |id, index| existing.any? { |e| e[0] == id && e[1] == index } }
80
+ unless list.empty?
81
+ values = list.map { |id, index| "(#{id}, #{index.nil? ? 'NULL' : index})" }.join(", ")
82
+ ActiveRecord::Base.connection.execute(%(INSERT INTO fixes (id, "index") VALUES #{values}))
83
+ end
84
+ puts("Loaded #{list.length} #{'fix'.pluralize(list.length)}")
85
+ end
86
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ class Configuration
5
+ # Named presets for `bin/rails generate yiffspace:fixer`, so `--template <name>` (or a
6
+ # registered shortname flag) can stand in for `--steps N`. Populated two ways:
7
+ #
8
+ # - Automatically, from YiffSpace::FixerTemplate subclasses found in
9
+ # YiffSpace.config.fixer_templates_path (the common case - see FixerTemplate).
10
+ # - Manually, via #register, for a preset that just needs a step count and no per-step
11
+ # content (every step falls back to the generic fixer.rb template).
12
+ #
13
+ # See YiffSpace::Configuration#fixer_templates and Yiffspace::FixerGenerator.
14
+ class FixerTemplates
15
+ include(Enumerable)
16
+
17
+ Template = Struct.new(:name, :short, :content_blocks, keyword_init: true) do
18
+ def steps
19
+ content_blocks.size
20
+ end
21
+ end
22
+
23
+ # -t is yiffspace:fixer's own --template flag; -f/-p/-q/-s are Rails::Generators::Base's
24
+ # built-in runtime options (--force/--pretend/--quiet/--skip).
25
+ RESERVED_SHORTS = %w[t f p q s].freeze
26
+
27
+ def initialize
28
+ @templates = {}
29
+ @discovered = false
30
+ end
31
+
32
+ # short is optional - a template is always reachable via `--template <name>`, a shortname
33
+ # flag (e.g. `-e`) is just a convenient alias for it.
34
+ def register(name, steps:, short: nil)
35
+ raise(ArgumentError, "steps must be a positive integer, got #{steps.inspect}") unless steps.is_a?(Integer) && steps.positive?
36
+
37
+ add(name.to_s, short: short, content_blocks: Array.new(steps))
38
+ end
39
+
40
+ def [](name)
41
+ discover!
42
+ @templates[name.to_s]
43
+ end
44
+
45
+ def each(&)
46
+ discover!
47
+ @templates.each_value(&)
48
+ end
49
+
50
+ private
51
+
52
+ def add(name, short:, content_blocks:)
53
+ raise(ArgumentError, "a fixer template named #{name.inspect} is already registered") if @templates.key?(name)
54
+
55
+ short = short.to_s.delete_prefix("-") if short
56
+ if short
57
+ raise(ArgumentError, "\"-#{short}\" is reserved by yiffspace:fixer's own options") if RESERVED_SHORTS.include?(short)
58
+
59
+ existing = @templates.values.find { |template| template.short == short }
60
+ raise(ArgumentError, "\"-#{short}\" is already registered to the #{existing.name.inspect} fixer template") if existing
61
+ end
62
+
63
+ @templates[name] = Template.new(name: name, short: short, content_blocks: content_blocks)
64
+ end
65
+
66
+ # Scans YiffSpace.config.fixer_templates_path for YiffSpace::FixerTemplate subclasses and
67
+ # registers each one - runs once, the first time a template is looked up (#[] or #each),
68
+ # so it sees whatever the host app registered manually beforehand.
69
+ def discover!
70
+ return if @discovered
71
+
72
+ @discovered = true
73
+ path = YiffSpace.config.fixer_templates_path
74
+ return unless path && Dir.exist?(path)
75
+
76
+ before = FixerTemplate.subclasses
77
+ Dir[File.join(path, "*.rb")].each { |file| require(file) }
78
+
79
+ (FixerTemplate.subclasses - before).each do |klass|
80
+ raise(ArgumentError, "#{klass.name} has no step blocks - add at least one `step { ... }`") if klass.steps.empty?
81
+
82
+ add(klass.template_name, short: klass.short, content_blocks: klass.steps)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ # Tracks which db/fixes/*.rb scripts a host app has applied, in a `fixes` table (see
5
+ # db/migrate/*_create_fixes.rb) the same way ActiveRecord's own schema_migrations table
6
+ # tracks migrations. Used by the `fixes:*` rake tasks (lib/tasks/fixes.rake).
7
+ module FixTracker
8
+ # Raised by requires_migration! when the required migration hasn't been applied yet.
9
+ class MigrationRequired < StandardError; end
10
+
11
+ # A fix script calls this as its first statement to declare that a migration must already be
12
+ # applied before it can run, e.g.:
13
+ #
14
+ # YiffSpace::FixTracker.requires_migration!("20260822004454")
15
+ #
16
+ # Written as a literal string call like that (not built dynamically) so YiffSpace::MigrationSync
17
+ # can also read it back out of the file with #required_migration_for, without loading/running
18
+ # the fix, to order `fixes:migrate_all` correctly.
19
+ REQUIRES_MIGRATION_PATTERN = /^\s*YiffSpace::FixTracker\.requires_migration!\(\s*["']([^"']+)["']\s*\)/
20
+
21
+ module_function
22
+
23
+ def fix_path(name)
24
+ Rails.root.glob("db/fixes/#{name}.rb").first
25
+ end
26
+
27
+ def requires_migration!(version)
28
+ return if migration_applied?(version)
29
+
30
+ raise(MigrationRequired, "migration #{version} must be applied before this fix can run")
31
+ end
32
+
33
+ def migration_applied?(version)
34
+ migration_context.get_all_versions.include?(version.to_i)
35
+ end
36
+
37
+ # Statically reads a fix script's declared requires_migration! version, if any, without
38
+ # loading/running it - nil if the fix has no such call.
39
+ def required_migration_for(name)
40
+ path = fix_path(name)
41
+ return nil unless path
42
+
43
+ path.read.match(REQUIRES_MIGRATION_PATTERN)&.captures&.first
44
+ end
45
+
46
+ # Resolves a YiffSpace::Fixers::RequiresFix id_or_name to the fix name(s) it refers to - an
47
+ # Integer id covers every step sharing that id (e.g. 2 -> %w[2_1_first_step 2_2_second_step]),
48
+ # a name matches that one fix file exactly.
49
+ def resolve(id_or_name)
50
+ if id_or_name.is_a?(Integer)
51
+ all_fixes.select { |name| key_for(name).first == id_or_name }
52
+ else
53
+ name = id_or_name.to_s.delete_suffix(".rb")
54
+ all_fixes.select { |candidate| candidate == name }
55
+ end
56
+ end
57
+
58
+ def migration_context
59
+ ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool.migration_context
60
+ end
61
+
62
+ # db/fixes filenames are "<id>_description.rb", occasionally "<id>_<index>_description.rb"
63
+ # (e.g. 1_1_..., 1_2_...) for a handful of fixes split into ordered steps - the `fixes`
64
+ # table mirrors this as an (id, index) pair, with index null for fixes with no subtype.
65
+ def key_for(name)
66
+ id, maybe_index = name.split("_", 3)
67
+ index = maybe_index =~ /\A\d+\z/ ? maybe_index.to_i : nil
68
+ [id.to_i, index]
69
+ end
70
+
71
+ # Sort on the (id, index) parts, not the filename string, so 12 doesn't sort before 2_2.
72
+ def sort_key(name)
73
+ id, index = key_for(name)
74
+ [id, index || 0]
75
+ end
76
+
77
+ # Only numbered one-time fixes are tracked/auto-applied - a host app's db/fixes may also hold
78
+ # reusable on-demand maintenance scripts that aren't meant to run automatically as part of
79
+ # `fixes:migrate`. Those stay reachable via `fixes:run[name]`.
80
+ def all_fixes
81
+ Rails.root.glob("db/fixes/*.rb").map { |path| File.basename(path, ".rb") }.grep(/\A\d/).sort_by { |name| sort_key(name) }
82
+ end
83
+
84
+ def applied
85
+ ActiveRecord::Base.connection.select_rows('SELECT id, "index" FROM fixes').to_set
86
+ end
87
+
88
+ def applied?(name)
89
+ id, index = key_for(name)
90
+ conn = ActiveRecord::Base.connection
91
+ index_clause = index.nil? ? '"index" IS NULL' : %("index" = #{index})
92
+ conn.select_value("SELECT 1 FROM fixes WHERE id = #{id} AND #{index_clause}").present?
93
+ end
94
+
95
+ def pending
96
+ applied_keys = applied
97
+ all_fixes.reject { |name| applied_keys.include?(key_for(name)) }
98
+ end
99
+
100
+ def record!(name)
101
+ id, index = key_for(name)
102
+ conn = ActiveRecord::Base.connection
103
+ conn.execute(<<~SQL.squish)
104
+ INSERT INTO fixes (id, "index") VALUES (#{id}, #{index || 'NULL'})
105
+ ON CONFLICT (id, "index") DO NOTHING
106
+ SQL
107
+ end
108
+
109
+ def run!(path)
110
+ name = File.basename(path, ".rb")
111
+ load(File.expand_path(path))
112
+ record!(name)
113
+ name
114
+ end
115
+
116
+ # Mirrors how ActiveRecord appends `INSERT INTO "schema_migrations"` to structure.sql after
117
+ # a schema dump (see Connection#dump_schema_versions) - appends the currently-applied fixes
118
+ # the same way, so loading structure.sql into a fresh database marks them applied too instead
119
+ # of leaving `fixes:migrate` to re-run every historical fix. Hooked into `db:schema:dump` in
120
+ # lib/tasks/fixes.rake, so it runs on every schema dump, not just `fixes:migrate`.
121
+ def dump_structure_sql!
122
+ db_config = ActiveRecord::Base.connection_db_config
123
+ return unless db_config.schema_format == :sql
124
+
125
+ filename = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config)
126
+ return unless filename && File.exist?(filename)
127
+
128
+ conn = ActiveRecord::Base.connection
129
+ return unless conn.table_exists?(:fixes)
130
+
131
+ rows = conn.select_rows('SELECT id, "index" FROM fixes ORDER BY id DESC, "index" DESC NULLS LAST')
132
+ return if rows.empty?
133
+
134
+ values = rows.map { |id, index| "(#{id}, #{index || 'NULL'})" }.join(",\n")
135
+ File.open(filename, "a") do |f|
136
+ f.puts(<<~TEXT)
137
+ INSERT INTO "fixes" (id, "index") VALUES
138
+ #{values};
139
+ TEXT
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ # Base class for a `bin/rails generate yiffspace:fixer` template, auto-discovered from *.rb
5
+ # files in YiffSpace.config.fixer_templates_path (default db/fixer_templates) - no explicit
6
+ # registration needed, just define a subclass:
7
+ #
8
+ # class ElasticsearchTemplate < YiffSpace::FixerTemplate
9
+ # short("e")
10
+ #
11
+ # step { "..." }
12
+ # step { "..." }
13
+ # end
14
+ #
15
+ # The template's name comes from the class name with a trailing "Template" dropped and
16
+ # kebab-cased (ElasticsearchTemplate -> "elasticsearch", OpenSearchTemplate -> "open-search"),
17
+ # reachable as `--template <name>` (see Yiffspace::FixerGenerator). Its step count is the
18
+ # number of `step` blocks - each one's return value becomes that step's fix file content,
19
+ # instead of the generic fixer.rb template a plain `--steps N` copies for every step.
20
+ class FixerTemplate
21
+ class << self
22
+ # Optional - a template is always reachable via `--template <name>`, a registered
23
+ # shortname flag (e.g. `-e`) is just a convenient alias for it.
24
+ def short(value = nil)
25
+ @short = value.to_s.delete_prefix("-") unless value.nil?
26
+ @short
27
+ end
28
+
29
+ def step(&block)
30
+ raise(ArgumentError, "#{name}.step requires a block") unless block
31
+
32
+ steps << block
33
+ end
34
+
35
+ def steps
36
+ @steps ||= []
37
+ end
38
+
39
+ def template_name
40
+ name.demodulize.delete_suffix("Template").underscore.dasherize
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("rails")
4
+
5
+ module YiffSpace
6
+ module Fixers
7
+ # No app/ of its own - this exists so Rails::Engine's default rake_tasks block picks up
8
+ # lib/tasks/fixes.rake (config.root anchors where it looks for lib/tasks/**/*.rake).
9
+ class Engine < ::Rails::Engine
10
+ config.root = File.expand_path("../../..", __dir__)
11
+
12
+ initializer("yiffspace.fixers.requires_fix") do
13
+ ActiveRecord::Migration.include(YiffSpace::Fixers::RequiresFix)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Fixers
5
+ # Adds `requires_fix` to ActiveRecord::Migration - the migration-side mirror of
6
+ # YiffSpace::FixTracker.requires_migration! - so a migration can declare that one or more
7
+ # db/fixes/*.rb scripts must already be applied before it runs, e.g.:
8
+ #
9
+ # class BackfillWidgetType < ActiveRecord::Migration[8.1]
10
+ # requires_fix(3)
11
+ #
12
+ # def change
13
+ # ...
14
+ # end
15
+ # end
16
+ #
17
+ # Checked whenever the migration actually runs (so plain `db:migrate` still refuses to run it
18
+ # out of order), and read back ahead of time by YiffSpace::MigrationSync to order
19
+ # `fixes:migrate_all` correctly. Included into ActiveRecord::Migration by
20
+ # YiffSpace::Fixers::Engine.
21
+ module RequiresFix
22
+ # Raised when a migration runs before one of its required fixes has been applied.
23
+ class FixRequired < StandardError; end
24
+
25
+ def self.included(base)
26
+ base.extend(ClassMethods)
27
+ base.prepend(InstanceMethods)
28
+ end
29
+
30
+ module ClassMethods
31
+ # id_or_name: a fix's leading id (e.g. 3, covers every step sharing that id) or its full
32
+ # file name (e.g. "2_1_first_step") for one specific step - see YiffSpace::FixTracker#resolve.
33
+ def requires_fix(id_or_name)
34
+ required_fixes << id_or_name
35
+ end
36
+
37
+ def required_fixes
38
+ @required_fixes ||= []
39
+ end
40
+ end
41
+
42
+ module InstanceMethods
43
+ def migrate(direction)
44
+ check_required_fixes! if direction == :up
45
+
46
+ super
47
+ end
48
+
49
+ private
50
+
51
+ def check_required_fixes!
52
+ self.class.required_fixes.each do |id_or_name|
53
+ names = YiffSpace::FixTracker.resolve(id_or_name)
54
+ raise(FixRequired, "#{self.class.name} requires fix #{id_or_name.inspect}, which does not exist") if names.empty?
55
+
56
+ missing = names.reject { |name| YiffSpace::FixTracker.applied?(name) }
57
+ raise(FixRequired, "#{self.class.name} requires #{missing.join(', ')} to be applied first") if missing.any?
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Fixers
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("yiffspace/core")
4
+ require("zeitwerk")
5
+
6
+ loader = Zeitwerk::Loader.for_gem_extension(YiffSpace)
7
+ loader.ignore("#{__dir__}/fixers/engine.rb")
8
+ loader.setup
9
+
10
+ # Require the engine eagerly so it registers with Rails before the host app's
11
+ # active_support.initialize_per_engine_zeitwerk_loaders initializer runs - same reasoning as
12
+ # yiffspace's own engine.rb/yiffspace-auth's auth/engine.rb.
13
+ require_relative("fixers/engine") if defined?(Rails)
14
+
15
+ module YiffSpace
16
+ class Configuration
17
+ # Named `--steps` presets for `bin/rails generate yiffspace:fixer`. See
18
+ # YiffSpace::Configuration::FixerTemplates.
19
+ def fixer_templates
20
+ @fixer_templates ||= FixerTemplates.new
21
+ end
22
+
23
+ # Directory scanned for YiffSpace::FixerTemplate subclasses (see FixerTemplate) - defaults to
24
+ # db/fixer_templates in the host app.
25
+ attr_writer(:fixer_templates_path)
26
+
27
+ def fixer_templates_path
28
+ @fixer_templates_path ||= Rails.root.join("db/fixer_templates")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ # Computes and applies pending db/migrate/*.rb migrations and db/fixes/*.rb fixes together, in
5
+ # whichever order their cross requirements demand - a fix can require a migration
6
+ # (YiffSpace::FixTracker.requires_migration!) and a migration can require a fix
7
+ # (YiffSpace::Fixers::RequiresFix#requires_fix). Migrations stay ordered among themselves by
8
+ # version, fixes among themselves by YiffSpace::FixTracker.sort_key, and those cross
9
+ # requirements are woven in via a topological sort. #plan/#apply are used by `rake
10
+ # fixes:migrate_all` (see lib/tasks/fixes.rake).
11
+ module MigrationSync
12
+ # A single item in the computed #plan - kind is :migration (ref a MigrationProxy) or :fix
13
+ # (ref a fix name).
14
+ Step = Struct.new(:kind, :ref, :key)
15
+
16
+ # Raised when a fix/migration requires something that isn't findable, or requirements form a
17
+ # cycle (e.g. a fix requires a migration that itself requires that same fix).
18
+ class UnresolvableOrder < StandardError; end
19
+
20
+ module_function
21
+
22
+ # The ordered list of pending Steps - migrations and fixes interleaved wherever a
23
+ # requires_fix/requires_migration! forces it, otherwise each kept in its own natural order.
24
+ def plan
25
+ migrations = pending_migrations
26
+ fixes = FixTracker.pending
27
+
28
+ steps = {}
29
+ migrations.each { |m| steps[[:migration, m.version]] = Step.new(:migration, m, [:migration, m.version]) }
30
+ fixes.each { |f| steps[[:fix, f]] = Step.new(:fix, f, [:fix, f]) }
31
+
32
+ edges = Hash.new { |h, k| h[k] = [] }
33
+ indegree = steps.each_key.index_with(0)
34
+
35
+ link = lambda do |before, after|
36
+ next unless steps.key?(before) && steps.key?(after)
37
+
38
+ edges[before] << after
39
+ indegree[after] += 1
40
+ end
41
+
42
+ # Preserve each kind's own natural order as implicit dependencies.
43
+ migrations.each_cons(2) { |a, b| link.call([:migration, a.version], [:migration, b.version]) }
44
+ fixes.each_cons(2) { |a, b| link.call([:fix, a], [:fix, b]) }
45
+
46
+ link_fix_requirements(fixes, link)
47
+ link_migration_requirements(migrations, link)
48
+
49
+ topological_sort(steps, edges, indegree)
50
+ end
51
+
52
+ def link_fix_requirements(fixes, link)
53
+ fixes.each do |name|
54
+ version = FixTracker.required_migration_for(name)
55
+ next unless version
56
+ raise(UnresolvableOrder, "#{name} requires migration #{version}, which does not exist") unless migration_exists?(version)
57
+
58
+ link.call([:migration, version.to_i], [:fix, name])
59
+ end
60
+ end
61
+
62
+ def link_migration_requirements(migrations, link)
63
+ migrations.each do |m|
64
+ migration_class_for(m).required_fixes.each do |id_or_name|
65
+ names = FixTracker.resolve(id_or_name)
66
+ raise(UnresolvableOrder, "#{m.name} requires fix #{id_or_name.inspect}, which does not exist") if names.empty?
67
+
68
+ names.each { |name| link.call([:fix, name], [:migration, m.version]) }
69
+ end
70
+ end
71
+ end
72
+
73
+ # Kahn's algorithm - ready nodes are processed FIFO in `steps`' insertion order (migrations
74
+ # before fixes when both are ready at once), so requirements only ever reorder things when
75
+ # they actually have to.
76
+ def topological_sort(steps, edges, indegree)
77
+ ready = indegree.select { |_, count| count.zero? }.keys
78
+ ordered = []
79
+
80
+ until ready.empty?
81
+ key = ready.shift
82
+ ordered << steps.fetch(key)
83
+
84
+ edges[key].each do |dependent|
85
+ indegree[dependent] -= 1
86
+ ready << dependent if indegree[dependent].zero?
87
+ end
88
+ end
89
+
90
+ return ordered if ordered.size == steps.size
91
+
92
+ stuck = (steps.keys - ordered.map(&:key)).map { |kind, ref| "#{kind}:#{ref}" }
93
+ raise(UnresolvableOrder, "circular requirement between #{stuck.join(', ')}")
94
+ end
95
+
96
+ def describe(step)
97
+ case step.kind
98
+ when :migration then "Migrating #{step.ref.name} (#{step.ref.version})..."
99
+ when :fix then "Running #{step.ref}..."
100
+ end
101
+ end
102
+
103
+ # Applies a single Step from #plan - a migration is brought up to (and including) its own
104
+ # version, a fix is run and recorded, same as FixTracker.run!.
105
+ def apply(step)
106
+ case step.kind
107
+ when :migration
108
+ migration_context.up(step.ref.version) { |candidate| candidate.version == step.ref.version }
109
+ when :fix
110
+ FixTracker.run!(FixTracker.fix_path(step.ref))
111
+ end
112
+ end
113
+
114
+ def pending_migrations
115
+ applied = migration_context.get_all_versions
116
+ migration_context.migrations.reject { |m| applied.include?(m.version) }
117
+ end
118
+
119
+ def migration_exists?(version)
120
+ migration_context.migrations.any? { |m| m.version == version.to_i }
121
+ end
122
+
123
+ # MigrationProxy defers loading the migration class until needed, via a private #migration
124
+ # accessor - reading `required_fixes` off it needs that class loaded early too, so reach past
125
+ # that privacy rather than reimplementing Rails' own file-loading/constantizing.
126
+ def migration_class_for(proxy)
127
+ proxy.send(:migration).class
128
+ end
129
+
130
+ def migration_context
131
+ FixTracker.migration_context
132
+ end
133
+ end
134
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yiffspace-fixers
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Donovan_DMC
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-09-04 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: yiffspace-core
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.2.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.2.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: zeitwerk
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.6'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.6'
54
+ description: One-time db/fixes scripts and their generators, for https://yiff.space
55
+ and related projects
56
+ email:
57
+ - hewwo@yiff.rocks
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE
64
+ - README.md
65
+ - Rakefile
66
+ - lib/generators/yiffspace/fixer/USAGE
67
+ - lib/generators/yiffspace/fixer/fixer_generator.rb
68
+ - lib/generators/yiffspace/fixer/templates/fixer.rb
69
+ - lib/generators/yiffspace/install/fixes_generator.rb
70
+ - lib/generators/yiffspace/install/templates/create_fixes.rb.erb
71
+ - lib/tasks/fixes.rake
72
+ - lib/yiffspace/configuration/fixer_templates.rb
73
+ - lib/yiffspace/fix_tracker.rb
74
+ - lib/yiffspace/fixer_template.rb
75
+ - lib/yiffspace/fixers.rb
76
+ - lib/yiffspace/fixers/engine.rb
77
+ - lib/yiffspace/fixers/requires_fix.rb
78
+ - lib/yiffspace/fixers/version.rb
79
+ - lib/yiffspace/migration_sync.rb
80
+ homepage: https://yiff.space
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ allowed_push_host: https://rubygems.org
85
+ homepage_uri: https://yiff.space
86
+ source_code_uri: https://github.com/YiffSpace/Gem/tree/master/yiffspace-fixers
87
+ changelog_uri: https://github.com/YiffSpace/Gem/blob/master/yiffspace-fixers/CHANGELOG.md
88
+ rubygems_mfa_required: 'true'
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 3.4.1
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.6.2
104
+ specification_version: 4
105
+ summary: One-time db/fixes scripts and their generators, for https://yiff.space and
106
+ related projects
107
+ test_files: []