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)



40
41
42
43
44
# File 'lib/familia/migration/runner.rb', line 40

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



32
33
34
# File 'lib/familia/migration/runner.rb', line 32

def logger
  @logger
end

#migrationsArray<Class> (readonly)

Returns Migration classes to operate on.

Returns:

  • (Array<Class>)

    Migration classes to operate on



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

def migrations
  @migrations
end

#registryRegistry (readonly)

Returns Registry for tracking applied migrations.

Returns:

  • (Registry)

    Registry for tracking applied migrations



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

def registry
  @registry
end

Instance Method Details

#pendingArray<Class>

Get all pending (unapplied) migrations.

Returns:

  • (Array<Class>)

    Migration classes that haven't been applied



80
81
82
# File 'lib/familia/migration/runner.rb', line 80

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:



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
237
238
# File 'lib/familia/migration/runner.rb', line 200

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



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

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)


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
186
187
# File 'lib/familia/migration/runner.rb', line 151

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


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

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)


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

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