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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +94 -0
  3. data/Gemfile +3 -0
  4. data/Gemfile.lock +12 -2
  5. data/README.md +1 -3
  6. data/docs/guides/feature-encrypted-fields.md +1 -1
  7. data/docs/guides/feature-expiration.md +1 -1
  8. data/docs/guides/feature-quantization.md +1 -1
  9. data/docs/guides/writing-migrations.md +345 -0
  10. data/docs/overview.md +7 -7
  11. data/docs/reference/api-technical.md +103 -7
  12. data/examples/migrations/v1_to_v2_serialization_migration.rb +374 -0
  13. data/examples/schemas/customer.json +33 -0
  14. data/examples/schemas/session.json +27 -0
  15. data/familia.gemspec +3 -2
  16. data/lib/familia/features/schema_validation.rb +139 -0
  17. data/lib/familia/migration/base.rb +447 -0
  18. data/lib/familia/migration/errors.rb +31 -0
  19. data/lib/familia/migration/model.rb +418 -0
  20. data/lib/familia/migration/pipeline.rb +226 -0
  21. data/lib/familia/migration/rake_tasks.rake +3 -0
  22. data/lib/familia/migration/rake_tasks.rb +160 -0
  23. data/lib/familia/migration/registry.rb +364 -0
  24. data/lib/familia/migration/runner.rb +311 -0
  25. data/lib/familia/migration/script.rb +234 -0
  26. data/lib/familia/migration.rb +43 -0
  27. data/lib/familia/schema_registry.rb +173 -0
  28. data/lib/familia/settings.rb +63 -1
  29. data/lib/familia/version.rb +1 -1
  30. data/lib/familia.rb +1 -0
  31. data/try/features/schema_registry_try.rb +193 -0
  32. data/try/features/schema_validation_feature_try.rb +218 -0
  33. data/try/migration/base_try.rb +226 -0
  34. data/try/migration/errors_try.rb +67 -0
  35. data/try/migration/integration_try.rb +451 -0
  36. data/try/migration/model_try.rb +431 -0
  37. data/try/migration/pipeline_try.rb +460 -0
  38. data/try/migration/rake_tasks_try.rb +61 -0
  39. data/try/migration/registry_try.rb +199 -0
  40. data/try/migration/runner_try.rb +311 -0
  41. data/try/migration/schema_validation_try.rb +201 -0
  42. data/try/migration/script_try.rb +192 -0
  43. data/try/migration/v1_to_v2_serialization_try.rb +513 -0
  44. data/try/performance/benchmarks_try.rb +11 -12
  45. metadata +45 -27
  46. data/docs/migrating/v2.0.0-pre.md +0 -84
  47. data/docs/migrating/v2.0.0-pre11.md +0 -253
  48. data/docs/migrating/v2.0.0-pre12.md +0 -306
  49. data/docs/migrating/v2.0.0-pre13.md +0 -95
  50. data/docs/migrating/v2.0.0-pre14.md +0 -37
  51. data/docs/migrating/v2.0.0-pre18.md +0 -58
  52. data/docs/migrating/v2.0.0-pre19.md +0 -197
  53. data/docs/migrating/v2.0.0-pre22.md +0 -241
  54. data/docs/migrating/v2.0.0-pre5.md +0 -131
  55. data/docs/migrating/v2.0.0-pre6.md +0 -154
  56. data/docs/migrating/v2.0.0-pre7.md +0 -222
@@ -0,0 +1,460 @@
1
+ # try/migration/pipeline_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../support/helpers/test_helpers'
6
+ require_relative '../../lib/familia/migration'
7
+
8
+ Familia.debug = false
9
+
10
+ @redis = Familia.dbclient
11
+ @test_id = "#{Process.pid}_#{Time.now.to_i}"
12
+ @prefix = "familia:test:pipeline:#{@test_id}"
13
+
14
+ @initial_migrations = Familia::Migration.migrations.dup
15
+
16
+ # Test model class for pipeline migrations
17
+ class PipelineTestRecord < Familia::Horreum
18
+ identifier_field :record_id
19
+ field :record_id
20
+ field :name
21
+ field :status
22
+ field :old_field
23
+ field :new_field
24
+ field :migrated_at
25
+ end
26
+
27
+ # Simple Pipeline migration
28
+ class SimplePipelineMigration < Familia::Migration::Pipeline
29
+ self.migration_id = 'pipeline_test_simple'
30
+ self.description = 'Simple pipeline migration test'
31
+
32
+ class << self
33
+ attr_accessor :processed_count
34
+ end
35
+ self.processed_count = 0
36
+
37
+ def prepare
38
+ @model_class = PipelineTestRecord
39
+ @batch_size = 10
40
+ end
41
+
42
+ def should_process?(obj)
43
+ true
44
+ end
45
+
46
+ def build_update_fields(obj)
47
+ self.class.processed_count += 1
48
+ { new_field: 'pipeline_updated' }
49
+ end
50
+ end
51
+
52
+ # Pipeline migration that filters records
53
+ class FilteringPipelineMigration < Familia::Migration::Pipeline
54
+ self.migration_id = 'pipeline_test_filtering'
55
+
56
+ class << self
57
+ attr_accessor :skipped_count, :processed_count
58
+ end
59
+ self.skipped_count = 0
60
+ self.processed_count = 0
61
+
62
+ def prepare
63
+ @model_class = PipelineTestRecord
64
+ @batch_size = 10
65
+ end
66
+
67
+ def should_process?(obj)
68
+ if obj.status == 'skip_me'
69
+ self.class.skipped_count += 1
70
+ track_stat(:skipped)
71
+ return false
72
+ end
73
+ self.class.processed_count += 1
74
+ true
75
+ end
76
+
77
+ def build_update_fields(obj)
78
+ { new_field: 'filtered_update', migrated_at: Time.now.to_i.to_s }
79
+ end
80
+ end
81
+
82
+ # Pipeline migration with empty fields (should skip HMSET)
83
+ class EmptyFieldsPipelineMigration < Familia::Migration::Pipeline
84
+ self.migration_id = 'pipeline_test_empty'
85
+
86
+ class << self
87
+ attr_accessor :build_called_count
88
+ end
89
+ self.build_called_count = 0
90
+
91
+ def prepare
92
+ @model_class = PipelineTestRecord
93
+ @batch_size = 10
94
+ end
95
+
96
+ def should_process?(obj)
97
+ true
98
+ end
99
+
100
+ def build_update_fields(obj)
101
+ self.class.build_called_count += 1
102
+ {} # Return empty hash
103
+ end
104
+ end
105
+
106
+ # Pipeline migration with nil fields
107
+ class NilFieldsPipelineMigration < Familia::Migration::Pipeline
108
+ self.migration_id = 'pipeline_test_nil'
109
+
110
+ def prepare
111
+ @model_class = PipelineTestRecord
112
+ @batch_size = 10
113
+ end
114
+
115
+ def should_process?(obj)
116
+ true
117
+ end
118
+
119
+ def build_update_fields(obj)
120
+ nil # Return nil
121
+ end
122
+ end
123
+
124
+ # Pipeline migration with custom execute_update
125
+ class CustomExecutePipelineMigration < Familia::Migration::Pipeline
126
+ self.migration_id = 'pipeline_test_custom_execute'
127
+
128
+ class << self
129
+ attr_accessor :custom_execute_called, :original_keys
130
+ end
131
+ self.custom_execute_called = 0
132
+ self.original_keys = []
133
+
134
+ def prepare
135
+ @model_class = PipelineTestRecord
136
+ @batch_size = 10
137
+ end
138
+
139
+ def should_process?(obj)
140
+ true
141
+ end
142
+
143
+ def build_update_fields(obj)
144
+ { custom_field: 'custom_value' }
145
+ end
146
+
147
+ def execute_update(pipe, obj, fields, original_key = nil)
148
+ self.class.custom_execute_called += 1
149
+ self.class.original_keys << original_key
150
+ super(pipe, obj, fields, original_key)
151
+ end
152
+ end
153
+
154
+ # Pipeline migration that errors during batch
155
+ class ErrorPipelineMigration < Familia::Migration::Pipeline
156
+ self.migration_id = 'pipeline_test_error'
157
+
158
+ class << self
159
+ attr_accessor :error_triggered
160
+ end
161
+ self.error_triggered = false
162
+
163
+ def prepare
164
+ @model_class = PipelineTestRecord
165
+ @batch_size = 10
166
+ end
167
+
168
+ def should_process?(obj)
169
+ if obj.name == 'trigger_error'
170
+ self.class.error_triggered = true
171
+ raise 'Intentional pipeline error'
172
+ end
173
+ true
174
+ end
175
+
176
+ def build_update_fields(obj)
177
+ { new_field: 'updated' }
178
+ end
179
+ end
180
+
181
+ # Pipeline without should_process? - should raise
182
+ class MissingShouldProcessPipeline < Familia::Migration::Pipeline
183
+ self.migration_id = 'pipeline_test_missing_should'
184
+
185
+ def prepare
186
+ @model_class = PipelineTestRecord
187
+ @batch_size = 10
188
+ end
189
+
190
+ def build_update_fields(obj)
191
+ { field: 'value' }
192
+ end
193
+ end
194
+
195
+ # Pipeline without build_update_fields - should raise
196
+ class MissingBuildFieldsPipeline < Familia::Migration::Pipeline
197
+ self.migration_id = 'pipeline_test_missing_build'
198
+
199
+ def prepare
200
+ @model_class = PipelineTestRecord
201
+ @batch_size = 10
202
+ end
203
+
204
+ def should_process?(obj)
205
+ true
206
+ end
207
+ end
208
+
209
+ # Helper to create test records with unique prefix
210
+ def create_pipeline_record(suffix, name: 'Test', status: 'active')
211
+ id = "#{@test_id}_#{suffix}"
212
+ record = PipelineTestRecord.new(record_id: id, name: name, status: status)
213
+ record.save
214
+ record
215
+ end
216
+
217
+ # Helper to cleanup all test records
218
+ def cleanup_records
219
+ pattern = "pipeline_test_record:#{@test_id}_*"
220
+ @redis.keys(pattern).each { |k| @redis.del(k) }
221
+ end
222
+
223
+ cleanup_records
224
+
225
+ ## Pipeline is a subclass of Model
226
+ Familia::Migration::Pipeline < Familia::Migration::Model
227
+ #=> true
228
+
229
+ ## Pipeline initializes correctly
230
+ migration = SimplePipelineMigration.new
231
+ migration.is_a?(Familia::Migration::Pipeline)
232
+ #=> true
233
+
234
+ ## Pipeline prepare sets model_class
235
+ SimplePipelineMigration.processed_count = 0
236
+ migration = SimplePipelineMigration.new
237
+ migration.prepare
238
+ migration.model_class == PipelineTestRecord
239
+ #=> true
240
+
241
+ ## Pipeline should_process? raises NotImplementedError for base class
242
+ begin
243
+ Familia::Migration::Pipeline.new.send(:should_process?, nil)
244
+ false
245
+ rescue NotImplementedError
246
+ true
247
+ end
248
+ #=> true
249
+
250
+ ## Pipeline build_update_fields raises NotImplementedError for base class
251
+ begin
252
+ Familia::Migration::Pipeline.new.send(:build_update_fields, nil)
253
+ false
254
+ rescue NotImplementedError
255
+ true
256
+ end
257
+ #=> true
258
+
259
+ ## Pipeline process_record is a no-op
260
+ migration = SimplePipelineMigration.new
261
+ migration.send(:process_record, nil, 'key')
262
+ true
263
+ #=> true
264
+
265
+ ## Pipeline processes records in batches
266
+ cleanup_records
267
+ create_pipeline_record('p1', name: 'P1')
268
+ create_pipeline_record('p2', name: 'P2')
269
+ create_pipeline_record('p3', name: 'P3')
270
+ SimplePipelineMigration.processed_count = 0
271
+ migration = SimplePipelineMigration.new(run: true)
272
+ migration.prepare
273
+ migration.migrate
274
+ SimplePipelineMigration.processed_count >= 3
275
+ #=> true
276
+
277
+ ## Pipeline updates are applied in actual_run mode
278
+ cleanup_records
279
+ record = create_pipeline_record('q1', name: 'Q1')
280
+ dbkey = record.dbkey
281
+ SimplePipelineMigration.processed_count = 0
282
+ migration = SimplePipelineMigration.new(run: true)
283
+ migration.prepare
284
+ migration.migrate
285
+ @redis.hget(dbkey, 'new_field')
286
+ #=> "pipeline_updated"
287
+
288
+ ## Pipeline respects dry_run mode
289
+ cleanup_records
290
+ record = create_pipeline_record('r1', name: 'R1')
291
+ SimplePipelineMigration.processed_count = 0
292
+ migration = SimplePipelineMigration.new(run: false)
293
+ migration.prepare
294
+ migration.migrate
295
+ reloaded = PipelineTestRecord.find_by_key(record.dbkey)
296
+ reloaded.new_field.nil?
297
+ #=> true
298
+
299
+ ## Pipeline filtering works correctly
300
+ cleanup_records
301
+ create_pipeline_record('s1', status: 'active')
302
+ create_pipeline_record('s2', status: 'skip_me')
303
+ create_pipeline_record('s3', status: 'active')
304
+ FilteringPipelineMigration.skipped_count = 0
305
+ FilteringPipelineMigration.processed_count = 0
306
+ migration = FilteringPipelineMigration.new(run: true)
307
+ migration.prepare
308
+ migration.migrate
309
+ FilteringPipelineMigration.skipped_count >= 1 && FilteringPipelineMigration.processed_count >= 2
310
+ #=> true
311
+
312
+ ## Pipeline tracks records_updated correctly
313
+ cleanup_records
314
+ create_pipeline_record('t1')
315
+ create_pipeline_record('t2')
316
+ SimplePipelineMigration.processed_count = 0
317
+ migration = SimplePipelineMigration.new(run: true)
318
+ migration.prepare
319
+ migration.migrate
320
+ migration.records_updated >= 2
321
+ #=> true
322
+
323
+ ## Pipeline with empty fields skips HMSET
324
+ cleanup_records
325
+ record = create_pipeline_record('u1', name: 'Original')
326
+ EmptyFieldsPipelineMigration.build_called_count = 0
327
+ migration = EmptyFieldsPipelineMigration.new(run: true)
328
+ migration.prepare
329
+ migration.migrate
330
+ EmptyFieldsPipelineMigration.build_called_count >= 1
331
+ #=> true
332
+
333
+ ## Pipeline with nil fields skips HMSET
334
+ cleanup_records
335
+ record = create_pipeline_record('v1', name: 'Original')
336
+ migration = NilFieldsPipelineMigration.new(run: true)
337
+ migration.prepare
338
+ migration.migrate
339
+ record.name
340
+ #=> 'Original'
341
+
342
+ ## Pipeline custom execute_update is called
343
+ cleanup_records
344
+ record = create_pipeline_record('w1')
345
+ CustomExecutePipelineMigration.custom_execute_called = 0
346
+ CustomExecutePipelineMigration.original_keys = []
347
+ migration = CustomExecutePipelineMigration.new(run: true)
348
+ migration.prepare
349
+ migration.migrate
350
+ CustomExecutePipelineMigration.custom_execute_called >= 1
351
+ #=> true
352
+
353
+ ## Pipeline custom execute_update receives original_key
354
+ cleanup_records
355
+ create_pipeline_record('x1')
356
+ CustomExecutePipelineMigration.custom_execute_called = 0
357
+ CustomExecutePipelineMigration.original_keys = []
358
+ migration = CustomExecutePipelineMigration.new(run: true)
359
+ migration.prepare
360
+ migration.migrate
361
+ CustomExecutePipelineMigration.original_keys.first.include?('object')
362
+ #=> true
363
+
364
+ ## Pipeline handles batch errors gracefully
365
+ cleanup_records
366
+ create_pipeline_record('y1', name: 'trigger_error')
367
+ ErrorPipelineMigration.error_triggered = false
368
+ migration = ErrorPipelineMigration.new(run: true)
369
+ migration.prepare
370
+ migration.migrate
371
+ ErrorPipelineMigration.error_triggered && migration.error_count >= 1
372
+ #=> true
373
+
374
+ ## Pipeline tracks errors per batch size
375
+ cleanup_records
376
+ create_pipeline_record('z1', name: 'trigger_error')
377
+ create_pipeline_record('z2', name: 'normal')
378
+ ErrorPipelineMigration.error_triggered = false
379
+ migration = ErrorPipelineMigration.new(run: true)
380
+ migration.prepare
381
+ migration.migrate
382
+ migration.error_count >= 1
383
+ #=> true
384
+
385
+ ## process_batch calls should_process? and build_update_fields
386
+ cleanup_records
387
+ create_pipeline_record('aa1')
388
+ SimplePipelineMigration.processed_count = 0
389
+ migration = SimplePipelineMigration.new(run: true)
390
+ migration.prepare
391
+ migration.migrate
392
+ SimplePipelineMigration.processed_count >= 1
393
+ #=> true
394
+
395
+ ## Pipeline tracks total_scanned
396
+ cleanup_records
397
+ create_pipeline_record('bb1')
398
+ create_pipeline_record('bb2')
399
+ migration = SimplePipelineMigration.new(run: true)
400
+ migration.prepare
401
+ migration.migrate
402
+ migration.total_scanned >= 2
403
+ #=> true
404
+
405
+ ## Pipeline tracks records_needing_update
406
+ cleanup_records
407
+ create_pipeline_record('cc1')
408
+ create_pipeline_record('cc2')
409
+ create_pipeline_record('cc3')
410
+ migration = SimplePipelineMigration.new(run: true)
411
+ migration.prepare
412
+ migration.migrate
413
+ migration.records_needing_update >= 3
414
+ #=> true
415
+
416
+ ## Pipeline returns true on success
417
+ cleanup_records
418
+ create_pipeline_record('dd1')
419
+ migration = SimplePipelineMigration.new(run: true)
420
+ migration.prepare
421
+ migration.migrate
422
+ #=> true
423
+
424
+ ## Pipeline returns false when errors
425
+ cleanup_records
426
+ create_pipeline_record('ee1', name: 'trigger_error')
427
+ ErrorPipelineMigration.error_triggered = false
428
+ migration = ErrorPipelineMigration.new(run: true)
429
+ migration.prepare
430
+ migration.migrate
431
+ #=> false
432
+
433
+ ## MissingShouldProcessPipeline raises NotImplementedError
434
+ cleanup_records
435
+ create_pipeline_record('ff1')
436
+ migration = MissingShouldProcessPipeline.new(run: true)
437
+ migration.prepare
438
+ begin
439
+ migration.migrate
440
+ false
441
+ rescue NotImplementedError => e
442
+ e.message.include?('should_process?')
443
+ end
444
+ #=> true
445
+
446
+ ## MissingBuildFieldsPipeline raises NotImplementedError
447
+ cleanup_records
448
+ create_pipeline_record('gg1')
449
+ migration = MissingBuildFieldsPipeline.new(run: true)
450
+ migration.prepare
451
+ begin
452
+ migration.migrate
453
+ false
454
+ rescue NotImplementedError => e
455
+ e.message.include?('build_update_fields')
456
+ end
457
+ #=> true
458
+
459
+ cleanup_records
460
+ Familia::Migration.migrations.replace(@initial_migrations)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require_relative '../../lib/familia/migration/rake_tasks'
5
+
6
+ # Enable metadata recording so descriptions are available
7
+ Rake::TaskManager.record_task_metadata = true
8
+
9
+ # Clear existing tasks and reload to ensure fresh state
10
+ Rake::Task.clear
11
+ Familia::Migration::RakeTasks.new
12
+
13
+ ## All expected tasks are defined
14
+ expected_tasks = %w[
15
+ familia:migrate
16
+ familia:migrate:run
17
+ familia:migrate:status
18
+ familia:migrate:dry_run
19
+ familia:migrate:rollback
20
+ familia:migrate:validate
21
+ familia:migrate:schema_drift
22
+ ]
23
+ expected_tasks.all? { |name| Rake::Task.task_defined?(name) }
24
+ #=> true
25
+
26
+ ## familia:migrate:run has correct description
27
+ Rake::Task['familia:migrate:run'].comment
28
+ #=> 'Run all pending migrations'
29
+
30
+ ## familia:migrate:status has correct description
31
+ Rake::Task['familia:migrate:status'].comment
32
+ #=> 'Show migration status table'
33
+
34
+ ## familia:migrate:dry_run has correct description
35
+ Rake::Task['familia:migrate:dry_run'].comment
36
+ #=> 'Preview pending migrations (dry run)'
37
+
38
+ ## familia:migrate:rollback has correct description
39
+ Rake::Task['familia:migrate:rollback'].comment
40
+ #=> 'Rollback a specific migration'
41
+
42
+ ## familia:migrate:validate has correct description
43
+ Rake::Task['familia:migrate:validate'].comment
44
+ #=> 'Validate migration dependencies'
45
+
46
+ ## familia:migrate:schema_drift has correct description
47
+ Rake::Task['familia:migrate:schema_drift'].comment
48
+ #=> 'List models with schema drift'
49
+
50
+ ## familia:migrate shortcut task has correct description
51
+ Rake::Task['familia:migrate'].comment
52
+ #=> 'Run all pending migrations'
53
+
54
+ ## familia:migrate shortcut has familia:migrate:run as prerequisite
55
+ Rake::Task['familia:migrate'].prerequisites
56
+ #=> ['migrate:run']
57
+
58
+ ## familia:migrate:rollback accepts an argument
59
+ task = Rake::Task['familia:migrate:rollback']
60
+ task.arg_names
61
+ #=> [:id]
@@ -0,0 +1,199 @@
1
+ # try/migration/registry_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../support/helpers/test_helpers'
6
+ require_relative '../../lib/familia/migration'
7
+
8
+ Familia.debug = false
9
+
10
+ @redis = Familia.dbclient
11
+ @prefix = "familia:test:registry:#{Process.pid}:#{Time.now.to_i}"
12
+ @registry = Familia::Migration::Registry.new(redis: @redis, prefix: @prefix)
13
+
14
+ # Clean any existing test keys
15
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
16
+
17
+ # Mock migration classes for pending and status tests
18
+ # Using instance variables so they persist across test cases
19
+ @applied_migration = Class.new do
20
+ def self.migration_id; 'test_migration_1'; end
21
+ end
22
+ @pending_migration = Class.new do
23
+ def self.migration_id; 'test_migration_2'; end
24
+ end
25
+ @also_pending_migration = Class.new do
26
+ def self.migration_id; 'test_migration_3'; end
27
+ end
28
+
29
+ ## Registry class exists
30
+ Familia::Migration::Registry.is_a?(Class)
31
+ #=> true
32
+
33
+ ## Registry initializes with correct prefix
34
+ @registry.prefix
35
+ #=> @prefix
36
+
37
+ ## Registry initializes with provided redis client
38
+ @registry.redis == @redis
39
+ #=> true
40
+
41
+ ## client method returns the redis client
42
+ @registry.client == @redis
43
+ #=> true
44
+
45
+ ## applied? returns false for new migration
46
+ @registry.applied?('test_migration_1')
47
+ #=> false
48
+
49
+ ## record_applied adds migration to applied set
50
+ @registry.record_applied('test_migration_1', { records_updated: 100 })
51
+ @registry.applied?('test_migration_1')
52
+ #=> true
53
+
54
+ ## applied_at returns Time when applied
55
+ result = @registry.applied_at('test_migration_1')
56
+ result.is_a?(Time) && (Time.now - result) < 5
57
+ #=> true
58
+
59
+ ## applied_at returns nil when not applied
60
+ @registry.applied_at('nonexistent_migration')
61
+ #=> nil
62
+
63
+ ## all_applied returns array with applied migrations
64
+ applied = @registry.all_applied
65
+ applied.is_a?(Array) && applied.any? { |h| h[:migration_id] == 'test_migration_1' }
66
+ #=> true
67
+
68
+ ## all_applied entry contains applied_at timestamp
69
+ applied = @registry.all_applied
70
+ entry = applied.find { |h| h[:migration_id] == 'test_migration_1' }
71
+ entry[:applied_at].is_a?(Time)
72
+ #=> true
73
+
74
+ ## metadata returns hash with correct status
75
+ meta = @registry.metadata('test_migration_1')
76
+ meta.is_a?(Hash) && meta[:status] == 'applied'
77
+ #=> true
78
+
79
+ ## metadata contains keys_scanned from stats
80
+ meta = @registry.metadata('test_migration_1')
81
+ meta.key?(:keys_scanned)
82
+ #=> true
83
+
84
+ ## metadata returns nil for unapplied migration
85
+ @registry.metadata('nonexistent_migration')
86
+ #=> nil
87
+
88
+ ## pending filters out applied migrations
89
+ pending = @registry.pending([@applied_migration, @pending_migration])
90
+ pending.map(&:migration_id)
91
+ #=> ['test_migration_2']
92
+
93
+ ## pending returns empty array when all are applied
94
+ @registry.record_applied('test_migration_2', {})
95
+ pending = @registry.pending([@applied_migration, @pending_migration])
96
+ pending
97
+ #=> []
98
+
99
+ ## pending returns empty array for nil input
100
+ @registry.pending(nil)
101
+ #=> []
102
+
103
+ ## pending returns empty array for empty input
104
+ @registry.pending([])
105
+ #=> []
106
+
107
+ ## status returns combined info for all migrations
108
+ status = @registry.status([@applied_migration, @pending_migration, @also_pending_migration])
109
+ statuses = status.map { |s| [s[:migration_id], s[:status]] }.to_h
110
+ [statuses['test_migration_1'], statuses['test_migration_3']]
111
+ #=> [:applied, :pending]
112
+
113
+ ## status entry includes applied_at for applied migrations
114
+ status = @registry.status([@applied_migration])
115
+ entry = status.first
116
+ entry[:applied_at].is_a?(Time)
117
+ #=> true
118
+
119
+ ## status entry has nil applied_at for pending migrations
120
+ status = @registry.status([@also_pending_migration])
121
+ entry = status.first
122
+ entry[:applied_at]
123
+ #=> nil
124
+
125
+ ## status returns empty array for nil input
126
+ @registry.status(nil)
127
+ #=> []
128
+
129
+ ## record_rollback removes from applied set
130
+ @registry.record_rollback('test_migration_1')
131
+ @registry.applied?('test_migration_1')
132
+ #=> false
133
+
134
+ ## record_rollback updates metadata to rolled_back status
135
+ meta = @registry.metadata('test_migration_1')
136
+ meta[:status]
137
+ #=> 'rolled_back'
138
+
139
+ ## record_rollback adds rolled_back_at timestamp
140
+ meta = @registry.metadata('test_migration_1')
141
+ meta.key?(:rolled_back_at)
142
+ #=> true
143
+
144
+ ## backup_field stores value in backup hash
145
+ @registry.backup_field('backup_test', 'some:key', 'field1', 'original_value')
146
+ backup_key = "#{@prefix}:backup:backup_test"
147
+ @redis.hget(backup_key, 'some:key:field1')
148
+ #=> 'original_value'
149
+
150
+ ## backup key has TTL set
151
+ ttl = @redis.ttl("#{@prefix}:backup:backup_test")
152
+ ttl > 0 && ttl <= Familia::Migration.config.backup_ttl
153
+ #=> true
154
+
155
+ ## restore_backup returns count of restored fields
156
+ @redis.del('some:key')
157
+ @redis.hset('some:key', 'field1', 'modified_value')
158
+ count = @registry.restore_backup('backup_test')
159
+ count >= 1
160
+ #=> true
161
+
162
+ ## restore_backup restores correct value
163
+ @redis.hget('some:key', 'field1')
164
+ #=> 'original_value'
165
+
166
+ ## restore_backup returns 0 when no backup exists
167
+ @registry.restore_backup('nonexistent_backup')
168
+ #=> 0
169
+
170
+ ## clear_backup removes backup data
171
+ @registry.clear_backup('backup_test')
172
+ @redis.exists("#{@prefix}:backup:backup_test")
173
+ #=> 0
174
+
175
+ ## record_applied works with class that has migration_id
176
+ @class_migration = Class.new do
177
+ def self.migration_id; 'class_based_migration'; end
178
+ end
179
+ @registry.record_applied(@class_migration, {})
180
+ @registry.applied?('class_based_migration')
181
+ #=> true
182
+
183
+ ## record_applied works with instance whose class has migration_id
184
+ @instance_migration_class = Class.new do
185
+ def self.migration_id; 'instance_based_migration'; end
186
+ end
187
+ instance = @instance_migration_class.new
188
+ @registry.record_applied(instance, { duration_ms: 150 })
189
+ @registry.applied?('instance_based_migration')
190
+ #=> true
191
+
192
+ ## record_applied stores duration_ms in metadata
193
+ meta = @registry.metadata('instance_based_migration')
194
+ meta[:duration_ms]
195
+ #=> 150
196
+
197
+ # Teardown
198
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
199
+ @redis.del('some:key')