Class: Familia::Migration::Runner

Inherits:
Object
  • Object
show all
Defined in:
lib/familia/migration/runner.rb

Overview

Runner orchestrates migration execution with dependency resolution.

Provides methods for querying migration status, validating dependencies, and executing migrations in the correct order using topological sorting.

Examples:

Basic usage

runner = Familia::Migration::Runner.new
runner.status        # => Array of migration status hashes
runner.pending       # => Array of unapplied migration classes
runner.run           # => Execute all pending migrations

Dry run

runner.run(dry_run: true)  # Preview without applying changes

Rolling back

runner.rollback('20260131_add_status_field')

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(migrations: nil, registry: nil, logger: nil) ⇒ Runner

Initialize a new Runner instance.

Parameters:

  • migrations (Array<Class>, nil) (defaults to: nil)

    Migration classes (defaults to registered migrations)

  • registry (Registry, nil) (defaults to: nil)

    Registry instance (defaults to new Registry)

  • logger (Logger, nil) (defaults to: nil)

    Logger instance (defaults to Familia.logger)



38
39
40
41
42
# File 'lib/familia/migration/runner.rb', line 38

def initialize(migrations: nil, registry: nil, logger: nil)
  @migrations = migrations || Familia::Migration.migrations
  @registry = registry || Registry.new
  @logger = logger || Familia.logger
end

Instance Attribute Details

#loggerLogger (readonly)

Returns Logger for migration output.

Returns:

  • (Logger)

    Logger for migration output



30
31
32
# File 'lib/familia/migration/runner.rb', line 30

def logger
  @logger
end

#migrationsArray<Class> (readonly)

Returns Migration classes to operate on.

Returns:

  • (Array<Class>)

    Migration classes to operate on



24
25
26
# File 'lib/familia/migration/runner.rb', line 24

def migrations
  @migrations
end

#registryRegistry (readonly)

Returns Registry for tracking applied migrations.

Returns:

  • (Registry)

    Registry for tracking applied migrations



27
28
29
# File 'lib/familia/migration/runner.rb', line 27

def registry
  @registry
end

Instance Method Details

#pendingArray<Class>

Get all pending (unapplied) migrations.

Returns:

  • (Array<Class>)

    Migration classes that haven't been applied



78
79
80
# File 'lib/familia/migration/runner.rb', line 78

def pending
  @registry.pending(@migrations)
end

#rollback(migration_id) ⇒ Hash

Rollback a previously applied migration.

Parameters:

  • migration_id (String)

    The migration identifier to rollback

Returns:

  • (Hash)

    Result hash with keys:

    • :migration_id [String] The migration identifier
    • :status [Symbol] :rolled_back or :failed
    • :error [String] Error message (if failed)

Raises:



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/familia/migration/runner.rb', line 198

def rollback(migration_id)
  klass = resolve_migration(migration_id)

  unless @registry.applied?(migration_id)
    raise Familia::Migration::Errors::NotApplied,
          "Migration #{migration_id} is not applied"
  end

  # Batch fetch all applied migrations to check for dependents
  applied_ids = @registry.all_applied.map { |e| e[:migration_id] }.to_set

  # Check no dependents are applied
  @migrations.each do |m|
    if (m.dependencies || []).include?(migration_id) && applied_ids.include?(m.migration_id)
      raise Familia::Migration::Errors::HasDependents,
            "Cannot rollback: #{m.migration_id} depends on #{migration_id}"
    end
  end

  instance = klass.new

  unless instance.reversible?
    raise Familia::Migration::Errors::NotReversible,
          "Migration #{migration_id} does not have a down method"
  end

  result = { migration_id: migration_id }

  begin
    instance.down
    @registry.record_rollback(migration_id)
    result[:status] = :rolled_back
  rescue StandardError => e
    result[:status] = :failed
    result[:error] = e.message
  end

  result
end

#run(dry_run: false, limit: nil) ⇒ Array<Hash>

Run all pending migrations in dependency order.

Parameters:

  • dry_run (Boolean) (defaults to: false)

    If true, preview without applying changes

  • limit (Integer, nil) (defaults to: nil)

    Maximum number of migrations to run

Returns:

  • (Array<Hash>)

    Results for each migration attempted



125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/familia/migration/runner.rb', line 125

def run(dry_run: false, limit: nil)
  pending_migrations = topological_sort(pending)
  pending_migrations = pending_migrations.first(limit) if limit

  results = []
  pending_migrations.each do |klass|
    result = run_one(klass, dry_run: dry_run)
    results << result
    break if result[:status] == :failed
  end
  results
end

#run_one(migration_class_or_id, dry_run: false) ⇒ Hash

Run a single migration.

Parameters:

  • migration_class_or_id (Class, String)

    Migration class or ID

  • dry_run (Boolean) (defaults to: false)

    If true, preview without applying changes

Returns:

  • (Hash)

    Result hash with keys:

    • :migration_id [String] The migration identifier
    • :dry_run [Boolean] Whether this was a dry run
    • :status [Symbol] :success, :skipped, or :failed
    • :stats [Hash] Statistics from the migration
    • :error [String] Error message (if failed)


149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/familia/migration/runner.rb', line 149

def run_one(migration_class_or_id, dry_run: false)
  klass = resolve_migration(migration_class_or_id)

  # Validate dependencies are applied
  (klass.dependencies || []).each do |dep_id|
    unless @registry.applied?(dep_id)
      raise Familia::Migration::Errors::DependencyNotMet,
            "Dependency #{dep_id} not applied for #{klass.migration_id}"
    end
  end

  instance = klass.new(run: !dry_run)
  instance.prepare

  result = {
    migration_id: klass.migration_id,
    dry_run: dry_run,
    stats: {},
  }

  begin
    if instance.migration_needed?
      instance.migrate
      result[:status] = :success
      result[:stats] = instance.stats
      @registry.record_applied(instance, instance.stats) unless dry_run
    else
      result[:status] = :skipped
    end
  rescue StandardError => e
    result[:status] = :failed
    result[:error] = e.message
    @logger.error { "Migration failed: #{e.message}" }
  end

  result
end

#statusArray<Hash>

Get the status of all migrations.

Returns:

  • (Array<Hash>)

    Array of migration info hashes with keys:

    • :migration_id [String] The migration identifier
    • :description [String] Human-readable description
    • :status [Symbol] :applied or :pending
    • :applied_at [Time, nil] When the migration was applied
    • :reversible [Boolean] Whether the migration has a down method


55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/familia/migration/runner.rb', line 55

def status
  # Batch fetch all applied migrations with timestamps in a single Redis call
  applied_info = @registry.all_applied.each_with_object({}) do |entry, hash|
    hash[entry[:migration_id]] = entry[:applied_at]
  end

  @migrations.map do |klass|
    id = klass.migration_id
    applied_at = applied_info[id]
    {
      migration_id: id,
      description: klass.description,
      status: applied_at ? :applied : :pending,
      applied_at: applied_at,
      reversible: klass.new.reversible?,
    }
  end
end

#validateArray<Hash>

Validate migration dependencies and configuration.

Returns:

  • (Array<Hash>)

    Array of issue hashes with keys:

    • :type [Symbol] Type of issue (:missing_dependency, :circular_dependency)
    • :migration_id [String] Migration with the issue (for missing deps)
    • :dependency [String] Missing dependency ID (for missing deps)
    • :message [String] Error message (for circular deps)


90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/familia/migration/runner.rb', line 90

def validate
  issues = []

  # Check for missing dependencies
  all_ids = @migrations.map(&:migration_id)
  @migrations.each do |klass|
    (klass.dependencies || []).each do |dep_id|
      unless all_ids.include?(dep_id)
        issues << {
          type: :missing_dependency,
          migration_id: klass.migration_id,
          dependency: dep_id,
        }
      end
    end
  end

  # Check for circular dependencies
  begin
    topological_sort(@migrations)
  rescue Familia::Migration::Errors::CircularDependency => e
    issues << { type: :circular_dependency, message: e.message }
  end

  issues
end