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,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Familia
4
+ module Migration
5
+ # Runner orchestrates migration execution with dependency resolution.
6
+ #
7
+ # Provides methods for querying migration status, validating dependencies,
8
+ # and executing migrations in the correct order using topological sorting.
9
+ #
10
+ # @example Basic usage
11
+ # runner = Familia::Migration::Runner.new
12
+ # runner.status # => Array of migration status hashes
13
+ # runner.pending # => Array of unapplied migration classes
14
+ # runner.run # => Execute all pending migrations
15
+ #
16
+ # @example Dry run
17
+ # runner.run(dry_run: true) # Preview without applying changes
18
+ #
19
+ # @example Rolling back
20
+ # runner.rollback('20260131_add_status_field')
21
+ #
22
+ class Runner
23
+ # @return [Array<Class>] Migration classes to operate on
24
+ attr_reader :migrations
25
+
26
+ # @return [Registry] Registry for tracking applied migrations
27
+ attr_reader :registry
28
+
29
+ # @return [Logger] Logger for migration output
30
+ attr_reader :logger
31
+
32
+ # Initialize a new Runner instance.
33
+ #
34
+ # @param migrations [Array<Class>, nil] Migration classes (defaults to registered migrations)
35
+ # @param registry [Registry, nil] Registry instance (defaults to new Registry)
36
+ # @param logger [Logger, nil] Logger instance (defaults to Familia.logger)
37
+ #
38
+ def initialize(migrations: nil, registry: nil, logger: nil)
39
+ @migrations = migrations || Familia::Migration.migrations
40
+ @registry = registry || Registry.new
41
+ @logger = logger || Familia.logger
42
+ end
43
+
44
+ # --- Status Methods ---
45
+
46
+ # Get the status of all migrations.
47
+ #
48
+ # @return [Array<Hash>] Array of migration info hashes with keys:
49
+ # - :migration_id [String] The migration identifier
50
+ # - :description [String] Human-readable description
51
+ # - :status [Symbol] :applied or :pending
52
+ # - :applied_at [Time, nil] When the migration was applied
53
+ # - :reversible [Boolean] Whether the migration has a down method
54
+ #
55
+ def status
56
+ # Batch fetch all applied migrations with timestamps in a single Redis call
57
+ applied_info = @registry.all_applied.each_with_object({}) do |entry, hash|
58
+ hash[entry[:migration_id]] = entry[:applied_at]
59
+ end
60
+
61
+ @migrations.map do |klass|
62
+ id = klass.migration_id
63
+ applied_at = applied_info[id]
64
+ {
65
+ migration_id: id,
66
+ description: klass.description,
67
+ status: applied_at ? :applied : :pending,
68
+ applied_at: applied_at,
69
+ reversible: klass.new.reversible?,
70
+ }
71
+ end
72
+ end
73
+
74
+ # Get all pending (unapplied) migrations.
75
+ #
76
+ # @return [Array<Class>] Migration classes that haven't been applied
77
+ #
78
+ def pending
79
+ @registry.pending(@migrations)
80
+ end
81
+
82
+ # Validate migration dependencies and configuration.
83
+ #
84
+ # @return [Array<Hash>] Array of issue hashes with keys:
85
+ # - :type [Symbol] Type of issue (:missing_dependency, :circular_dependency)
86
+ # - :migration_id [String] Migration with the issue (for missing deps)
87
+ # - :dependency [String] Missing dependency ID (for missing deps)
88
+ # - :message [String] Error message (for circular deps)
89
+ #
90
+ def validate
91
+ issues = []
92
+
93
+ # Check for missing dependencies
94
+ all_ids = @migrations.map(&:migration_id)
95
+ @migrations.each do |klass|
96
+ (klass.dependencies || []).each do |dep_id|
97
+ unless all_ids.include?(dep_id)
98
+ issues << {
99
+ type: :missing_dependency,
100
+ migration_id: klass.migration_id,
101
+ dependency: dep_id,
102
+ }
103
+ end
104
+ end
105
+ end
106
+
107
+ # Check for circular dependencies
108
+ begin
109
+ topological_sort(@migrations)
110
+ rescue Familia::Migration::Errors::CircularDependency => e
111
+ issues << { type: :circular_dependency, message: e.message }
112
+ end
113
+
114
+ issues
115
+ end
116
+
117
+ # --- Execution Methods ---
118
+
119
+ # Run all pending migrations in dependency order.
120
+ #
121
+ # @param dry_run [Boolean] If true, preview without applying changes
122
+ # @param limit [Integer, nil] Maximum number of migrations to run
123
+ # @return [Array<Hash>] Results for each migration attempted
124
+ #
125
+ def run(dry_run: false, limit: nil)
126
+ pending_migrations = topological_sort(pending)
127
+ pending_migrations = pending_migrations.first(limit) if limit
128
+
129
+ results = []
130
+ pending_migrations.each do |klass|
131
+ result = run_one(klass, dry_run: dry_run)
132
+ results << result
133
+ break if result[:status] == :failed
134
+ end
135
+ results
136
+ end
137
+
138
+ # Run a single migration.
139
+ #
140
+ # @param migration_class_or_id [Class, String] Migration class or ID
141
+ # @param dry_run [Boolean] If true, preview without applying changes
142
+ # @return [Hash] Result hash with keys:
143
+ # - :migration_id [String] The migration identifier
144
+ # - :dry_run [Boolean] Whether this was a dry run
145
+ # - :status [Symbol] :success, :skipped, or :failed
146
+ # - :stats [Hash] Statistics from the migration
147
+ # - :error [String] Error message (if failed)
148
+ #
149
+ def run_one(migration_class_or_id, dry_run: false)
150
+ klass = resolve_migration(migration_class_or_id)
151
+
152
+ # Validate dependencies are applied
153
+ (klass.dependencies || []).each do |dep_id|
154
+ unless @registry.applied?(dep_id)
155
+ raise Familia::Migration::Errors::DependencyNotMet,
156
+ "Dependency #{dep_id} not applied for #{klass.migration_id}"
157
+ end
158
+ end
159
+
160
+ instance = klass.new(run: !dry_run)
161
+ instance.prepare
162
+
163
+ result = {
164
+ migration_id: klass.migration_id,
165
+ dry_run: dry_run,
166
+ stats: {},
167
+ }
168
+
169
+ begin
170
+ if instance.migration_needed?
171
+ instance.migrate
172
+ result[:status] = :success
173
+ result[:stats] = instance.stats
174
+ @registry.record_applied(instance, instance.stats) unless dry_run
175
+ else
176
+ result[:status] = :skipped
177
+ end
178
+ rescue StandardError => e
179
+ result[:status] = :failed
180
+ result[:error] = e.message
181
+ @logger.error { "Migration failed: #{e.message}" }
182
+ end
183
+
184
+ result
185
+ end
186
+
187
+ # Rollback a previously applied migration.
188
+ #
189
+ # @param migration_id [String] The migration identifier to rollback
190
+ # @return [Hash] Result hash with keys:
191
+ # - :migration_id [String] The migration identifier
192
+ # - :status [Symbol] :rolled_back or :failed
193
+ # - :error [String] Error message (if failed)
194
+ # @raise [Errors::NotApplied] if migration hasn't been applied
195
+ # @raise [Errors::HasDependents] if other migrations depend on this one
196
+ # @raise [Errors::NotReversible] if migration has no down method
197
+ #
198
+ def rollback(migration_id)
199
+ klass = resolve_migration(migration_id)
200
+
201
+ unless @registry.applied?(migration_id)
202
+ raise Familia::Migration::Errors::NotApplied,
203
+ "Migration #{migration_id} is not applied"
204
+ end
205
+
206
+ # Batch fetch all applied migrations to check for dependents
207
+ applied_ids = @registry.all_applied.map { |e| e[:migration_id] }.to_set
208
+
209
+ # Check no dependents are applied
210
+ @migrations.each do |m|
211
+ if (m.dependencies || []).include?(migration_id) && applied_ids.include?(m.migration_id)
212
+ raise Familia::Migration::Errors::HasDependents,
213
+ "Cannot rollback: #{m.migration_id} depends on #{migration_id}"
214
+ end
215
+ end
216
+
217
+ instance = klass.new
218
+
219
+ unless instance.reversible?
220
+ raise Familia::Migration::Errors::NotReversible,
221
+ "Migration #{migration_id} does not have a down method"
222
+ end
223
+
224
+ result = { migration_id: migration_id }
225
+
226
+ begin
227
+ instance.down
228
+ @registry.record_rollback(migration_id)
229
+ result[:status] = :rolled_back
230
+ rescue StandardError => e
231
+ result[:status] = :failed
232
+ result[:error] = e.message
233
+ end
234
+
235
+ result
236
+ end
237
+
238
+ private
239
+
240
+ # Sort migrations in dependency order using Kahn's algorithm.
241
+ #
242
+ # @param migrations [Array<Class>] Migrations to sort
243
+ # @return [Array<Class>] Migrations in execution order
244
+ # @raise [Errors::CircularDependency] if a cycle is detected
245
+ #
246
+ def topological_sort(migrations)
247
+ return [] if migrations.empty?
248
+
249
+ # Build graph
250
+ id_to_class = migrations.each_with_object({}) { |m, h| h[m.migration_id] = m }
251
+ in_degree = Hash.new(0)
252
+ graph = Hash.new { |h, k| h[k] = [] }
253
+
254
+ migrations.each do |klass|
255
+ id = klass.migration_id
256
+ (klass.dependencies || []).each do |dep_id|
257
+ # Only consider dependencies that are in our migration set
258
+ next unless id_to_class.key?(dep_id)
259
+
260
+ graph[dep_id] << id
261
+ in_degree[id] += 1
262
+ end
263
+ in_degree[id] ||= 0 # Ensure entry exists
264
+ end
265
+
266
+ # Find nodes with no dependencies
267
+ queue = migrations.select { |m| in_degree[m.migration_id].zero? }
268
+ .map(&:migration_id)
269
+ result = []
270
+
271
+ until queue.empty?
272
+ id = queue.shift
273
+ result << id_to_class[id]
274
+
275
+ graph[id].each do |dependent_id|
276
+ in_degree[dependent_id] -= 1
277
+ queue << dependent_id if in_degree[dependent_id].zero?
278
+ end
279
+ end
280
+
281
+ if result.size != migrations.size
282
+ raise Familia::Migration::Errors::CircularDependency,
283
+ 'Circular dependency detected in migrations'
284
+ end
285
+
286
+ result
287
+ end
288
+
289
+ # Resolve a migration class from a class or ID string.
290
+ #
291
+ # @param class_or_id [Class, String] Migration class or ID
292
+ # @return [Class] The migration class
293
+ # @raise [Errors::NotFound] if migration ID not found
294
+ # @raise [ArgumentError] if invalid argument type
295
+ #
296
+ def resolve_migration(class_or_id)
297
+ case class_or_id
298
+ when Class
299
+ class_or_id
300
+ when String
301
+ klass = @migrations.find { |m| m.migration_id == class_or_id }
302
+ raise Familia::Migration::Errors::NotFound, "Migration #{class_or_id} not found" unless klass
303
+
304
+ klass
305
+ else
306
+ raise ArgumentError, "Expected Class or String, got #{class_or_id.class}"
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+
5
+ module Familia
6
+ module Migration
7
+ # Lua script registry for atomic Redis operations during migrations.
8
+ #
9
+ # Provides class-level registration and execution of Lua scripts with
10
+ # EVALSHA/EVAL fallback pattern for efficiency. Scripts are precomputed
11
+ # with their SHA1 hashes at registration time.
12
+ #
13
+ # @example Registering a custom script
14
+ # Familia::Migration::Script.register(:my_script, <<~LUA)
15
+ # local key = KEYS[1]
16
+ # return redis.call('GET', key)
17
+ # LUA
18
+ #
19
+ # @example Executing a script
20
+ # result = Familia::Migration::Script.execute(
21
+ # redis,
22
+ # :rename_field,
23
+ # keys: ['user:123'],
24
+ # argv: ['old_name', 'new_name']
25
+ # )
26
+ #
27
+ class Script
28
+ # Error raised when a script is not found in the registry
29
+ class ScriptNotFound < Familia::Migration::Errors::MigrationError; end
30
+
31
+ # Error raised when script execution fails
32
+ class ScriptError < Familia::Migration::Errors::MigrationError; end
33
+
34
+ # Holds script source and precomputed SHA1
35
+ ScriptEntry = Data.define(:source, :sha) do
36
+ def initialize(source:, sha: nil)
37
+ computed_sha = sha || Digest::SHA1.hexdigest(source)
38
+ super(source: source.freeze, sha: computed_sha.freeze)
39
+ end
40
+ end
41
+
42
+ class << self
43
+ # Access the script registry
44
+ #
45
+ # @return [Hash{Symbol => ScriptEntry}] Frozen hash of registered scripts
46
+ def scripts
47
+ @scripts ||= {}
48
+ end
49
+
50
+ # Register a Lua script with the given name
51
+ #
52
+ # @param name [Symbol] Unique identifier for the script
53
+ # @param lua_source [String] The Lua script source code
54
+ # @return [ScriptEntry] The registered script entry
55
+ # @raise [ArgumentError] If name is not a Symbol or source is empty
56
+ def register(name, lua_source)
57
+ raise ArgumentError, 'Script name must be a Symbol' unless name.is_a?(Symbol)
58
+ raise ArgumentError, 'Lua source cannot be empty' if lua_source.nil? || lua_source.strip.empty?
59
+
60
+ entry = ScriptEntry.new(source: lua_source.strip)
61
+ scripts[name] = entry
62
+ entry
63
+ end
64
+
65
+ # Execute a registered script with EVALSHA/EVAL fallback
66
+ #
67
+ # Attempts EVALSHA first for efficiency. If the script is not cached
68
+ # on the Redis server (NOSCRIPT error), falls back to EVAL which
69
+ # also caches the script for future calls.
70
+ #
71
+ # @param redis [Redis] Redis client connection
72
+ # @param name [Symbol] Name of the registered script
73
+ # @param keys [Array<String>] KEYS array for the Lua script
74
+ # @param argv [Array] ARGV array for the Lua script
75
+ # @return [Object] The script's return value
76
+ # @raise [ScriptNotFound] If the script name is not registered
77
+ # @raise [ScriptError] If script execution fails (other than NOSCRIPT)
78
+ def execute(redis, name, keys: [], argv: [])
79
+ entry = scripts[name]
80
+ raise ScriptNotFound, "Script not found: #{name}" unless entry
81
+
82
+ execute_with_fallback(redis, entry, keys, argv, name)
83
+ end
84
+
85
+ # Preload all registered scripts to the Redis server
86
+ #
87
+ # Loads scripts using SCRIPT LOAD so subsequent EVALSHA calls
88
+ # will succeed without fallback. Useful at application startup.
89
+ #
90
+ # @param redis [Redis] Redis client connection
91
+ # @return [Hash{Symbol => String}] Map of script names to their SHAs
92
+ def preload_all(redis)
93
+ scripts.each_with_object({}) do |(name, entry), loaded|
94
+ sha = redis.script(:load, entry.source)
95
+ loaded[name] = sha
96
+ end
97
+ end
98
+
99
+ # Check if a script is registered
100
+ #
101
+ # @param name [Symbol] Script name to check
102
+ # @return [Boolean] true if the script exists
103
+ def registered?(name)
104
+ scripts.key?(name)
105
+ end
106
+
107
+ # Get the SHA for a registered script
108
+ #
109
+ # @param name [Symbol] Script name
110
+ # @return [String, nil] The script's SHA1 hash or nil if not found
111
+ def sha_for(name)
112
+ scripts[name]&.sha
113
+ end
114
+
115
+ # Reset the registry (primarily for testing)
116
+ #
117
+ # @return [void]
118
+ def reset!
119
+ @scripts = {}
120
+ register_builtin_scripts
121
+ end
122
+
123
+ private
124
+
125
+ # Execute script with EVALSHA, falling back to full script on NOSCRIPT
126
+ def execute_with_fallback(redis, entry, keys, argv, name)
127
+ redis.evalsha(entry.sha, keys: keys, argv: argv)
128
+ rescue Redis::CommandError => e
129
+ if e.message.include?('NOSCRIPT')
130
+ # Script not cached on server, send full script (also caches it)
131
+ redis.call('EVAL', entry.source, keys.size, *keys, *argv)
132
+ else
133
+ raise ScriptError, "Script execution failed for #{name}: #{e.message}"
134
+ end
135
+ end
136
+
137
+ # Register all built-in migration scripts
138
+ def register_builtin_scripts
139
+ register_rename_field
140
+ register_copy_field
141
+ register_delete_field
142
+ register_rename_key_preserve_ttl
143
+ register_backup_and_modify_field
144
+ end
145
+
146
+ def register_rename_field
147
+ register(:rename_field, <<~LUA)
148
+ local key = KEYS[1]
149
+ local old_field = ARGV[1]
150
+ local new_field = ARGV[2]
151
+
152
+ if redis.call('HEXISTS', key, new_field) == 1 then
153
+ return redis.error_reply('Target field already exists: ' .. new_field)
154
+ end
155
+
156
+ local val = redis.call('HGET', key, old_field)
157
+ if val then
158
+ redis.call('HSET', key, new_field, val)
159
+ redis.call('HDEL', key, old_field)
160
+ return 1
161
+ end
162
+ return 0
163
+ LUA
164
+ end
165
+
166
+ def register_copy_field
167
+ register(:copy_field, <<~LUA)
168
+ local key = KEYS[1]
169
+ local src_field = ARGV[1]
170
+ local dst_field = ARGV[2]
171
+
172
+ local val = redis.call('HGET', key, src_field)
173
+ if val then
174
+ redis.call('HSET', key, dst_field, val)
175
+ return 1
176
+ end
177
+ return 0
178
+ LUA
179
+ end
180
+
181
+ def register_delete_field
182
+ register(:delete_field, <<~LUA)
183
+ local key = KEYS[1]
184
+ local field = ARGV[1]
185
+ return redis.call('HDEL', key, field)
186
+ LUA
187
+ end
188
+
189
+ def register_rename_key_preserve_ttl
190
+ register(:rename_key_preserve_ttl, <<~LUA)
191
+ local src = KEYS[1]
192
+ local dst = KEYS[2]
193
+
194
+ if redis.call('EXISTS', dst) == 1 then
195
+ return redis.error_reply('Destination key already exists')
196
+ end
197
+
198
+ local ttl = redis.call('PTTL', src)
199
+ redis.call('RENAME', src, dst)
200
+
201
+ if ttl > 0 then
202
+ redis.call('PEXPIRE', dst, ttl)
203
+ end
204
+
205
+ return ttl
206
+ LUA
207
+ end
208
+
209
+ def register_backup_and_modify_field
210
+ register(:backup_and_modify_field, <<~LUA)
211
+ local hash_key = KEYS[1]
212
+ local backup_key = KEYS[2]
213
+ local field = ARGV[1]
214
+ local new_value = ARGV[2]
215
+ local ttl = tonumber(ARGV[3])
216
+
217
+ local old_val = redis.call('HGET', hash_key, field)
218
+ if old_val then
219
+ redis.call('HSET', backup_key, hash_key .. ':' .. field, old_val)
220
+ if ttl and ttl > 0 then
221
+ redis.call('EXPIRE', backup_key, ttl)
222
+ end
223
+ end
224
+ redis.call('HSET', hash_key, field, new_value)
225
+ return old_val
226
+ LUA
227
+ end
228
+ end
229
+
230
+ # Register built-in scripts when the class is loaded
231
+ register_builtin_scripts
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'migration/errors'
4
+ require_relative 'migration/script'
5
+ require_relative 'migration/registry'
6
+ require_relative 'migration/runner'
7
+ require_relative 'migration/base'
8
+ require_relative 'migration/model'
9
+ require_relative 'migration/pipeline'
10
+
11
+ module Familia
12
+ module Migration
13
+ class << self
14
+ # Registered migration classes (populated by Base.inherited)
15
+ def migrations
16
+ @migrations ||= []
17
+ end
18
+
19
+ def migrations=(list)
20
+ @migrations = list
21
+ end
22
+
23
+ # Configuration
24
+ def config
25
+ @config ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield config if block_given?
30
+ end
31
+ end
32
+
33
+ class Configuration
34
+ attr_accessor :migrations_key, :backup_ttl, :batch_size
35
+
36
+ def initialize
37
+ @migrations_key = 'familia:migrations'
38
+ @backup_ttl = 86_400 # 24 hours
39
+ @batch_size = 1000
40
+ end
41
+ end
42
+ end
43
+ end