Class: Familia::Migration::Registry

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

Overview

Registry provides Redis-backed tracking for migration state.

Storage Schema (Redis keys): #prefix:applied - Sorted Set (member=migration_id, score=timestamp) #prefix:metadata - Hash (field=migration_id, value=JSON metadata) #prefix:schema - Hash (field=model_name, value=schema_digest) #prefix:backup:id - Hash with TTL for rollback data

Examples:

Basic usage

registry = Familia::Migration::Registry.new
registry.applied?('20260131_add_status_field')  # => false
registry.record_applied(migration, stats)
registry.applied?('20260131_add_status_field')  # => true

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(redis: nil, prefix: nil) ⇒ Registry

Initialize a new Registry instance.

Parameters:

  • redis (Redis, nil) (defaults to: nil)

    Redis client (defaults to Familia.dbclient)

  • prefix (String, nil) (defaults to: nil)

    Key prefix (defaults to config.migrations_key)



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

def initialize(redis: nil, prefix: nil)
  @redis = redis
  @prefix = prefix || Familia::Migration.config.migrations_key
end

Instance Attribute Details

#prefixString (readonly)

Returns The key prefix for all registry data.

Returns:

  • (String)

    The key prefix for all registry data



25
26
27
# File 'lib/familia/migration/registry.rb', line 25

def prefix
  @prefix
end

#redisRedis? (readonly)

Returns The Redis client for this registry.

Returns:

  • (Redis, nil)

    The Redis client for this registry



22
23
24
# File 'lib/familia/migration/registry.rb', line 22

def redis
  @redis
end

Instance Method Details

#all_appliedArray<Hash>

Get all applied migrations with their timestamps.

Returns:

  • (Array<Hash>)

    Array of hashes with :migration_id and :applied_at keys



72
73
74
75
76
77
78
79
80
81
82
# File 'lib/familia/migration/registry.rb', line 72

def all_applied
  # ZRANGE with WITHSCORES returns [member, score, member, score, ...]
  results = client.zrange(applied_key, 0, -1, withscores: true)

  results.map do |migration_id, score|
    {
      migration_id: migration_id,
      applied_at: Time.at(score),
    }
  end
end

#applied?(migration_id) ⇒ Boolean

Check if a migration has been applied.

Parameters:

  • migration_id (String)

    The migration identifier

Returns:

  • (Boolean)

    true if the migration is in the applied set



52
53
54
# File 'lib/familia/migration/registry.rb', line 52

def applied?(migration_id)
  client.zscore(applied_key, migration_id.to_s) != nil
end

#applied_at(migration_id) ⇒ Time?

Get the timestamp when a migration was applied.

Parameters:

  • migration_id (String)

    The migration identifier

Returns:

  • (Time, nil)

    The time the migration was applied, or nil if not applied



61
62
63
64
65
66
# File 'lib/familia/migration/registry.rb', line 61

def applied_at(migration_id)
  score = client.zscore(applied_key, migration_id.to_s)
  return nil if score.nil?

  Time.at(score)
end

#backup_field(migration_id, key, field, value) ⇒ Object

Store a backup of a field value for potential rollback.

Parameters:

  • migration_id (String)

    The migration identifier

  • key (String)

    The Redis key being modified

  • field (String)

    The field name within the key

  • value (String)

    The original value to preserve



274
275
276
277
278
# File 'lib/familia/migration/registry.rb', line 274

def backup_field(migration_id, key, field, value)
  bkey = backup_key(migration_id)
  client.hset(bkey, "#{key}:#{field}", value)
  client.expire(bkey, Familia::Migration.config.backup_ttl)
end

#clear_backup(migration_id) ⇒ Object

Clear the backup data for a migration.

Parameters:

  • migration_id (String)

    The migration identifier



312
313
314
# File 'lib/familia/migration/registry.rb', line 312

def clear_backup(migration_id)
  client.del(backup_key(migration_id))
end

#clientRedis

Get the Redis client, using lazy initialization.

Returns:

  • (Redis)

    The Redis client



41
42
43
# File 'lib/familia/migration/registry.rb', line 41

def client
  @redis ||= Familia.dbclient
end

#metadata(migration_id) ⇒ Hash?

Get metadata for a specific migration.

Parameters:

  • migration_id (String)

    The migration identifier

Returns:

  • (Hash, nil)

    Parsed JSON metadata or nil if not found



106
107
108
109
110
111
# File 'lib/familia/migration/registry.rb', line 106

def (migration_id)
  json = client.hget(, migration_id.to_s)
  return nil if json.nil?

  JSON.parse(json, symbolize_names: true)
end

#pending(all_migrations) ⇒ Array<Class>

Filter a list of migrations to only those not yet applied.

Parameters:

  • all_migrations (Array<Class>)

    All migration classes

Returns:

  • (Array<Class>)

    Migration classes that haven't been applied



89
90
91
92
93
94
95
96
97
98
99
# File 'lib/familia/migration/registry.rb', line 89

def pending(all_migrations)
  return [] if all_migrations.nil? || all_migrations.empty?

  # Batch fetch all applied migration IDs in a single Redis call
  applied_ids = client.zrange(applied_key, 0, -1).to_set

  all_migrations.reject do |migration|
    migration_id = extract_migration_id(migration)
    applied_ids.include?(migration_id)
  end
end

#record_applied(migration, stats = {}) ⇒ Object

Record that a migration has been applied.

Parameters:

  • migration (Class, Object)

    The migration class or instance

  • stats (Hash) (defaults to: {})

    Statistics from the migration run

Options Hash (stats):

  • :duration_ms (Float)

    Duration in milliseconds

  • :keys_scanned (Integer)

    Number of keys scanned

  • :keys_modified (Integer)

    Number of keys modified

  • :errors (Integer)

    Number of errors

  • :reversible (Boolean)

    Whether the migration is reversible



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/familia/migration/registry.rb', line 150

def record_applied(migration, stats = {})
  migration_id = extract_migration_id(migration)
  now = Time.now

  # ZADD to applied set with current timestamp
  client.zadd(applied_key, now.to_f, migration_id)

  # Build metadata
  meta = {
    status: 'applied',
    applied_at: now.iso8601,
    duration_ms: stats[:duration_ms] || 0,
    keys_scanned: stats[:keys_scanned] || 0,
    keys_modified: stats[:keys_modified] || 0,
    errors: stats[:errors] || 0,
    reversible: stats[:reversible] || false,
  }

  # HSET metadata JSON
  client.hset(, migration_id, JSON.generate(meta))
end

#record_rollback(migration_id) ⇒ Object

Record that a migration has been rolled back.

Parameters:

  • migration_id (String)

    The migration identifier



176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/familia/migration/registry.rb', line 176

def record_rollback(migration_id)
  migration_id = migration_id.to_s

  # Remove from applied set
  client.zrem(applied_key, migration_id)

  # Update metadata to show rolled_back status
  existing = (migration_id)
  meta = existing || {}
  meta[:status] = 'rolled_back'
  meta[:rolled_back_at] = Time.now.iso8601

  client.hset(, migration_id, JSON.generate(meta))
end

#restore_backup(migration_id) ⇒ Integer

Restore all backed up fields for a migration.

Parameters:

  • migration_id (String)

    The migration identifier

Returns:

  • (Integer)

    Number of fields restored



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/familia/migration/registry.rb', line 285

def restore_backup(migration_id)
  bkey = backup_key(migration_id)
  backup_data = client.hgetall(bkey)
  return 0 if backup_data.empty?

  count = 0

  backup_data.each do |composite_key, value|
    # Parse "redis_key:field_name" format
    # Note: field_name might contain colons, so we only split on the last colon
    parts = composite_key.rpartition(':')
    redis_key = parts[0]
    field_name = parts[2]

    next if redis_key.empty? || field_name.empty?

    client.hset(redis_key, field_name, value)
    count += 1
  end

  count
end

#schema_changed?(model_class) ⇒ Boolean

Check if the schema has changed for a model class.

Parameters:

  • model_class (Class)

    A Familia::Horreum subclass

Returns:

  • (Boolean)

    true if schema differs from stored version



235
236
237
238
239
240
# File 'lib/familia/migration/registry.rb', line 235

def schema_changed?(model_class)
  stored = stored_schema(model_class)
  return false if stored.nil? # No stored schema = no drift

  stored != schema_digest(model_class)
end

#schema_digest(model_class) ⇒ String

Calculate the schema digest for a model class.

Parameters:

  • model_class (Class)

    A Familia::Horreum subclass

Returns:

  • (String)

    SHA256 hex digest of the field schema



198
199
200
201
202
203
204
205
206
207
208
# File 'lib/familia/migration/registry.rb', line 198

def schema_digest(model_class)
  fields = model_class.fields.sort
  field_types = model_class.field_types

  field_strings = fields.map do |field|
    type = field_types[field] || 'unknown'
    "#{field}:#{type}"
  end

  Digest::SHA256.hexdigest(field_strings.join('|'))
end

#schema_driftArray<String>

Get a list of model classes with changed schemas.

Returns:

  • (Array<String>)

    Model names with schema drift



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/familia/migration/registry.rb', line 246

def schema_drift
  # Get all stored schemas
  stored = client.hgetall(schema_key)
  return [] if stored.empty?

  drifted = []

  stored.each do |model_name, stored_digest|
    # Try to find the model class
    model_class = find_model_class(model_name)
    next if model_class.nil?

    current_digest = schema_digest(model_class)
    drifted << model_name if stored_digest != current_digest
  end

  drifted
end

#status(all_migrations) ⇒ Array<Hash>

Get the status of all migrations.

Parameters:

  • all_migrations (Array<Class>)

    All migration classes

Returns:

  • (Array<Hash>)

    Array of status hashes with :migration_id, :status, :applied_at



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/familia/migration/registry.rb', line 118

def status(all_migrations)
  return [] if all_migrations.nil? || all_migrations.empty?

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

  all_migrations.map do |migration|
    migration_id = extract_migration_id(migration)
    timestamp = applied_info[migration_id]

    {
      migration_id: migration_id,
      status: timestamp ? :applied : :pending,
      applied_at: timestamp,
    }
  end
end

#store_schema(model_class) ⇒ Object

Store the current schema digest for a model class.

Parameters:

  • model_class (Class)

    A Familia::Horreum subclass



214
215
216
217
218
# File 'lib/familia/migration/registry.rb', line 214

def store_schema(model_class)
  model_name = model_class.name || model_class.to_s
  digest = schema_digest(model_class)
  client.hset(schema_key, model_name, digest)
end

#stored_schema(model_class) ⇒ String?

Get the stored schema digest for a model class.

Parameters:

  • model_class (Class)

    A Familia::Horreum subclass

Returns:

  • (String, nil)

    The stored digest or nil if not found



225
226
227
228
# File 'lib/familia/migration/registry.rb', line 225

def stored_schema(model_class)
  model_name = model_class.name || model_class.to_s
  client.hget(schema_key, model_name)
end