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,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