familia 2.0.0.pre18 → 2.0.0.pre19

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +58 -6
  3. data/CLAUDE.md +34 -9
  4. data/Gemfile +2 -2
  5. data/Gemfile.lock +9 -47
  6. data/README.md +39 -0
  7. data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +91 -0
  8. data/changelog.d/20251011_203905_delano_next.rst +30 -0
  9. data/changelog.d/20251011_212633_delano_next.rst +13 -0
  10. data/changelog.d/20251011_221253_delano_next.rst +26 -0
  11. data/docs/guides/feature-expiration.md +18 -18
  12. data/docs/migrating/v2.0.0-pre19.md +197 -0
  13. data/examples/datatype_standalone.rb +281 -0
  14. data/lib/familia/connection/behavior.rb +252 -0
  15. data/lib/familia/connection/handlers.rb +95 -0
  16. data/lib/familia/connection/operation_core.rb +1 -1
  17. data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +2 -2
  18. data/lib/familia/connection/transaction_core.rb +7 -9
  19. data/lib/familia/connection.rb +3 -2
  20. data/lib/familia/data_type/connection.rb +151 -7
  21. data/lib/familia/data_type/database_commands.rb +7 -4
  22. data/lib/familia/data_type/serialization.rb +4 -0
  23. data/lib/familia/data_type/types/hashkey.rb +1 -1
  24. data/lib/familia/errors.rb +51 -14
  25. data/lib/familia/features/expiration/extensions.rb +8 -10
  26. data/lib/familia/features/expiration.rb +19 -19
  27. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +39 -38
  28. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +115 -43
  29. data/lib/familia/features/relationships/indexing.rb +37 -42
  30. data/lib/familia/features/relationships/indexing_relationship.rb +14 -4
  31. data/lib/familia/field_type.rb +2 -1
  32. data/lib/familia/horreum/connection.rb +11 -35
  33. data/lib/familia/horreum/database_commands.rb +129 -10
  34. data/lib/familia/horreum/definition.rb +2 -1
  35. data/lib/familia/horreum/management.rb +21 -15
  36. data/lib/familia/horreum/persistence.rb +190 -66
  37. data/lib/familia/horreum/serialization.rb +3 -0
  38. data/lib/familia/horreum/utils.rb +0 -8
  39. data/lib/familia/horreum.rb +31 -12
  40. data/lib/familia/logging.rb +2 -5
  41. data/lib/familia/settings.rb +7 -7
  42. data/lib/familia/version.rb +1 -1
  43. data/lib/middleware/database_logger.rb +76 -5
  44. data/try/edge_cases/string_coercion_try.rb +4 -4
  45. data/try/features/expiration/expiration_try.rb +1 -1
  46. data/try/features/relationships/indexing_try.rb +28 -4
  47. data/try/features/relationships/relationships_api_changes_try.rb +4 -4
  48. data/try/integration/connection/fiber_context_preservation_try.rb +3 -3
  49. data/try/integration/connection/operation_mode_guards_try.rb +1 -1
  50. data/try/integration/connection/pipeline_fallback_integration_try.rb +12 -12
  51. data/try/integration/create_method_try.rb +22 -22
  52. data/try/integration/data_types/datatype_pipelines_try.rb +104 -0
  53. data/try/integration/data_types/datatype_transactions_try.rb +247 -0
  54. data/try/integration/models/customer_safe_dump_try.rb +5 -1
  55. data/try/integration/models/familia_object_try.rb +1 -1
  56. data/try/integration/persistence_operations_try.rb +162 -10
  57. data/try/unit/data_types/boolean_try.rb +1 -1
  58. data/try/unit/data_types/string_try.rb +1 -1
  59. data/try/unit/horreum/auto_indexing_on_save_try.rb +32 -16
  60. data/try/unit/horreum/automatic_index_validation_try.rb +253 -0
  61. data/try/unit/horreum/base_try.rb +1 -1
  62. data/try/unit/horreum/class_methods_try.rb +2 -2
  63. data/try/unit/horreum/initialization_try.rb +1 -1
  64. data/try/unit/horreum/relations_try.rb +4 -4
  65. data/try/unit/horreum/serialization_try.rb +2 -2
  66. data/try/unit/horreum/unique_index_edge_cases_try.rb +376 -0
  67. data/try/unit/horreum/unique_index_guard_validation_try.rb +281 -0
  68. metadata +14 -2
@@ -0,0 +1,197 @@
1
+ # Migrating Guide: v2.0.0-pre19
2
+
3
+ This version introduces significant improvements to Familia's database operations, making them more atomic, reliable, and consistent with Rails conventions. The changes include enhanced error handling, optimistic locking support, and breaking API changes.
4
+
5
+ ## Breaking Changes
6
+
7
+ ### Management.create → create!
8
+
9
+ **What Changed:**
10
+
11
+ The `create` class method has been renamed to `create!` to follow Rails conventions and indicate that it raises exceptions on failure.
12
+
13
+ **Before:**
14
+ ```ruby
15
+ user = User.create(email: "test@example.com")
16
+ ```
17
+
18
+ **After:**
19
+ ```ruby
20
+ user = User.create!(email: "test@example.com")
21
+ ```
22
+
23
+ **Why This Matters:**
24
+
25
+ - Follows Rails naming conventions where `!` methods raise exceptions
26
+ - Makes it clear that `CreationError` will be raised if object already exists
27
+ - Prevents silent failures in object creation
28
+
29
+ ### save_if_not_exists → save_if_not_exists!
30
+
31
+ **What Changed:**
32
+
33
+ The `save_if_not_exists` method has been renamed to `save_if_not_exists!` and now includes optimistic locking with automatic retry logic.
34
+
35
+ **Before:**
36
+ ```ruby
37
+ success = user.save_if_not_exists
38
+ ```
39
+
40
+ **After:**
41
+ ```ruby
42
+ success = user.save_if_not_exists!
43
+ ```
44
+
45
+ **Behavior Changes:**
46
+ - Now uses Redis WATCH/MULTI/EXEC for optimistic locking
47
+ - Automatically retries up to 3 times on `OptimisticLockError`
48
+ - Raises `RecordExistsError` if object already exists
49
+ - More atomic and thread-safe
50
+
51
+ ## Enhanced Error Handling
52
+
53
+ ### New Error Hierarchy
54
+
55
+ **What's New:**
56
+
57
+ A structured error hierarchy provides better error categorization:
58
+
59
+ ```ruby
60
+ Familia::Problem # Base class
61
+ ├── Familia::PersistenceError # Redis/database errors
62
+ │ ├── Familia::NonUniqueKey
63
+ │ ├── Familia::OptimisticLockError
64
+ │ ├── Familia::OperationModeError
65
+ │ ├── Familia::NoConnectionAvailable
66
+ │ ├── Familia::NotFound
67
+ │ └── Familia::NotConnected
68
+ └── Familia::HorreumError # Model-related errors
69
+ ├── Familia::CreationError
70
+ ├── Familia::NoIdentifier
71
+ ├── Familia::FieldTypeError
72
+ ├── Familia::AutoloadError
73
+ ├── Familia::SerializerError
74
+ ├── Familia::UnknownFieldError
75
+ └── Familia::NotDistinguishableError
76
+ ```
77
+
78
+ **Migration:**
79
+
80
+ Update exception handling to use specific error classes:
81
+
82
+ ```ruby
83
+ # Before
84
+ rescue Familia::Problem => e
85
+ # Handle all errors
86
+
87
+ # After - More granular handling
88
+ rescue Familia::CreationError => e
89
+ # Handle creation failures specifically
90
+ rescue Familia::OptimisticLockError => e
91
+ # Handle concurrent modification
92
+ rescue Familia::PersistenceError => e
93
+ # Handle database-related errors
94
+ ```
95
+
96
+ ### New Exception Types
97
+
98
+ - **`CreationError`**: Raised when object creation fails (replaces generic errors)
99
+ - **`OptimisticLockError`**: Raised when WATCH fails due to concurrent modification
100
+ - **`UnknownFieldError`**: Raised when referencing non-existent fields
101
+
102
+ ## Database Operation Improvements
103
+
104
+ ### Atomic Save Operations
105
+
106
+ **What Changed:**
107
+
108
+ The `save` method now uses a single Redis transaction for complete atomicity:
109
+
110
+ ```ruby
111
+ # All operations now happen atomically:
112
+ # 1. Save all fields (HMSET)
113
+ # 2. Set expiration (EXPIRE)
114
+ # 3. Update indexes
115
+ # 4. Add to instances collection
116
+ ```
117
+
118
+ **Benefits:**
119
+ - Eliminates race conditions during save operations
120
+ - Ensures data consistency across related operations
121
+ - Better performance with fewer round trips
122
+
123
+ ### Enhanced Timestamp Precision
124
+
125
+ **What Changed:**
126
+
127
+ Created/updated timestamps now use float values instead of integers for higher precision:
128
+
129
+ **Before:**
130
+ ```ruby
131
+ user.created # => 1697234567 (integer seconds)
132
+ ```
133
+
134
+ **After:**
135
+ ```ruby
136
+ user.created # => 1697234567.123 (float with milliseconds)
137
+ ```
138
+
139
+ **Migration:**
140
+
141
+ No code changes needed. Existing integer timestamps continue to work.
142
+
143
+ ## Redis Command Enhancements
144
+
145
+ ### New Commands Available
146
+
147
+ Added support for optimistic locking commands:
148
+ - `watch(key)` - Watch key for changes
149
+ - `unwatch()` - Remove all watches
150
+ - `discard()` - Discard queued commands
151
+
152
+ ### Improved Command Logging
153
+
154
+ Database command logging now includes:
155
+ - Structured format for better readability
156
+ - Pipelined operation tracking
157
+ - Transaction boundary markers
158
+ - Command timing information
159
+
160
+ ## Terminology Updates
161
+
162
+ **What Changed:**
163
+
164
+ Standardized on "pipelined" terminology throughout (previously mixed "pipeline"/"pipelined").
165
+
166
+ **Files Affected:**
167
+ - Method names now consistently use "pipelined"
168
+ - Documentation updated to match Redis terminology
169
+ - Log messages standardized
170
+
171
+ **Migration:**
172
+
173
+ No code changes needed - this was an internal consistency improvement.
174
+
175
+ ## Recommended Actions
176
+
177
+ 1. **Update method calls:**
178
+ ```ruby
179
+ # Replace all instances
180
+ Model.create(...) → Model.create!(...)
181
+ obj.save_if_not_exists → obj.save_if_not_exists!
182
+ ```
183
+
184
+ 2. **Review error handling:**
185
+ ```ruby
186
+ # Consider more specific error handling
187
+ rescue Familia::CreationError
188
+ rescue Familia::OptimisticLockError
189
+ ```
190
+
191
+ 3. **Test concurrent operations:**
192
+ - The new optimistic locking provides better concurrency handling
193
+ - Verify your application handles `OptimisticLockError` appropriately
194
+
195
+ 4. **Review logging:**
196
+ - Enhanced database command logging may affect log volume
197
+ - Adjust log levels if needed for production environments
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env ruby
2
+ # examples/datatype_standalone.rb
3
+
4
+ # Demonstration: Familia::StringKey for Session Storage with Atomic Transactions
5
+ #
6
+ # This example shows how to use Familia's DataType classes independently
7
+ # without inheriting from Familia::Horreum. It implements a Rack-compatible
8
+ # session store using Familia::StringKey for secure, TTL-managed storage.
9
+ #
10
+ # Key Familia Features Demonstrated:
11
+ # - Standalone DataType usage (no parent model required)
12
+ # - Atomic transactions for multi-operation consistency
13
+ # - TTL management for automatic expiration
14
+ # - JSON serialization for complex data structures
15
+ # - Direct Redis access through DataType objects
16
+
17
+ require 'rack/session/abstract/id'
18
+ require 'securerandom'
19
+
20
+ require 'base64'
21
+ require 'openssl'
22
+
23
+ # Load local development version of Familia (not the gem)
24
+ begin
25
+ require_relative '../lib/familia'
26
+ rescue LoadError
27
+ # Fall back to installed gem
28
+ require 'familia'
29
+ end
30
+
31
+ # SecureSessionStore - a rack-session compatible session store using Familia::StringKey
32
+ #
33
+ # Usage:
34
+ # ruby examples/datatype_standalone.rb
35
+ # # Or in your Rack app:
36
+ # use SecureSessionStore, secret: 'your-secret-key', expire_after: 3600
37
+ #
38
+ # @see https://raw.githubusercontent.com/rack/rack-session/dadcfe60f193e8/lib/rack/session/abstract/id.rb
39
+ # @see https://raw.githubusercontent.com/rack/rack-session/dadcfe60f193e8/lib/rack/session/encryptor.rb
40
+ #
41
+ class SecureSessionStore < Rack::Session::Abstract::PersistedSecure
42
+ unless defined?(DEFAULT_OPTIONS)
43
+ DEFAULT_OPTIONS = {
44
+ key: 'project.session',
45
+ expire_after: 86_400, # 24 hours default
46
+ namespace: 'session',
47
+ sidbits: 256, # Required by Rack::Session::Abstract::Persisted
48
+ dbclient: nil,
49
+ }.freeze
50
+ end
51
+
52
+ attr_reader :dbclient
53
+
54
+ def initialize(app, options = {})
55
+ # Require a secret for security
56
+ raise ArgumentError, 'Secret required for secure sessions' unless options[:secret]
57
+
58
+ # Merge options with defaults
59
+ options = DEFAULT_OPTIONS.merge(options)
60
+
61
+ # Configure Familia connection if redis_uri provided
62
+ @dbclient = options[:dbclient] || Familia.dbclient
63
+
64
+ super
65
+
66
+ @secret = options[:secret]
67
+ @expire_after = options[:expire_after]
68
+ @namespace = options[:namespace] || 'session'
69
+
70
+ # Derive different keys for different purposes
71
+ @hmac_key = derive_key('hmac')
72
+ @encryption_key = derive_key('encryption')
73
+ end
74
+
75
+ private
76
+
77
+ # Create a StringKey instance for a session ID
78
+ def get_stringkey(sid)
79
+ return nil if sid.to_s.empty?
80
+
81
+ key = Familia.join(@namespace, sid)
82
+ Familia::StringKey.new(key,
83
+ ttl: @expire_after,
84
+ default: nil)
85
+ end
86
+
87
+ def delete_session(_request, sid, _options)
88
+ # Extract string ID from SessionId object if needed
89
+ sid_string = sid.respond_to?(:public_id) ? sid.public_id : sid
90
+
91
+ get_stringkey(sid_string)&.del
92
+
93
+ generate_sid
94
+ end
95
+
96
+ def valid_session_id?(sid)
97
+ return false if sid.to_s.empty?
98
+ return false unless sid.match?(/\A[a-f0-9]{64,}\z/)
99
+
100
+ # Additional security checks could go here
101
+ true
102
+ end
103
+
104
+ def valid_hmac?(data, hmac)
105
+ expected = compute_hmac(data)
106
+ return false unless hmac.is_a?(String) && expected.is_a?(String) && hmac.bytesize == expected.bytesize
107
+
108
+ Rack::Utils.secure_compare(expected, hmac)
109
+ end
110
+
111
+ def derive_key(purpose)
112
+ OpenSSL::HMAC.hexdigest('SHA256', @secret, "session-#{purpose}")
113
+ end
114
+
115
+ def compute_hmac(data)
116
+ OpenSSL::HMAC.hexdigest('SHA256', @hmac_key, data)
117
+ end
118
+
119
+ def find_session(_request, sid)
120
+ # Parent class already extracts sid from cookies
121
+ # sid may be a SessionId object or nil
122
+ sid_string = sid.respond_to?(:public_id) ? sid.public_id : sid
123
+
124
+ # Only generate new sid if none provided or invalid
125
+ return [generate_sid, {}] unless sid_string && valid_session_id?(sid_string)
126
+
127
+ begin
128
+ stringkey = get_stringkey(sid_string)
129
+ stored_data = stringkey.value if stringkey
130
+
131
+ # If no data stored, return empty session
132
+ return [sid, {}] unless stored_data
133
+
134
+ # Verify HMAC before deserializing
135
+ data, hmac = stored_data.split('--', 2)
136
+
137
+ # If no HMAC or invalid format, create new session
138
+ unless hmac && valid_hmac?(data, hmac)
139
+ # Session tampered with - create new session
140
+ return [generate_sid, {}]
141
+ end
142
+
143
+ # Decode and parse the session data
144
+ session_data = Familia::JsonSerializer.parse(Base64.decode64(data))
145
+
146
+ [sid, session_data]
147
+ rescue Familia::PersistenceError => e
148
+ # Log error in development/debugging
149
+ Familia.ld "[Session] Error reading session #{sid_string}: #{e.message}"
150
+
151
+ # Return new session on any error
152
+ [generate_sid, {}]
153
+ end
154
+ end
155
+
156
+ def write_session(_request, sid, session_data, _options)
157
+ # Extract string ID from SessionId object if needed
158
+ sid_string = sid.respond_to?(:public_id) ? sid.public_id : sid
159
+
160
+ # Serialize and sign the data
161
+ encoded = Base64.encode64(Familia::JsonSerializer.dump(session_data)).delete("\n")
162
+ hmac = compute_hmac(encoded)
163
+ signed_data = "#{encoded}--#{hmac}"
164
+
165
+ # Get or create StringKey for this session
166
+ stringkey = get_stringkey(sid_string)
167
+
168
+ # ATOMIC TRANSACTION: Ensures both operations succeed or both fail
169
+ #
170
+ # Before DataType transaction support (PR #160), these operations were not atomic:
171
+ # stringkey.set(signed_data)
172
+ # stringkey.update_expiration(expiration: @expire_after)
173
+ #
174
+ # With transaction support, we guarantee atomicity - critical for session storage
175
+ # where partial writes could lead to sessions without TTL (memory leaks) or
176
+ # expired sessions with stale data (security issues).
177
+ #
178
+ # RECOMMENDED PATTERN: Use DataType methods inside transaction blocks
179
+ # The transaction block automatically handles the atomic MULTI/EXEC wrapping.
180
+ # DataType methods handle key generation and provide clean, expressive syntax.
181
+ stringkey.transaction do
182
+ stringkey.set(signed_data)
183
+ stringkey.update_expiration(expiration: @expire_after) if @expire_after&.positive?
184
+ end
185
+
186
+ # ADVANCED: The block yields the Redis connection for low-level access when needed
187
+ # This is useful for operations that require direct Redis command access or
188
+ # when working with multiple DataTypes in a single transaction.
189
+ #
190
+ # stringkey.transaction do |conn|
191
+ # conn.set(stringkey.dbkey, signed_data)
192
+ # conn.expire(stringkey.dbkey, @expire_after) if @expire_after&.positive?
193
+ # end
194
+
195
+ # Return the original sid (may be SessionId object)
196
+ sid
197
+ rescue Familia::PersistenceError => e
198
+ # Log error in development/debugging
199
+ Familia.ld "[Session] Error writing session #{sid_string}: #{e.message}"
200
+
201
+ # Return false to indicate failure
202
+ false
203
+ end
204
+
205
+ # Clean up expired sessions (optional, can be called periodically)
206
+ def cleanup_expired_sessions
207
+ # This would typically be handled by Redis TTL automatically
208
+ # but you could implement manual cleanup if needed
209
+ end
210
+ end
211
+
212
+ # Demo application showing session store in action
213
+ class DemoApp
214
+ def initialize
215
+ @store = SecureSessionStore.new(
216
+ proc { |_env| [200, {}, ['Demo App']] },
217
+ secret: 'demo-secret-key-change-in-production',
218
+ expire_after: 300, # 5 minutes for demo
219
+ )
220
+ end
221
+
222
+ def call(env)
223
+ puts "\n=== Familia::StringKey Session Demo ==="
224
+
225
+ # Mock Rack environment
226
+ env['rack.session'] ||= {}
227
+ env['HTTP_COOKIE'] ||= ''
228
+
229
+ # Simulate session operations
230
+ session_id = SecureRandom.hex(32)
231
+ session_data = {
232
+ 'user_id' => '12345',
233
+ 'username' => 'demo_user',
234
+ 'login_time' => Time.now.to_i,
235
+ 'preferences' => { 'theme' => 'dark', 'lang' => 'en' },
236
+ }
237
+
238
+ puts 'Writing session data...'
239
+ result = @store.send(:write_session, nil, session_id, session_data, {})
240
+ puts " Result: #{result ? 'Success' : 'Failed'}"
241
+
242
+ puts "\nReading session data..."
243
+ found_id, found_data = @store.send(:find_session, nil, session_id)
244
+ puts " Session ID: #{found_id}"
245
+ puts " Data: #{found_data}"
246
+
247
+ puts "\nDeleting session..."
248
+ @store.send(:delete_session, nil, session_id, {})
249
+
250
+ puts "\nVerifying deletion..."
251
+ deleted_id, deleted_data = @store.send(:find_session, nil, session_id)
252
+ puts " Data after deletion: #{deleted_data}"
253
+ puts " New session ID: #{deleted_id == session_id ? 'Same' : 'Generated'}"
254
+
255
+ puts "\n✅ Demo complete!"
256
+ puts "\nKey Familia Features Used:"
257
+ puts '• Familia::StringKey for typed Redis storage'
258
+ puts '• Automatic TTL management'
259
+ puts '• Direct Redis operations (set, get, del)'
260
+ puts '• JSON serialization support'
261
+ puts '• No Horreum inheritance required'
262
+
263
+ [200, { 'Content-Type' => 'text/plain' }, ['Familia StringKey Demo - Check console output']]
264
+ end
265
+ end
266
+
267
+ # Run demo if executed directly
268
+ if __FILE__ == $0
269
+ # Ensure Redis is available
270
+ begin
271
+ Familia.dbclient.ping
272
+ rescue Familia::PersistenceError => e
273
+ puts "❌ Redis connection failed: #{e.message}"
274
+ puts ' Please ensure Redis is running on localhost:6379'
275
+ exit 1
276
+ end
277
+
278
+ # Run the demo
279
+ app = DemoApp.new
280
+ app.call({})
281
+ end