familia 2.0.0 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +45 -0
  3. data/Gemfile +2 -0
  4. data/Gemfile.lock +11 -1
  5. data/docs/guides/writing-migrations.md +345 -0
  6. data/examples/migrations/v1_to_v2_serialization_migration.rb +374 -0
  7. data/examples/schemas/customer.json +33 -0
  8. data/examples/schemas/session.json +27 -0
  9. data/familia.gemspec +2 -0
  10. data/lib/familia/data_type/types/hashkey.rb +0 -238
  11. data/lib/familia/data_type/types/listkey.rb +4 -110
  12. data/lib/familia/data_type/types/sorted_set.rb +0 -365
  13. data/lib/familia/data_type/types/stringkey.rb +0 -139
  14. data/lib/familia/data_type/types/unsorted_set.rb +2 -122
  15. data/lib/familia/features/schema_validation.rb +139 -0
  16. data/lib/familia/migration/base.rb +447 -0
  17. data/lib/familia/migration/errors.rb +31 -0
  18. data/lib/familia/migration/model.rb +418 -0
  19. data/lib/familia/migration/pipeline.rb +226 -0
  20. data/lib/familia/migration/rake_tasks.rake +3 -0
  21. data/lib/familia/migration/rake_tasks.rb +160 -0
  22. data/lib/familia/migration/registry.rb +364 -0
  23. data/lib/familia/migration/runner.rb +311 -0
  24. data/lib/familia/migration/script.rb +234 -0
  25. data/lib/familia/migration.rb +43 -0
  26. data/lib/familia/schema_registry.rb +173 -0
  27. data/lib/familia/settings.rb +63 -1
  28. data/lib/familia/version.rb +1 -1
  29. data/lib/familia.rb +1 -0
  30. data/try/features/schema_registry_try.rb +193 -0
  31. data/try/features/schema_validation_feature_try.rb +218 -0
  32. data/try/migration/base_try.rb +226 -0
  33. data/try/migration/errors_try.rb +67 -0
  34. data/try/migration/integration_try.rb +451 -0
  35. data/try/migration/model_try.rb +431 -0
  36. data/try/migration/pipeline_try.rb +460 -0
  37. data/try/migration/rake_tasks_try.rb +61 -0
  38. data/try/migration/registry_try.rb +199 -0
  39. data/try/migration/runner_try.rb +311 -0
  40. data/try/migration/schema_validation_try.rb +201 -0
  41. data/try/migration/script_try.rb +192 -0
  42. data/try/migration/v1_to_v2_serialization_try.rb +513 -0
  43. data/try/performance/benchmarks_try.rb +11 -12
  44. metadata +44 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a908aad71096ba6a650fa67545aad0a2f891be6472fc6cb4ed067977c4edd706
4
- data.tar.gz: 01d676ccdf7a783d585053be2861319785a8aa5c33298848607f589b3dfa6aaf
3
+ metadata.gz: e83013bddd8ff985ad1cd9ef73421146b5fea6c35cbb77aa0cf12656dd270f68
4
+ data.tar.gz: ba7ebef4af806d823317fd41e1875733b12971b3c6321e841c9ecbf0c9d727a0
5
5
  SHA512:
6
- metadata.gz: 6eb5d61cb2255e5901969ae607b324a1b396f819562815d9f7f57a851b433b6cd4c695d339978de0a3341f92ddb909c8c4878a3580d442cf651449b6ac6d3be8
7
- data.tar.gz: 96f2e0a1bdefbd12e6332aefcdf189ae8279d07adaa05348d839ebccf40a9e306bdd18fc21a5a9af478dab94e6b5de71fd6e86ac7cbdd264a99a26f502d598d8
6
+ metadata.gz: 126d79d60cad6c4ed4ed03a557f4435aec82d03ef2230ad64e4d2205f8c8856cc74b140c18fb5f1d567631f203f6b259e7d19d0bed8f36e09b5540fc004f4e6d
7
+ data.tar.gz: cb5a52b4622c9f193d3858b0f2766795f49000f56fccbffdc630dc5b05e68db0a8c200ad25137bf543d9fc64ee2f53cab891517a5d9f91c2dd8debeb2119b7e7
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,51 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.1.0:
11
+
12
+ 2.1.0 — 2026-02-01
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Redis-native migration system with three patterns: Base (abstract foundation),
19
+ Model (record-by-record iteration via SCAN), and Pipeline (bulk updates with
20
+ Redis pipelining). Includes dependency resolution using topological sort,
21
+ dry-run mode, CLI support, and comprehensive Rake tasks.
22
+
23
+ - Migration registry for tracking applied migrations in Redis with rollback
24
+ support and schema drift detection.
25
+
26
+ - Lua script framework with atomic operations: rename_field, copy_field,
27
+ delete_field, rename_key_preserve_ttl, and backup_and_modify_field.
28
+
29
+ - Optional JSON Schema validation for Horreum models via ``feature :schema_validation``
30
+ with centralized SchemaRegistry supporting convention-based and explicit schema
31
+ discovery using the json_schemer gem.
32
+
33
+ - V1 to V2 serialization migration example at ``examples/migrations/v1_to_v2_serialization_migration.rb``
34
+ demonstrating how to upgrade Horreum objects from v1.x format (selective serialization
35
+ with type information loss) to v2.0 format (universal JSON encoding with type preservation).
36
+ Includes type detection heuristics, field type declarations, and batch processing.
37
+
38
+ Documentation
39
+ -------------
40
+
41
+ - Added comprehensive migration writing guide at ``docs/guides/writing-migrations.md``
42
+ covering all three migration patterns, CLI usage, dependencies, and best practices.
43
+
44
+ AI Assistance
45
+ -------------
46
+
47
+ - Claude Code assisted with test coverage analysis, identifying gaps in Model and
48
+ Pipeline test coverage. Implemented 67 new tests covering CLI entry points,
49
+ circular dependency detection, and comprehensive Model/Pipeline scenarios.
50
+
51
+ - Claude Code identified and fixed a bug where schema validation hooks were never
52
+ triggered in Model migrations, and optimized N+1 query patterns in Registry and
53
+ Runner classes.
54
+
10
55
  .. _changelog-2.0.0:
11
56
 
12
57
  2.0.0 — 2026-01-19
data/Gemfile CHANGED
@@ -15,6 +15,8 @@ end
15
15
  group :development, :test do
16
16
  gem 'benchmark', '~> 0.4', require: false
17
17
  gem 'debug', require: false
18
+ gem 'json_schemer', '~> 2.0', require: false
19
+ gem 'rake', '~> 13.0', require: false
18
20
  gem 'irb', '~> 1.15.2', require: false
19
21
  gem 'redcarpet', require: false
20
22
  gem 'reek', require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.0.0)
4
+ familia (2.1.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (~> 2.5)
7
7
  csv (~> 3.3)
@@ -58,12 +58,18 @@ GEM
58
58
  erb (5.1.3)
59
59
  ffi (1.17.2)
60
60
  ffi (1.17.2-arm64-darwin)
61
+ hana (1.3.7)
61
62
  io-console (0.8.1)
62
63
  irb (1.15.3)
63
64
  pp (>= 0.6.0)
64
65
  rdoc (>= 4.0.0)
65
66
  reline (>= 0.4.2)
66
67
  json (2.15.1)
68
+ json_schemer (2.5.0)
69
+ bigdecimal
70
+ hana (~> 1.3)
71
+ regexp_parser (~> 2.0)
72
+ simpleidn (~> 0.2)
67
73
  language_server-protocol (3.17.0.5)
68
74
  lint_roller (1.1.0)
69
75
  logger (1.7.0)
@@ -87,6 +93,7 @@ GEM
87
93
  stringio
88
94
  racc (1.8.1)
89
95
  rainbow (3.1.1)
96
+ rake (13.3.1)
90
97
  rbnacl (7.1.2)
91
98
  ffi (~> 1)
92
99
  rbs (3.9.5)
@@ -152,6 +159,7 @@ GEM
152
159
  ruby-prof (1.7.2)
153
160
  base64
154
161
  ruby-progressbar (1.13.0)
162
+ simpleidn (0.2.3)
155
163
  stackprof (0.2.27)
156
164
  stringio (3.1.9)
157
165
  timecop (0.9.10)
@@ -185,6 +193,8 @@ DEPENDENCIES
185
193
  debug
186
194
  familia!
187
195
  irb (~> 1.15.2)
196
+ json_schemer (~> 2.0)
197
+ rake (~> 13.0)
188
198
  rbnacl (~> 7.1, >= 7.1.1)
189
199
  redcarpet
190
200
  reek
@@ -0,0 +1,345 @@
1
+ # Writing Migrations
2
+
3
+ ## Quick Start
4
+
5
+ Minimal working migration:
6
+
7
+ ```ruby
8
+ class NormalizeEmails < Familia::Migration::Base
9
+ self.migration_id = '20260131_120000_normalize_emails'
10
+ self.description = 'Lowercase all email addresses'
11
+
12
+ def migration_needed?
13
+ redis.exists('needs:normalization') > 0
14
+ end
15
+
16
+ def migrate
17
+ redis.scan_each(match: 'user:*:object') do |key|
18
+ for_realsies_this_time? do
19
+ # perform changes
20
+ end
21
+ track_stat(:processed)
22
+ end
23
+ end
24
+ end
25
+ ```
26
+
27
+ Run it:
28
+
29
+ ```bash
30
+ bundle exec rake familia:migrate:dry_run # Preview
31
+ bundle exec rake familia:migrate # Apply
32
+ ```
33
+
34
+ ## Migration Types
35
+
36
+ | Type | Use When | Key Method |
37
+ |------|----------|------------|
38
+ | `Base` | Raw Redis operations, key patterns, config changes | `migrate` |
39
+ | `Model` | Iterating over Horreum objects with per-record logic | `process_record(obj, key)` |
40
+ | `Pipeline` | Bulk updates with Redis pipelining (1000+ records) | `should_process?(obj)`, `build_update_fields(obj)` |
41
+
42
+ ### Base
43
+
44
+ Direct Redis access. Use for key renames, TTL changes, config migrations:
45
+
46
+ ```ruby
47
+ class AddTTLToSessions < Familia::Migration::Base
48
+ self.migration_id = '20260131_add_ttl'
49
+
50
+ def migration_needed?
51
+ redis.exists('legacy:session:*') > 0
52
+ end
53
+
54
+ def migrate
55
+ cursor = '0'
56
+ loop do
57
+ cursor, keys = redis.scan(cursor, match: 'legacy:session:*', count: 1000)
58
+ keys.each do |key|
59
+ for_realsies_this_time? { redis.expire(key, 3600) }
60
+ track_stat(:keys_expired)
61
+ end
62
+ break if cursor == '0'
63
+ end
64
+ end
65
+ end
66
+ ```
67
+
68
+ ### Model
69
+
70
+ SCAN-based iteration over Horreum objects. Use for per-record transformations with error handling:
71
+
72
+ ```ruby
73
+ class CustomerEmailMigration < Familia::Migration::Model
74
+ self.migration_id = '20260131_customer_emails'
75
+
76
+ def prepare
77
+ @model_class = Customer
78
+ @batch_size = 500 # optional, default: 1000
79
+ end
80
+
81
+ def process_record(customer, key)
82
+ return unless customer.email =~ /[A-Z]/
83
+
84
+ for_realsies_this_time? do
85
+ customer.email = customer.email.downcase
86
+ customer.save
87
+ end
88
+ track_stat(:records_updated)
89
+ end
90
+ end
91
+ ```
92
+
93
+ ### Pipeline
94
+
95
+ Batched updates using Redis pipelining. Use for high-volume simple field updates:
96
+
97
+ ```ruby
98
+ class AddDefaultSettings < Familia::Migration::Pipeline
99
+ self.migration_id = '20260131_default_settings'
100
+
101
+ def prepare
102
+ @model_class = User
103
+ @batch_size = 100 # smaller batches for pipelines
104
+ end
105
+
106
+ def should_process?(user)
107
+ user.settings.nil?
108
+ end
109
+
110
+ def build_update_fields(user)
111
+ { 'settings' => '{}' }
112
+ end
113
+ end
114
+ ```
115
+
116
+ Override `execute_update` for custom pipeline operations:
117
+
118
+ ```ruby
119
+ def execute_update(pipe, obj, fields, original_key)
120
+ dbkey = original_key || obj.dbkey
121
+ pipe.hmset(dbkey, *fields.flatten)
122
+ pipe.expire(dbkey, 86400) # also set TTL
123
+ end
124
+ ```
125
+
126
+ ## Class Attributes
127
+
128
+ | Attribute | Required | Description |
129
+ |-----------|----------|-------------|
130
+ | `migration_id` | Yes | Unique identifier. Format: `YYYYMMDD_HHMMSS_snake_case_name` |
131
+ | `description` | No | Human-readable summary for status output |
132
+ | `dependencies` | No | Array of migration IDs that must run first |
133
+
134
+ ```ruby
135
+ class BuildEmailIndex < Familia::Migration::Base
136
+ self.migration_id = '20260131_150000_build_index'
137
+ self.description = 'Create secondary index for email lookups'
138
+ self.dependencies = ['20260131_120000_normalize_emails']
139
+ # ...
140
+ end
141
+ ```
142
+
143
+ ## Lifecycle Methods
144
+
145
+ | Method | Purpose | Required |
146
+ |--------|---------|----------|
147
+ | `prepare` | Initialize config, set `@model_class` | Model/Pipeline only |
148
+ | `migration_needed?` | Idempotency check. Return `false` to skip. | Yes |
149
+ | `migrate` | Core migration logic | Base only |
150
+ | `process_record(obj, key)` | Per-record logic | Model only |
151
+ | `should_process?(obj)` | Filter predicate | Pipeline only |
152
+ | `build_update_fields(obj)` | Return Hash of field updates | Pipeline only |
153
+ | `down` | Rollback logic | No (enables rollback) |
154
+
155
+ ## Dry Run vs Live
156
+
157
+ Wrap destructive operations with `for_realsies_this_time?`:
158
+
159
+ ```ruby
160
+ def migrate
161
+ redis.scan_each(match: 'session:*') do |key|
162
+ for_realsies_this_time? do
163
+ redis.del(key) # only executes with --run
164
+ end
165
+ track_stat(:deleted)
166
+ end
167
+ end
168
+ ```
169
+
170
+ | Mode | `for_realsies_this_time?` | Registry Updated |
171
+ |------|---------------------------|------------------|
172
+ | Dry run (`:dry_run` task) | Block skipped | No |
173
+ | Live (`:run` task) | Block executes | Yes |
174
+
175
+ ## Dependencies
176
+
177
+ Dependencies ensure execution order. The runner uses topological sort (Kahn's algorithm).
178
+
179
+ ```ruby
180
+ class MigrationA < Familia::Migration::Base
181
+ self.migration_id = 'step_a'
182
+ self.dependencies = []
183
+ end
184
+
185
+ class MigrationB < Familia::Migration::Base
186
+ self.migration_id = 'step_b'
187
+ self.dependencies = ['step_a'] # runs after step_a
188
+ end
189
+ ```
190
+
191
+ Rollback is blocked if dependents are still applied:
192
+
193
+ ```ruby
194
+ runner.rollback('step_a')
195
+ # => Errors::HasDependents if step_b is applied
196
+ ```
197
+
198
+ ## Rollback
199
+
200
+ Implement `down` to enable rollback:
201
+
202
+ ```ruby
203
+ class AddFeatureFlag < Familia::Migration::Base
204
+ self.migration_id = '20260131_feature_flag'
205
+
206
+ def migration_needed?
207
+ !redis.exists?('config:feature:enabled')
208
+ end
209
+
210
+ def migrate
211
+ for_realsies_this_time? do
212
+ redis.set('config:feature:enabled', 'true')
213
+ end
214
+ end
215
+
216
+ def down
217
+ redis.del('config:feature:enabled')
218
+ end
219
+ end
220
+ ```
221
+
222
+ Check reversibility:
223
+
224
+ ```ruby
225
+ instance = AddFeatureFlag.new
226
+ instance.reversible? # => true
227
+ ```
228
+
229
+ ## Lua Scripts
230
+
231
+ Use `Familia::Migration::Script` for atomic operations:
232
+
233
+ ```ruby
234
+ # Rename hash field atomically
235
+ Familia::Migration::Script.execute(
236
+ redis,
237
+ :rename_field,
238
+ keys: ['user:123:object'],
239
+ argv: ['old_name', 'new_name']
240
+ )
241
+ ```
242
+
243
+ Built-in scripts:
244
+
245
+ | Script | Purpose | KEYS | ARGV |
246
+ |--------|---------|------|------|
247
+ | `:rename_field` | Rename hash field | `[hash_key]` | `[old, new]` |
248
+ | `:copy_field` | Copy field within hash | `[hash_key]` | `[src, dst]` |
249
+ | `:delete_field` | Delete hash field | `[hash_key]` | `[field]` |
250
+ | `:rename_key_preserve_ttl` | Rename key, keep TTL | `[src, dst]` | `[]` |
251
+ | `:backup_and_modify_field` | Backup old value, set new | `[hash, backup]` | `[field, value, ttl]` |
252
+
253
+ Register custom scripts:
254
+
255
+ ```ruby
256
+ Familia::Migration::Script.register(:my_script, <<~LUA)
257
+ local key = KEYS[1]
258
+ return redis.call('GET', key)
259
+ LUA
260
+ ```
261
+
262
+ ## CLI Reference
263
+
264
+ ```bash
265
+ # Status
266
+ bundle exec rake familia:migrate:status # Show applied/pending
267
+ bundle exec rake familia:migrate:validate # Check dependency issues
268
+ bundle exec rake familia:migrate:schema_drift # Models with changed schemas
269
+
270
+ # Execution
271
+ bundle exec rake familia:migrate:dry_run # Preview (no changes)
272
+ bundle exec rake familia:migrate # Apply all pending
273
+ bundle exec rake familia:migrate:run # Same as above
274
+
275
+ # Rollback
276
+ bundle exec rake "familia:migrate:rollback[20260131_120000_migration_id]"
277
+ ```
278
+
279
+ ## Statistics
280
+
281
+ Track operations with `track_stat`:
282
+
283
+ ```ruby
284
+ def process_record(obj, key)
285
+ if obj.email.blank?
286
+ track_stat(:skipped_blank)
287
+ return
288
+ end
289
+
290
+ for_realsies_this_time? do
291
+ obj.email = obj.email.downcase
292
+ obj.save
293
+ end
294
+ track_stat(:records_updated)
295
+ end
296
+ ```
297
+
298
+ Access stats:
299
+
300
+ ```ruby
301
+ instance.stats[:records_updated] # => 42
302
+ instance.stats[:skipped_blank] # => 7
303
+ ```
304
+
305
+ ## Configuration
306
+
307
+ ```ruby
308
+ Familia::Migration.configure do |config|
309
+ config.migrations_key = 'familia:migrations' # Registry key prefix
310
+ config.backup_ttl = 86_400 # Backup expiration (24h)
311
+ config.batch_size = 1000 # Default SCAN batch
312
+ end
313
+ ```
314
+
315
+ ## Best Practices
316
+
317
+ 1. **Test locally first.** Run dry run, verify stats, then run live on staging before production.
318
+
319
+ 2. **Deploy schema changes separately.** Avoid updating model definitions and running migrations in the same deploy. New model logic can break migration code.
320
+
321
+ 3. **Keep migrations idempotent.** `migration_needed?` should return `false` after successful execution.
322
+
323
+ 4. **Use descriptive IDs.** `20260131_120000_normalize_customer_emails` beats `20260131_fix_stuff`.
324
+
325
+ 5. **Backup critical data.** Use `:backup_and_modify_field` or `registry.backup_field` before destructive changes.
326
+
327
+ ## Error Reference
328
+
329
+ | Error | Cause |
330
+ |-------|-------|
331
+ | `NotReversible` | `down` not implemented |
332
+ | `NotApplied` | Rollback of unapplied migration |
333
+ | `DependencyNotMet` | Dependency not yet applied |
334
+ | `HasDependents` | Rollback blocked by dependents |
335
+ | `CircularDependency` | Dependency cycle detected |
336
+ | `PreconditionFailed` | `@model_class` not set in `prepare` |
337
+
338
+ ## Source Files
339
+
340
+ - [`lib/familia/migration/base.rb`](../../lib/familia/migration/base.rb)
341
+ - [`lib/familia/migration/model.rb`](../../lib/familia/migration/model.rb)
342
+ - [`lib/familia/migration/pipeline.rb`](../../lib/familia/migration/pipeline.rb)
343
+ - [`lib/familia/migration/registry.rb`](../../lib/familia/migration/registry.rb)
344
+ - [`lib/familia/migration/runner.rb`](../../lib/familia/migration/runner.rb)
345
+ - [`lib/familia/migration/script.rb`](../../lib/familia/migration/script.rb)