familia 2.0.0.pre26 → 2.1.0
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 +4 -4
- data/CHANGELOG.rst +94 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +12 -2
- data/README.md +1 -3
- data/docs/guides/feature-encrypted-fields.md +1 -1
- data/docs/guides/feature-expiration.md +1 -1
- data/docs/guides/feature-quantization.md +1 -1
- data/docs/guides/writing-migrations.md +345 -0
- data/docs/overview.md +7 -7
- data/docs/reference/api-technical.md +103 -7
- data/examples/migrations/v1_to_v2_serialization_migration.rb +374 -0
- data/examples/schemas/customer.json +33 -0
- data/examples/schemas/session.json +27 -0
- data/familia.gemspec +3 -2
- data/lib/familia/features/schema_validation.rb +139 -0
- data/lib/familia/migration/base.rb +447 -0
- data/lib/familia/migration/errors.rb +31 -0
- data/lib/familia/migration/model.rb +418 -0
- data/lib/familia/migration/pipeline.rb +226 -0
- data/lib/familia/migration/rake_tasks.rake +3 -0
- data/lib/familia/migration/rake_tasks.rb +160 -0
- data/lib/familia/migration/registry.rb +364 -0
- data/lib/familia/migration/runner.rb +311 -0
- data/lib/familia/migration/script.rb +234 -0
- data/lib/familia/migration.rb +43 -0
- data/lib/familia/schema_registry.rb +173 -0
- data/lib/familia/settings.rb +63 -1
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -0
- data/try/features/schema_registry_try.rb +193 -0
- data/try/features/schema_validation_feature_try.rb +218 -0
- data/try/migration/base_try.rb +226 -0
- data/try/migration/errors_try.rb +67 -0
- data/try/migration/integration_try.rb +451 -0
- data/try/migration/model_try.rb +431 -0
- data/try/migration/pipeline_try.rb +460 -0
- data/try/migration/rake_tasks_try.rb +61 -0
- data/try/migration/registry_try.rb +199 -0
- data/try/migration/runner_try.rb +311 -0
- data/try/migration/schema_validation_try.rb +201 -0
- data/try/migration/script_try.rb +192 -0
- data/try/migration/v1_to_v2_serialization_try.rb +513 -0
- data/try/performance/benchmarks_try.rb +11 -12
- metadata +45 -27
- data/docs/migrating/v2.0.0-pre.md +0 -84
- data/docs/migrating/v2.0.0-pre11.md +0 -253
- data/docs/migrating/v2.0.0-pre12.md +0 -306
- data/docs/migrating/v2.0.0-pre13.md +0 -95
- data/docs/migrating/v2.0.0-pre14.md +0 -37
- data/docs/migrating/v2.0.0-pre18.md +0 -58
- data/docs/migrating/v2.0.0-pre19.md +0 -197
- data/docs/migrating/v2.0.0-pre22.md +0 -241
- data/docs/migrating/v2.0.0-pre5.md +0 -131
- data/docs/migrating/v2.0.0-pre6.md +0 -154
- data/docs/migrating/v2.0.0-pre7.md +0 -222
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rake'
|
|
4
|
+
require_relative '../migration'
|
|
5
|
+
|
|
6
|
+
module Familia
|
|
7
|
+
module Migration
|
|
8
|
+
# RakeTasks provides a set of Rake tasks for managing Familia migrations.
|
|
9
|
+
#
|
|
10
|
+
# Tasks are installed automatically when this file is required. Available tasks:
|
|
11
|
+
#
|
|
12
|
+
# familia:migrate - Run all pending migrations
|
|
13
|
+
# familia:migrate:run - Run all pending migrations
|
|
14
|
+
# familia:migrate:status - Show migration status table
|
|
15
|
+
# familia:migrate:dry_run - Preview pending migrations (dry run)
|
|
16
|
+
# familia:migrate:rollback[ID] - Rollback a specific migration
|
|
17
|
+
# familia:migrate:validate - Validate migration dependencies
|
|
18
|
+
# familia:migrate:schema_drift - List models with schema drift
|
|
19
|
+
#
|
|
20
|
+
# @example Loading tasks in a Rakefile
|
|
21
|
+
# require 'familia/migration/rake_tasks'
|
|
22
|
+
#
|
|
23
|
+
# @example Loading tasks via .rake file
|
|
24
|
+
# load 'familia/migration/rake_tasks.rake'
|
|
25
|
+
#
|
|
26
|
+
class RakeTasks
|
|
27
|
+
include Rake::DSL
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
define_tasks
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def define_tasks
|
|
34
|
+
namespace :familia do
|
|
35
|
+
namespace :migrate do
|
|
36
|
+
desc 'Run all pending migrations'
|
|
37
|
+
task :run do
|
|
38
|
+
runner = Runner.new
|
|
39
|
+
results = runner.run(dry_run: false)
|
|
40
|
+
print_results(results)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc 'Show migration status table'
|
|
44
|
+
task :status do
|
|
45
|
+
runner = Runner.new
|
|
46
|
+
print_status(runner.status)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc 'Preview pending migrations (dry run)'
|
|
50
|
+
task :dry_run do
|
|
51
|
+
runner = Runner.new
|
|
52
|
+
results = runner.run(dry_run: true)
|
|
53
|
+
print_results(results)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc 'Rollback a specific migration'
|
|
57
|
+
task :rollback, [:id] do |_t, args|
|
|
58
|
+
abort 'Usage: rake familia:migrate:rollback[MIGRATION_ID]' unless args[:id]
|
|
59
|
+
|
|
60
|
+
runner = Runner.new
|
|
61
|
+
result = runner.rollback(args[:id])
|
|
62
|
+
print_result(result)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
desc 'Validate migration dependencies'
|
|
66
|
+
task :validate do
|
|
67
|
+
runner = Runner.new
|
|
68
|
+
issues = runner.validate
|
|
69
|
+
|
|
70
|
+
if issues.empty?
|
|
71
|
+
puts 'All migrations valid'
|
|
72
|
+
else
|
|
73
|
+
puts "Found #{issues.size} issue(s):"
|
|
74
|
+
issues.each do |issue|
|
|
75
|
+
puts " - #{issue[:type]}: #{issue[:message] || issue[:dependency] || issue[:migration_id]}"
|
|
76
|
+
end
|
|
77
|
+
exit 1
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
desc 'List models with schema drift'
|
|
82
|
+
task :schema_drift do
|
|
83
|
+
registry = Registry.new
|
|
84
|
+
drift = registry.schema_drift
|
|
85
|
+
|
|
86
|
+
if drift.empty?
|
|
87
|
+
puts 'No schema drift detected'
|
|
88
|
+
else
|
|
89
|
+
puts 'Models with schema drift:'
|
|
90
|
+
drift.each { |model| puts " - #{model}" }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Shortcut: familia:migrate runs all pending
|
|
96
|
+
desc 'Run all pending migrations'
|
|
97
|
+
task migrate: 'migrate:run'
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def print_status(status_list)
|
|
104
|
+
puts 'Migration Status:'
|
|
105
|
+
puts '-' * 80
|
|
106
|
+
|
|
107
|
+
applied_count = 0
|
|
108
|
+
pending_count = 0
|
|
109
|
+
|
|
110
|
+
status_list.each do |entry|
|
|
111
|
+
if entry[:status] == :applied
|
|
112
|
+
applied_count += 1
|
|
113
|
+
time_str = entry[:applied_at]&.strftime('%Y-%m-%d %H:%M') || 'unknown'
|
|
114
|
+
puts " Applied #{entry[:migration_id].ljust(45)} #{time_str}"
|
|
115
|
+
else
|
|
116
|
+
pending_count += 1
|
|
117
|
+
puts " Pending #{entry[:migration_id]}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
puts '-' * 80
|
|
122
|
+
puts "Total: #{status_list.size} (#{applied_count} applied, #{pending_count} pending)"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def print_results(results)
|
|
126
|
+
return puts 'No migrations to run' if results.empty?
|
|
127
|
+
|
|
128
|
+
results.each do |result|
|
|
129
|
+
status_indicator = case result[:status]
|
|
130
|
+
when :success then 'Applied'
|
|
131
|
+
when :skipped then 'Skipped'
|
|
132
|
+
when :failed then 'Failed'
|
|
133
|
+
when :rolled_back then 'Rolled back'
|
|
134
|
+
else 'Unknown'
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
dry_run_note = result[:dry_run] ? ' (dry run)' : ''
|
|
138
|
+
puts "#{status_indicator}: #{result[:migration_id]}#{dry_run_note}"
|
|
139
|
+
|
|
140
|
+
if result[:error]
|
|
141
|
+
puts " Error: #{result[:error]}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if result[:stats] && !result[:stats].empty?
|
|
145
|
+
result[:stats].each do |key, value|
|
|
146
|
+
puts " #{key}: #{value}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def print_result(result)
|
|
153
|
+
print_results([result])
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Auto-install tasks when loaded
|
|
160
|
+
Familia::Migration::RakeTasks.new
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'digest'
|
|
3
|
+
|
|
4
|
+
module Familia
|
|
5
|
+
module Migration
|
|
6
|
+
# Registry provides Redis-backed tracking for migration state.
|
|
7
|
+
#
|
|
8
|
+
# Storage Schema (Redis keys):
|
|
9
|
+
# {prefix}:applied - Sorted Set (member=migration_id, score=timestamp)
|
|
10
|
+
# {prefix}:metadata - Hash (field=migration_id, value=JSON metadata)
|
|
11
|
+
# {prefix}:schema - Hash (field=model_name, value=schema_digest)
|
|
12
|
+
# {prefix}:backup:{id} - Hash with TTL for rollback data
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# registry = Familia::Migration::Registry.new
|
|
16
|
+
# registry.applied?('20260131_add_status_field') # => false
|
|
17
|
+
# registry.record_applied(migration, stats)
|
|
18
|
+
# registry.applied?('20260131_add_status_field') # => true
|
|
19
|
+
#
|
|
20
|
+
class Registry
|
|
21
|
+
# @return [Redis, nil] The Redis client for this registry
|
|
22
|
+
attr_reader :redis
|
|
23
|
+
|
|
24
|
+
# @return [String] The key prefix for all registry data
|
|
25
|
+
attr_reader :prefix
|
|
26
|
+
|
|
27
|
+
# Initialize a new Registry instance.
|
|
28
|
+
#
|
|
29
|
+
# @param redis [Redis, nil] Redis client (defaults to Familia.dbclient)
|
|
30
|
+
# @param prefix [String, nil] Key prefix (defaults to config.migrations_key)
|
|
31
|
+
#
|
|
32
|
+
def initialize(redis: nil, prefix: nil)
|
|
33
|
+
@redis = redis
|
|
34
|
+
@prefix = prefix || Familia::Migration.config.migrations_key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get the Redis client, using lazy initialization.
|
|
38
|
+
#
|
|
39
|
+
# @return [Redis] The Redis client
|
|
40
|
+
#
|
|
41
|
+
def client
|
|
42
|
+
@redis ||= Familia.dbclient
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# --- Query Methods ---
|
|
46
|
+
|
|
47
|
+
# Check if a migration has been applied.
|
|
48
|
+
#
|
|
49
|
+
# @param migration_id [String] The migration identifier
|
|
50
|
+
# @return [Boolean] true if the migration is in the applied set
|
|
51
|
+
#
|
|
52
|
+
def applied?(migration_id)
|
|
53
|
+
client.zscore(applied_key, migration_id.to_s) != nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get the timestamp when a migration was applied.
|
|
57
|
+
#
|
|
58
|
+
# @param migration_id [String] The migration identifier
|
|
59
|
+
# @return [Time, nil] The time the migration was applied, or nil if not applied
|
|
60
|
+
#
|
|
61
|
+
def applied_at(migration_id)
|
|
62
|
+
score = client.zscore(applied_key, migration_id.to_s)
|
|
63
|
+
return nil if score.nil?
|
|
64
|
+
|
|
65
|
+
Time.at(score)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get all applied migrations with their timestamps.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<Hash>] Array of hashes with :migration_id and :applied_at keys
|
|
71
|
+
#
|
|
72
|
+
def all_applied
|
|
73
|
+
# ZRANGE with WITHSCORES returns [member, score, member, score, ...]
|
|
74
|
+
results = client.zrange(applied_key, 0, -1, withscores: true)
|
|
75
|
+
|
|
76
|
+
results.map do |migration_id, score|
|
|
77
|
+
{
|
|
78
|
+
migration_id: migration_id,
|
|
79
|
+
applied_at: Time.at(score),
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Filter a list of migrations to only those not yet applied.
|
|
85
|
+
#
|
|
86
|
+
# @param all_migrations [Array<Class>] All migration classes
|
|
87
|
+
# @return [Array<Class>] Migration classes that haven't been applied
|
|
88
|
+
#
|
|
89
|
+
def pending(all_migrations)
|
|
90
|
+
return [] if all_migrations.nil? || all_migrations.empty?
|
|
91
|
+
|
|
92
|
+
# Batch fetch all applied migration IDs in a single Redis call
|
|
93
|
+
applied_ids = client.zrange(applied_key, 0, -1).to_set
|
|
94
|
+
|
|
95
|
+
all_migrations.reject do |migration|
|
|
96
|
+
migration_id = extract_migration_id(migration)
|
|
97
|
+
applied_ids.include?(migration_id)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get metadata for a specific migration.
|
|
102
|
+
#
|
|
103
|
+
# @param migration_id [String] The migration identifier
|
|
104
|
+
# @return [Hash, nil] Parsed JSON metadata or nil if not found
|
|
105
|
+
#
|
|
106
|
+
def metadata(migration_id)
|
|
107
|
+
json = client.hget(metadata_key, migration_id.to_s)
|
|
108
|
+
return nil if json.nil?
|
|
109
|
+
|
|
110
|
+
JSON.parse(json, symbolize_names: true)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get the status of all migrations.
|
|
114
|
+
#
|
|
115
|
+
# @param all_migrations [Array<Class>] All migration classes
|
|
116
|
+
# @return [Array<Hash>] Array of status hashes with :migration_id, :status, :applied_at
|
|
117
|
+
#
|
|
118
|
+
def status(all_migrations)
|
|
119
|
+
return [] if all_migrations.nil? || all_migrations.empty?
|
|
120
|
+
|
|
121
|
+
# Batch fetch all applied migrations with timestamps in a single Redis call
|
|
122
|
+
applied_info = all_applied.each_with_object({}) do |entry, hash|
|
|
123
|
+
hash[entry[:migration_id]] = entry[:applied_at]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
all_migrations.map do |migration|
|
|
127
|
+
migration_id = extract_migration_id(migration)
|
|
128
|
+
timestamp = applied_info[migration_id]
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
migration_id: migration_id,
|
|
132
|
+
status: timestamp ? :applied : :pending,
|
|
133
|
+
applied_at: timestamp,
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# --- Recording Methods ---
|
|
139
|
+
|
|
140
|
+
# Record that a migration has been applied.
|
|
141
|
+
#
|
|
142
|
+
# @param migration [Class, Object] The migration class or instance
|
|
143
|
+
# @param stats [Hash] Statistics from the migration run
|
|
144
|
+
# @option stats [Float] :duration_ms Duration in milliseconds
|
|
145
|
+
# @option stats [Integer] :keys_scanned Number of keys scanned
|
|
146
|
+
# @option stats [Integer] :keys_modified Number of keys modified
|
|
147
|
+
# @option stats [Integer] :errors Number of errors
|
|
148
|
+
# @option stats [Boolean] :reversible Whether the migration is reversible
|
|
149
|
+
#
|
|
150
|
+
def record_applied(migration, stats = {})
|
|
151
|
+
migration_id = extract_migration_id(migration)
|
|
152
|
+
now = Time.now
|
|
153
|
+
|
|
154
|
+
# ZADD to applied set with current timestamp
|
|
155
|
+
client.zadd(applied_key, now.to_f, migration_id)
|
|
156
|
+
|
|
157
|
+
# Build metadata
|
|
158
|
+
meta = {
|
|
159
|
+
status: 'applied',
|
|
160
|
+
applied_at: now.iso8601,
|
|
161
|
+
duration_ms: stats[:duration_ms] || 0,
|
|
162
|
+
keys_scanned: stats[:keys_scanned] || 0,
|
|
163
|
+
keys_modified: stats[:keys_modified] || 0,
|
|
164
|
+
errors: stats[:errors] || 0,
|
|
165
|
+
reversible: stats[:reversible] || false,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# HSET metadata JSON
|
|
169
|
+
client.hset(metadata_key, migration_id, JSON.generate(meta))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Record that a migration has been rolled back.
|
|
173
|
+
#
|
|
174
|
+
# @param migration_id [String] The migration identifier
|
|
175
|
+
#
|
|
176
|
+
def record_rollback(migration_id)
|
|
177
|
+
migration_id = migration_id.to_s
|
|
178
|
+
|
|
179
|
+
# Remove from applied set
|
|
180
|
+
client.zrem(applied_key, migration_id)
|
|
181
|
+
|
|
182
|
+
# Update metadata to show rolled_back status
|
|
183
|
+
existing = metadata(migration_id)
|
|
184
|
+
meta = existing || {}
|
|
185
|
+
meta[:status] = 'rolled_back'
|
|
186
|
+
meta[:rolled_back_at] = Time.now.iso8601
|
|
187
|
+
|
|
188
|
+
client.hset(metadata_key, migration_id, JSON.generate(meta))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# --- Schema Tracking Methods ---
|
|
192
|
+
|
|
193
|
+
# Calculate the schema digest for a model class.
|
|
194
|
+
#
|
|
195
|
+
# @param model_class [Class] A Familia::Horreum subclass
|
|
196
|
+
# @return [String] SHA256 hex digest of the field schema
|
|
197
|
+
#
|
|
198
|
+
def schema_digest(model_class)
|
|
199
|
+
fields = model_class.fields.sort
|
|
200
|
+
field_types = model_class.field_types
|
|
201
|
+
|
|
202
|
+
field_strings = fields.map do |field|
|
|
203
|
+
type = field_types[field] || 'unknown'
|
|
204
|
+
"#{field}:#{type}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
Digest::SHA256.hexdigest(field_strings.join('|'))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Store the current schema digest for a model class.
|
|
211
|
+
#
|
|
212
|
+
# @param model_class [Class] A Familia::Horreum subclass
|
|
213
|
+
#
|
|
214
|
+
def store_schema(model_class)
|
|
215
|
+
model_name = model_class.name || model_class.to_s
|
|
216
|
+
digest = schema_digest(model_class)
|
|
217
|
+
client.hset(schema_key, model_name, digest)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Get the stored schema digest for a model class.
|
|
221
|
+
#
|
|
222
|
+
# @param model_class [Class] A Familia::Horreum subclass
|
|
223
|
+
# @return [String, nil] The stored digest or nil if not found
|
|
224
|
+
#
|
|
225
|
+
def stored_schema(model_class)
|
|
226
|
+
model_name = model_class.name || model_class.to_s
|
|
227
|
+
client.hget(schema_key, model_name)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Check if the schema has changed for a model class.
|
|
231
|
+
#
|
|
232
|
+
# @param model_class [Class] A Familia::Horreum subclass
|
|
233
|
+
# @return [Boolean] true if schema differs from stored version
|
|
234
|
+
#
|
|
235
|
+
def schema_changed?(model_class)
|
|
236
|
+
stored = stored_schema(model_class)
|
|
237
|
+
return false if stored.nil? # No stored schema = no drift
|
|
238
|
+
|
|
239
|
+
stored != schema_digest(model_class)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Get a list of model classes with changed schemas.
|
|
243
|
+
#
|
|
244
|
+
# @return [Array<String>] Model names with schema drift
|
|
245
|
+
#
|
|
246
|
+
def schema_drift
|
|
247
|
+
# Get all stored schemas
|
|
248
|
+
stored = client.hgetall(schema_key)
|
|
249
|
+
return [] if stored.empty?
|
|
250
|
+
|
|
251
|
+
drifted = []
|
|
252
|
+
|
|
253
|
+
stored.each do |model_name, stored_digest|
|
|
254
|
+
# Try to find the model class
|
|
255
|
+
model_class = find_model_class(model_name)
|
|
256
|
+
next if model_class.nil?
|
|
257
|
+
|
|
258
|
+
current_digest = schema_digest(model_class)
|
|
259
|
+
drifted << model_name if stored_digest != current_digest
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
drifted
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# --- Backup Methods ---
|
|
266
|
+
|
|
267
|
+
# Store a backup of a field value for potential rollback.
|
|
268
|
+
#
|
|
269
|
+
# @param migration_id [String] The migration identifier
|
|
270
|
+
# @param key [String] The Redis key being modified
|
|
271
|
+
# @param field [String] The field name within the key
|
|
272
|
+
# @param value [String] The original value to preserve
|
|
273
|
+
#
|
|
274
|
+
def backup_field(migration_id, key, field, value)
|
|
275
|
+
bkey = backup_key(migration_id)
|
|
276
|
+
client.hset(bkey, "#{key}:#{field}", value)
|
|
277
|
+
client.expire(bkey, Familia::Migration.config.backup_ttl)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Restore all backed up fields for a migration.
|
|
281
|
+
#
|
|
282
|
+
# @param migration_id [String] The migration identifier
|
|
283
|
+
# @return [Integer] Number of fields restored
|
|
284
|
+
#
|
|
285
|
+
def restore_backup(migration_id)
|
|
286
|
+
bkey = backup_key(migration_id)
|
|
287
|
+
backup_data = client.hgetall(bkey)
|
|
288
|
+
return 0 if backup_data.empty?
|
|
289
|
+
|
|
290
|
+
count = 0
|
|
291
|
+
|
|
292
|
+
backup_data.each do |composite_key, value|
|
|
293
|
+
# Parse "redis_key:field_name" format
|
|
294
|
+
# Note: field_name might contain colons, so we only split on the last colon
|
|
295
|
+
parts = composite_key.rpartition(':')
|
|
296
|
+
redis_key = parts[0]
|
|
297
|
+
field_name = parts[2]
|
|
298
|
+
|
|
299
|
+
next if redis_key.empty? || field_name.empty?
|
|
300
|
+
|
|
301
|
+
client.hset(redis_key, field_name, value)
|
|
302
|
+
count += 1
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
count
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Clear the backup data for a migration.
|
|
309
|
+
#
|
|
310
|
+
# @param migration_id [String] The migration identifier
|
|
311
|
+
#
|
|
312
|
+
def clear_backup(migration_id)
|
|
313
|
+
client.del(backup_key(migration_id))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
private
|
|
317
|
+
|
|
318
|
+
# --- Key Helpers ---
|
|
319
|
+
|
|
320
|
+
def applied_key
|
|
321
|
+
"#{@prefix}:applied"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def metadata_key
|
|
325
|
+
"#{@prefix}:metadata"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def schema_key
|
|
329
|
+
"#{@prefix}:schema"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def backup_key(migration_id)
|
|
333
|
+
"#{@prefix}:backup:#{migration_id}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# --- Utility Methods ---
|
|
337
|
+
|
|
338
|
+
# Extract migration ID from various input types.
|
|
339
|
+
#
|
|
340
|
+
# @param migration [Class, Object, String] Migration class, instance, or ID
|
|
341
|
+
# @return [String] The migration identifier
|
|
342
|
+
#
|
|
343
|
+
def extract_migration_id(migration)
|
|
344
|
+
case migration
|
|
345
|
+
when String
|
|
346
|
+
migration
|
|
347
|
+
when Class
|
|
348
|
+
migration.respond_to?(:migration_id) ? migration.migration_id : migration.name
|
|
349
|
+
else
|
|
350
|
+
migration.class.respond_to?(:migration_id) ? migration.class.migration_id : migration.class.name
|
|
351
|
+
end.to_s
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Find a model class by name from Familia's registry.
|
|
355
|
+
#
|
|
356
|
+
# @param model_name [String] The class name
|
|
357
|
+
# @return [Class, nil] The model class or nil
|
|
358
|
+
#
|
|
359
|
+
def find_model_class(model_name)
|
|
360
|
+
Familia.members.find { |m| m.name == model_name }
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|