familia 2.0.0.pre2 → 2.0.0.pre4

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/CLAUDE.md +12 -5
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +3 -9
  5. data/lib/familia/core_ext.rb +2 -2
  6. data/lib/familia/features/expiration.rb +0 -1
  7. data/lib/familia/features/relatable_objects.rb +127 -0
  8. data/lib/familia/features.rb +7 -3
  9. data/lib/familia/horreum/class_methods.rb +32 -4
  10. data/lib/familia/secure_identifier.rb +129 -0
  11. data/lib/familia/utils.rb +7 -96
  12. data/lib/familia/version.rb +1 -1
  13. data/lib/familia.rb +2 -0
  14. data/try/configuration/scenarios_try.rb +43 -31
  15. data/try/core/errors_try.rb +10 -10
  16. data/try/core/extensions_try.rb +56 -23
  17. data/try/core/familia_extended_try.rb +3 -3
  18. data/try/core/familia_try.rb +0 -4
  19. data/try/core/middleware_try.rb +34 -40
  20. data/try/core/secure_identifier_try.rb +104 -0
  21. data/try/core/tools_try.rb +52 -36
  22. data/try/core/utils_try.rb +0 -98
  23. data/try/datatypes/boolean_try.rb +6 -7
  24. data/try/datatypes/datatype_base_try.rb +2 -2
  25. data/try/datatypes/hash_try.rb +0 -1
  26. data/try/datatypes/list_try.rb +0 -1
  27. data/try/datatypes/set_try.rb +0 -2
  28. data/try/datatypes/sorted_set_try.rb +1 -2
  29. data/try/datatypes/string_try.rb +1 -2
  30. data/try/edge_cases/empty_identifiers_try.rb +42 -35
  31. data/try/edge_cases/hash_symbolization_try.rb +5 -5
  32. data/try/edge_cases/json_serialization_try.rb +12 -13
  33. data/try/edge_cases/race_conditions_try.rb +46 -49
  34. data/try/edge_cases/reserved_keywords_try.rb +103 -49
  35. data/try/edge_cases/string_coercion_try.rb +2 -2
  36. data/try/edge_cases/ttl_side_effects_try.rb +44 -25
  37. data/try/features/expiration_try.rb +2 -2
  38. data/try/features/quantization_try.rb +2 -2
  39. data/try/features/relatable_objects_try.rb +221 -0
  40. data/try/features/safe_dump_advanced_try.rb +13 -14
  41. data/try/features/safe_dump_try.rb +8 -8
  42. data/try/helpers/test_helpers.rb +10 -12
  43. data/try/horreum/base_try.rb +9 -9
  44. data/try/horreum/class_methods_try.rb +27 -30
  45. data/try/horreum/commands_try.rb +69 -33
  46. data/try/horreum/initialization_try.rb +4 -4
  47. data/try/horreum/relations_try.rb +13 -14
  48. data/try/horreum/serialization_try.rb +3 -3
  49. data/try/horreum/settings_try.rb +25 -31
  50. data/try/integration/cross_component_try.rb +45 -35
  51. data/try/models/customer_safe_dump_try.rb +4 -4
  52. data/try/models/customer_try.rb +21 -24
  53. data/try/models/datatype_base_try.rb +0 -1
  54. data/try/models/familia_object_try.rb +3 -4
  55. data/try/performance/benchmarks_try.rb +47 -38
  56. data/try/prototypes/atomic_saves_v4.rb +3 -3
  57. metadata +15 -12
  58. data/try/core/refinements_try.rb +0 -39
  59. /data/try/{pooling/connection_pool_test_try.rb → core/pools_try.rb} +0 -0
  60. /data/try/{pooling → prototypes/pooling}/README.md +0 -0
  61. /data/try/{pooling/configurable_stress_test_try.rb → prototypes/pooling/configurable_stress_test.rb} +0 -0
  62. /data/try/{pooling → prototypes/pooling}/lib/atomic_saves_v3_connection_pool_helpers.rb +0 -0
  63. /data/try/{pooling → prototypes/pooling}/lib/connection_pool_metrics.rb +0 -0
  64. /data/try/{pooling → prototypes/pooling}/lib/connection_pool_stress_test.rb +0 -0
  65. /data/try/{pooling → prototypes/pooling}/lib/connection_pool_threading_models.rb +0 -0
  66. /data/try/{pooling → prototypes/pooling}/lib/visualize_stress_results.rb +0 -0
  67. /data/try/{pooling/pool_siege_try.rb → prototypes/pooling/pool_siege.rb} +0 -0
  68. /data/try/{pooling/run_stress_tests_try.rb → prototypes/pooling/run_stress_tests.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e104394eac8f785cc87da94119810cf0634d9c4b26e0c37bbf9682a64223637c
4
- data.tar.gz: 91d80c9e21c48e0e50867a0296ac7bfee93f800ca23905369d2e0e47f41e6a03
3
+ metadata.gz: c17c7c0fbd21ec7edf380a157f36bcf50632838c083474ac56291653e5e5b5f2
4
+ data.tar.gz: 496226f45b28c48a20f4a7c0901e45f048407b53cb80d4d373f5840f06673bd5
5
5
  SHA512:
6
- metadata.gz: a50739960222cae1f8e66cc1e6ece4bf023d18d23ad141acd80c5a9b22387d99be22111459da2a34a7b22b45361d57311dd943ee3cc595e081a8fbf36ef81abf
7
- data.tar.gz: dc330db28c720538589341ceb3a914687c83efe5d3ead5f892953c6bdf36882c08468f00e031a767758ae7887cfb3525df9224b5852804ef50739eac403ae333
6
+ metadata.gz: bfce15edc52796648eb1356be796260d8e8dae8bcba38f11a435b86cd732f73acacc8959e67d2bb3e3025d82a962e63ca2627fecb2f88dfc0e76bb1da174592d
7
+ data.tar.gz: 3cc196dc11aaf65409061ed5772043c151dfbc0fe39701b86e2808d2c9f1a55a5647f9ba22cb80cc806752ad17d41a05c2cce743fecf2258e8ff6b91f9868c12
data/CLAUDE.md CHANGED
@@ -5,10 +5,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
5
5
  ## Development Commands
6
6
 
7
7
  ### Testing
8
- - **Run tests**: `bundle exec tryouts` (uses tryouts testing framework)
9
- - **Run specific test file**: `bundle exec tryouts try/specific_test_try.rb`
10
- - **Debug mode**: `FAMILIA_DEBUG=1 bundle exec tryouts`
11
- - **Trace mode**: `FAMILIA_TRACE=1 bundle exec tryouts` (detailed Redis operation logging)
8
+
9
+ A couple rules when writing tests:
10
+ 1) Every tryouts file has three sections: setup, testcases, teardown.
11
+ 2) Every tryouts testcase also has three parts: description, code, expectations.
12
+ 3) Tryouts tests are meant to double as documentation examples; keep that in mind when considering syntax choices.
13
+ 4) There are multiple kinds of expectations: `#=>` is the default comparison, `#=:>` is a class comparison via `is_a?` or `kind_of?`, `#=!>` is an exception class which allows you to knowingly raise an exception without needing a begin/rescue.
14
+
15
+ - **Run tests**: `bundle exec try` (uses tryouts testing framework)
16
+ - **Run specific test file, verbose**: `bundle exec try -v try/specific_test_try.rb`
17
+ - **Debug mode**: `FAMILIA_DEBUG=1 bundle exec try -D`
18
+ - **Trace mode**: `FAMILIA_TRACE=1 bundle exec try -D` (detailed Redis operation logging)
12
19
 
13
20
  ### Development Setup
14
21
  - **Install dependencies**: `bundle install`
@@ -74,7 +81,7 @@ end
74
81
  ```
75
82
 
76
83
  **Identifier Resolution**: Multiple strategies for object identification:
77
- - Symbol: `identifier :email`
84
+ - Symbol: `identifier_field :email`
78
85
  - Proc: `identifier ->(user) { "user:#{user.email}" }`
79
86
  - Array: `identifier [:type, :email]`
80
87
 
data/Gemfile CHANGED
@@ -9,7 +9,7 @@ group :test do
9
9
  gem 'tryouts', path: '../tryouts'
10
10
  gem 'uri-valkey', path: '..//uri-valkey/gems', glob: 'uri-valkey.gemspec'
11
11
  else
12
- gem 'tryouts', '~> 3.1.1', require: false
12
+ gem 'tryouts', '~> 3.1.2', require: false
13
13
  end
14
14
  gem 'concurrent-ruby', '~> 1.3.5', require: false
15
15
  gem 'ruby-prof'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.0.0.pre2)
4
+ familia (2.0.0.pre4)
5
5
  benchmark
6
6
  connection_pool
7
7
  csv
@@ -23,7 +23,6 @@ GEM
23
23
  csv (3.3.5)
24
24
  date (3.4.1)
25
25
  diff-lcs (1.6.2)
26
- drydock (0.6.9)
27
26
  erb (5.0.2)
28
27
  io-console (0.8.1)
29
28
  irb (1.15.2)
@@ -107,15 +106,10 @@ GEM
107
106
  base64
108
107
  ruby-progressbar (1.13.0)
109
108
  stackprof (0.2.27)
110
- storable (0.10.0)
111
109
  stringio (3.1.7)
112
- sysinfo (0.10.0)
113
- drydock (< 1.0)
114
- storable (~> 0.10)
115
- tryouts (3.1.1)
110
+ tryouts (3.1.2)
116
111
  minitest (~> 5.0)
117
112
  rspec (~> 3.0)
118
- sysinfo (>= 0.8, < 1.0)
119
113
  unicode-display_width (3.1.4)
120
114
  unicode-emoji (~> 4.0, >= 4.0.4)
121
115
  unicode-emoji (4.0.4)
@@ -138,7 +132,7 @@ DEPENDENCIES
138
132
  rubocop-thread_safety
139
133
  ruby-prof
140
134
  stackprof
141
- tryouts (~> 3.1.1)
135
+ tryouts (~> 3.1.2)
142
136
  yard (~> 0.9)
143
137
 
144
138
  BUNDLED WITH
@@ -12,7 +12,7 @@ class String
12
12
  #
13
13
  # @return [Float, nil] The time in seconds, or nil if the string is invalid
14
14
  def in_seconds
15
- q, u = scan(/([\d.]+)([smh])?/).flatten
15
+ q, u = scan(/([\d.]+)([smhyd])?/).flatten
16
16
  q &&= q.to_f and u ||= 's'
17
17
  q&.in_seconds(u)
18
18
  end
@@ -125,7 +125,7 @@ class Numeric
125
125
  size = abs.to_f
126
126
  unit = 0
127
127
 
128
- while size > 1024 && unit < units.length - 1
128
+ while size >= 1024 && unit < units.length - 1
129
129
  size /= 1024
130
130
  unit += 1
131
131
  end
@@ -92,7 +92,6 @@ module Familia::Features
92
92
  # a bool.
93
93
  expire(default_expiration)
94
94
  end
95
-
96
95
  extend ClassMethods
97
96
 
98
97
  Familia::Base.add_feature self, :expiration
@@ -0,0 +1,127 @@
1
+ # apps/api/v2/models/features/relatable_object.rb
2
+
3
+ module V2
4
+ module Features
5
+ class RelatableObjectError < Familia::Problem; end
6
+
7
+ # RelatableObject
8
+ #
9
+ # Provides the standard core object fields and methods.
10
+ #
11
+ module RelatableObject
12
+ klass = self
13
+ err_klass = V2::Features::RelatableObjectError
14
+
15
+ def self.included(base)
16
+ base.class_sorted_set :relatable_objids
17
+ base.class_hashkey :owners
18
+
19
+ # NOTE: we do not automatically assign the objid field as the
20
+ # main identifier field. That's up to the implementing class.
21
+ base.field :objid
22
+ base.field :extid
23
+ base.field :api_version
24
+
25
+ base.extend(ClassMethods)
26
+
27
+ # prepend ensures our methods execute BEFORE field-generated accessors
28
+ # include would place them AFTER, but they'd never execute because
29
+ # attr_reader doesn't call super - it just returns the instance variable
30
+ #
31
+ # Method lookup chain:
32
+ # prepend: [InstanceMethods] → [Field Methods] → [Parent]
33
+ # include: [Field Methods] → [InstanceMethods] → [Parent]
34
+ # (stops here, no super) (never reached)
35
+ #
36
+ base.prepend(InstanceMethods)
37
+ end
38
+
39
+ module InstanceMethods
40
+ # We lazily generate the object ID and external ID when they are first
41
+ # accessed so that we can instantiate and load existing objects, without
42
+ # eagerly generating them, only to be overridden by the storage layer.
43
+ #
44
+ def init
45
+ super if defined?(super) # Only call if parent has init
46
+
47
+ @api_version ||= 'v2'
48
+ end
49
+
50
+ def objid
51
+ @objid ||= begin # lazy loader
52
+ generated_id = self.class.generate_objid
53
+ # Using the attr_writer method ensures any future Familia
54
+ # enhancements to the setter are properly invoked (as opposed
55
+ # to directly assigning @objid).
56
+ self.objid = generated_id
57
+ end
58
+ end
59
+ alias relatable_objid objid
60
+
61
+ def extid
62
+ @extid ||= begin # lazy loader
63
+ generated_id = self.class.generate_extid
64
+ self.extid = generated_id
65
+ end
66
+ end
67
+ alias external_identifier extid
68
+
69
+ # Check if the given customer is the owner of this domain
70
+ #
71
+ # @param cust [V2::Customer, String] The customer object or customer ID to check
72
+ # @return [Boolean] true if the customer is the owner, false otherwise
73
+ def owner?(related_object)
74
+ self.class.relatable?(related_object) do
75
+ # Check the hash (our objid => related_object's objid)
76
+ owner_objid = self.class.owners.get(objid).to_s
77
+ return false if owner_objid.empty?
78
+
79
+ owner_objid.eql?(related_object.objid)
80
+ end
81
+ end
82
+
83
+ def owned?
84
+ # We can only have an owner if we are relatable ourselves.
85
+ return false unless self.is_a?(RelatableObject)
86
+ # If our object identifier is present, we have an owner
87
+ self.class.owners.key?(objid)
88
+ end
89
+ end
90
+
91
+ module ClassMethods
92
+ def relatable?(obj, &)
93
+ is_relatable = obj.is_a?(RelatableObject)
94
+ err_klass = V2::Features::RelatableObjectError
95
+ raise err_klass, 'Not relatable object' unless is_relatable
96
+ raise err_klass, 'No self-ownership' if obj.class == self
97
+
98
+ block_given? ? yield : is_relatable
99
+ end
100
+
101
+ def find_by_objid(objid)
102
+ return nil if objid.to_s.empty?
103
+
104
+ if Familia.debug?
105
+ reference = caller(1..1).first
106
+ Familia.trace :FIND_BY_OBJID, Familia.dbclient(uri), objkey, reference
107
+ end
108
+
109
+ find_by_key objkey
110
+ end
111
+
112
+ def generate_objid
113
+ SecureRandom.uuid_v7
114
+ end
115
+
116
+ # Guaranteed length of 54
117
+ def generate_extid
118
+ format('ext_%s', Familia.generate_id)
119
+ end
120
+ end
121
+ extend ClassMethods
122
+
123
+ # Self-register the kids for martial arts classes
124
+ Familia::Base.add_feature self, :relatable_object
125
+ end
126
+ end
127
+ end
@@ -47,6 +47,10 @@ module Familia
47
47
 
48
48
  end
49
49
 
50
- require_relative 'features/expiration'
51
- require_relative 'features/quantization'
52
- require_relative 'features/safe_dump'
50
+ # Load all feature files from the features directory
51
+ features_dir = File.join(__dir__, 'features')
52
+ if Dir.exist?(features_dir)
53
+ Dir.glob(File.join(features_dir, '*.rb')).each do |feature_file|
54
+ require_relative feature_file
55
+ end
56
+ end
@@ -217,9 +217,38 @@ module Familia
217
217
  @suffix || Familia.default_suffix
218
218
  end
219
219
 
220
+ # Sets or retrieves the prefix for generating Redis keys.
221
+ #
222
+ # @param a [String, Symbol, nil] the prefix to set (optional).
223
+ # @return [String, Symbol] the current prefix.
224
+ #
225
+ # The exception is only raised when both @prefix is nil/falsy AND name is nil,
226
+ # which typically occurs with anonymous classes that haven't had their prefix
227
+ # explicitly set.
228
+ #
220
229
  def prefix(a = nil)
221
230
  @prefix = a if a
222
- @prefix || name.downcase.gsub('::', Familia.delim).to_sym
231
+ @prefix || begin
232
+ if name.nil?
233
+ raise Problem, 'Cannot generate prefix for anonymous class. ' \
234
+ 'Use `prefix` method to set explicitly.'
235
+ end
236
+ name.downcase.gsub('::', Familia.delim).to_sym
237
+ end
238
+ end
239
+
240
+ # Converts the class name into a string that can be used to look up
241
+ # configuration values. This is particularly useful when mapping
242
+ # familia models with specific database numbers in the configuration.
243
+ #
244
+ # @example V2::Session.config_name => 'session'
245
+ #
246
+ # @return [String] The underscored class name as a string
247
+ def config_name
248
+ name.split('::').last
249
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
250
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
251
+ .downcase
223
252
  end
224
253
 
225
254
  # Creates and persists a new instance of the class.
@@ -258,8 +287,8 @@ module Familia
258
287
  # @see #exists?
259
288
  # @see #save
260
289
  #
261
- def create *args, **kwargs
262
- fobj = new(*args, **kwargs)
290
+ def create(*, **)
291
+ fobj = new(*, **)
263
292
  raise Familia::Problem, "#{self} already exists: #{fobj.dbkey}" if fobj.exists?
264
293
 
265
294
  fobj.save
@@ -455,6 +484,5 @@ module Familia
455
484
  @load_method || :from_json # Familia.load_method
456
485
  end
457
486
  end
458
-
459
487
  end
460
488
  end
@@ -0,0 +1,129 @@
1
+ # lib/familia/secure_identifier.rb
2
+
3
+ require 'securerandom'
4
+
5
+ module Familia
6
+ module SecureIdentifier
7
+
8
+ # Generates a 256-bit cryptographically secure hexadecimal identifier.
9
+ #
10
+ # @return [String] A 64-character hex string representing 256 bits of entropy.
11
+ # @security Provides ~10^77 possible values, far exceeding UUID4's 128 bits.
12
+ def generate_hex_id
13
+ SecureRandom.hex(32)
14
+ end
15
+
16
+ # Generates a 64-bit cryptographically secure hexadecimal trace identifier.
17
+ #
18
+ # @return [String] A 16-character hex string representing 64 bits of entropy.
19
+ # @note 64 bits provides ~18 quintillion values, sufficient for request tracing.
20
+ def generate_hex_trace_id
21
+ SecureRandom.hex(8)
22
+ end
23
+
24
+ # Generates a cryptographically secure identifier, encoded in the specified base.
25
+ # By default, this creates a compact, URL-safe base-36 string.
26
+ #
27
+ # @param base [Integer] The base for encoding the output string (2-36, default: 36).
28
+ # @return [String] A secure identifier.
29
+ #
30
+ # @example Generate a 256-bit ID in base-36 (default)
31
+ # generate_id # => "25nkfebno45yy36z47ffxef2a7vpg4qk06ylgxzwgpnz4q3os4"
32
+ #
33
+ # @example Generate a 256-bit ID in base-16 (hexadecimal)
34
+ # generate_id(16) # => "568bdb582bc5042bf435d3f126cf71593981067463709c880c91df1ad9777a34"
35
+ #
36
+ def generate_id(base = 36)
37
+ target_length = SecureIdentifier.min_length_for_bits(256, base)
38
+ generate_hex_id.to_i(16).to_s(base).rjust(target_length, '0')
39
+ end
40
+
41
+ # Generates a short, secure trace identifier, encoded in the specified base.
42
+ # Suitable for tracing, logging, and other ephemeral use cases.
43
+ #
44
+ # @param base [Integer] The base for encoding the output string (2-36, default: 36).
45
+ # @return [String] A secure short identifier.
46
+ #
47
+ # @example Generate a 64-bit short ID in base-36 (default)
48
+ # generate_trace_id # => "lh7uap704unf"
49
+ #
50
+ # @example Generate a 64-bit short ID in base-16 (hexadecimal)
51
+ # generate_trace_id(16) # => "94cf9f8cfb0eb692"
52
+ #
53
+ def generate_trace_id(base = 36)
54
+ target_length = SecureIdentifier.min_length_for_bits(64, base)
55
+ generate_hex_trace_id.to_i(16).to_s(base).rjust(target_length, '0')
56
+ end
57
+
58
+ # Truncates a 256-bit hexadecimal ID to 64 bits and encodes it in a given base.
59
+ # These short, deterministic IDs are useful for secure logging. By inputting the
60
+ # full hexadecimal string, you can generate a consistent short ID that allows
61
+ # tracking an entity through logs without exposing the entity's full identifier..
62
+ #
63
+ # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
64
+ # @param base [Integer] The base for encoding the output string (2-36, default: 36).
65
+ # @return [String] A 64-bit identifier, encoded in the specified base.
66
+ def shorten_to_trace_id(hex_id, base: 36)
67
+ target_length = SecureIdentifier.min_length_for_bits(64, base)
68
+ truncated = hex_id.to_i(16) >> (256 - 64) # Always 64 bits
69
+ truncated.to_s(base).rjust(target_length, '0')
70
+ end
71
+
72
+ # Truncates a 256-bit hexadecimal ID to 128 bits and encodes it in a given base.
73
+ # This function takes the most significant bits from the hex string to maintain
74
+ # randomness while creating a shorter, deterministic identifier that's safe for
75
+ # outdoor use.
76
+ #
77
+ # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
78
+ # @param base [Integer] The base for encoding the output string (2-36, default: 36).
79
+ # @return [String] A 128-bit identifier, encoded in the specified base.
80
+ #
81
+ # @example Create a shorter external ID from a full 256-bit internal ID
82
+ # hex_id = generate_hex_id
83
+ # external_id = shorten_to_external_id(hex_id)
84
+ #
85
+ # @note This is useful for creating shorter, public-facing IDs from secure internal ones.
86
+ # @security Truncation preserves the cryptographic properties of the most significant bits.
87
+ def shorten_to_external_id(hex_id, base: 36)
88
+ target_length = SecureIdentifier.min_length_for_bits(128, base)
89
+ truncated = hex_id.to_i(16) >> (256 - 128) # Always 128 bits
90
+ truncated.to_s(base).rjust(target_length, '0')
91
+ end
92
+
93
+ # Calculate minimum string length to represent N bits in given base
94
+ #
95
+ # When generating random IDs, we need to know how many characters are required
96
+ # to represent a certain amount of entropy. This ensures consistent ID lengths.
97
+ #
98
+ # Formula: ceil(bits * log(2) / log(base))
99
+ #
100
+ # @example Common usage with SecureRandom
101
+ # SecureRandom.hex(32) # 32 bytes = 256 bits = 64 hex chars
102
+ # SecureRandom.hex(16) # 16 bytes = 128 bits = 32 hex chars
103
+ #
104
+ # @example Using the method
105
+ # min_length_for_bits(256, 16) # => 64 (hex)
106
+ # min_length_for_bits(256, 36) # => 50 (base36)
107
+ # min_length_for_bits(128, 10) # => 39 (decimal)
108
+
109
+ # Fast lookup for hex (base 16) - our most common case
110
+ # Avoids calculation overhead for 99% of ID generation
111
+ HEX_LENGTHS = {
112
+ 256 => 64, # SHA-256 equivalent entropy
113
+ 128 => 32, # UUID equivalent entropy
114
+ 64 => 16, # Compact ID
115
+ }.freeze
116
+
117
+ # Get minimum character length needed to encode `bits` of entropy in `base`
118
+ #
119
+ # @param bits [Integer] Number of bits of entropy needed
120
+ # @param base [Integer] Numeric base (2-36)
121
+ # @return [Integer] Minimum string length required
122
+ def self.min_length_for_bits(bits, base)
123
+ return HEX_LENGTHS[bits] if base == 16 && HEX_LENGTHS.key?(bits)
124
+
125
+ @length_cache ||= {}
126
+ @length_cache[[bits, base]] ||= (bits * Math.log(2) / Math.log(base)).ceil
127
+ end
128
+ end
129
+ end
data/lib/familia/utils.rb CHANGED
@@ -1,93 +1,8 @@
1
1
  # lib/familia/utils.rb
2
2
 
3
- require 'securerandom'
4
-
5
3
  module Familia
6
-
7
4
  module Utils
8
5
 
9
-
10
- # Generates a 256-bit cryptographically secure hexadecimal identifier.
11
- #
12
- # @return [String] A 64-character hex string representing 256 bits of entropy.
13
- # @security Provides ~10^77 possible values, far exceeding UUID4's 128 bits.
14
- def generate_hex_id
15
- SecureRandom.hex(32)
16
- end
17
-
18
- # Generates a cryptographically secure identifier, encoded in the specified base.
19
- # By default, this creates a compact, URL-safe base-36 string.
20
- #
21
- # @param base [Integer] The base for encoding the output string (2-36, default: 36).
22
- # @return [String] A secure identifier.
23
- #
24
- # @example Generate a 256-bit ID in base-36 (default)
25
- # generate_id # => "25nkfebno45yy36z47ffxef2a7vpg4qk06ylgxzwgpnz4q3os4"
26
- #
27
- # @example Generate a 256-bit ID in base-16 (hexadecimal)
28
- # generate_id(16) # => "568bdb582bc5042bf435d3f126cf71593981067463709c880c91df1ad9777a34"
29
- #
30
- def generate_id(base = 36)
31
- target_length = LENGTH_256_BIT[base]
32
- generate_hex_id.to_i(16).to_s(base).rjust(target_length, '0')
33
- end
34
-
35
- # Generates a 64-bit cryptographically secure hexadecimal trace identifier.
36
- #
37
- # @return [String] A 16-character hex string representing 64 bits of entropy.
38
- # @note 64 bits provides ~18 quintillion values, sufficient for request tracing.
39
- def generate_hex_trace_id
40
- SecureRandom.hex(8)
41
- end
42
-
43
- # Generates a short, secure trace identifier, encoded in the specified base.
44
- # Suitable for tracing, logging, and other ephemeral use cases.
45
- #
46
- # @param base [Integer] The base for encoding the output string (2-36, default: 36).
47
- # @return [String] A secure short identifier.
48
- #
49
- # @example Generate a 64-bit short ID in base-36 (default)
50
- # generate_trace_id # => "lh7uap704unf"
51
- #
52
- # @example Generate a 64-bit short ID in base-16 (hexadecimal)
53
- # generate_trace_id(16) # => "94cf9f8cfb0eb692"
54
- #
55
- def generate_trace_id(base = 36)
56
- target_length = LENGTH_64_BIT[base]
57
- generate_hex_trace_id.to_i(16).to_s(base).rjust(target_length, '0')
58
- end
59
-
60
- # Truncates a 256-bit hexadecimal ID to 128 bits and encodes it in a given base.
61
- # This function takes the most significant bits from the hex string to maintain
62
- # randomness while creating a shorter, deterministic identifier.
63
- #
64
- # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
65
- # @param base [Integer] The base for encoding the output string (2-36, default: 36).
66
- # @return [String] A 128-bit identifier, encoded in the specified base.
67
- #
68
- # @example Create a shorter external ID from a full 256-bit internal ID
69
- # hex_id = generate_hex_id
70
- # external_id = shorten_to_external_id(hex_id)
71
- #
72
- # @note This is useful for creating shorter, public-facing IDs from secure internal ones.
73
- # @security Truncation preserves the cryptographic properties of the most significant bits.
74
- def shorten_to_external_id(hex_id, base: 36)
75
- target_length = LENGTH_128_BIT[base]
76
- truncated = hex_id.to_i(16) >> (256 - 128) # Always 128 bits
77
- truncated.to_s(base).rjust(target_length, '0')
78
- end
79
-
80
- # Truncates a 256-bit hexadecimal ID to 64 bits and encodes it in a given base.
81
- #
82
- # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
83
- # @param base [Integer] The base for encoding the output string (2-36, default: 36).
84
- # @return [String] A 64-bit identifier, encoded in the specified base.
85
- def shorten_to_trace_id(hex_id, base: 36)
86
- target_length = LENGTH_64_BIT[base]
87
- truncated = hex_id.to_i(16) >> (256 - 64) # Always 64 bits
88
- truncated.to_s(base).rjust(target_length, '0')
89
- end
90
-
91
6
  # Joins array elements with Familia delimiter
92
7
  # @param val [Array] elements to join
93
8
  # @return [String] joined string
@@ -152,7 +67,6 @@ module Familia
152
67
  end
153
68
  end
154
69
 
155
-
156
70
  # This method determines the appropriate transformation to apply based on
157
71
  # the class of the input argument.
158
72
  #
@@ -179,14 +93,14 @@ module Familia
179
93
  def distinguisher(value_to_distinguish, strict_values: true)
180
94
  case value_to_distinguish
181
95
  when ::Symbol, ::String, ::Integer, ::Float
182
- Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "string", caller(1..1) if Familia.debug?
96
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, 'string', caller(1..1) if Familia.debug?
183
97
 
184
98
  # Symbols and numerics are naturally serializable to strings
185
99
  # so it's a relatively low risk operation.
186
100
  value_to_distinguish.to_s
187
101
 
188
102
  when ::TrueClass, ::FalseClass, ::NilClass
189
- Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "true/false/nil", caller(1..1) if Familia.debug?
103
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, 'true/false/nil', caller(1..1) if Familia.debug?
190
104
 
191
105
  # TrueClass, FalseClass, and NilClass are considered high risk because their
192
106
  # original types cannot be reliably determined from their serialized string
@@ -200,10 +114,11 @@ module Familia
200
114
  # explicitly set to false.
201
115
  #
202
116
  raise Familia::HighRiskFactor, value_to_distinguish if strict_values
117
+
203
118
  value_to_distinguish.to_s #=> "true", "false", ""
204
119
 
205
120
  when Familia::Base, Class
206
- Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "base", caller(1..1) if Familia.debug?
121
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, 'base', caller(1..1) if Familia.debug?
207
122
 
208
123
  # When called with a class we simply transform it to its name. For
209
124
  # instances of Familia class, we store the identifier.
@@ -214,25 +129,21 @@ module Familia
214
129
  end
215
130
 
216
131
  else
217
- Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "else1 #{strict_values}", caller(1..1) if Familia.debug?
132
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "else1 #{strict_values}", caller(1..1) if Familia.debug?
218
133
 
219
134
  if value_to_distinguish.class.ancestors.member?(Familia::Base)
220
- Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "isabase", caller(1..1) if Familia.debug?
135
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, 'isabase', caller(1..1) if Familia.debug?
221
136
 
222
137
  value_to_distinguish.identifier
223
138
 
224
139
  else
225
140
  Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "else2 #{strict_values}", caller(1..1) if Familia.debug?
226
141
  raise Familia::HighRiskFactor, value_to_distinguish if strict_values
142
+
227
143
  nil
228
144
  end
229
145
  end
230
146
  end
231
147
 
232
- # Calculate minimum string length to represent N bits in given base
233
- calc_length = ->(bits, base) { (bits * Math.log(2) / Math.log(base)).ceil }
234
- LENGTH_256_BIT = [nil, nil] + (2..36).map { |b| calc_length.call(256, b) }
235
- LENGTH_128_BIT = [nil, nil] + (2..36).map { |b| calc_length.call(128, b) }
236
- LENGTH_64_BIT = [nil, nil] + (2..36).map { |b| calc_length.call(64, b) }
237
148
  end
238
149
  end
@@ -3,6 +3,6 @@
3
3
  module Familia
4
4
  # Version information for the Familia
5
5
  unless defined?(Familia::VERSION)
6
- VERSION = '2.0.0.pre2'
6
+ VERSION = '2.0.0.pre4'
7
7
  end
8
8
  end
data/lib/familia.rb CHANGED
@@ -66,11 +66,13 @@ module Familia
66
66
  end
67
67
  end
68
68
 
69
+ require_relative 'familia/secure_identifier'
69
70
  require_relative 'familia/logging'
70
71
  require_relative 'familia/connection'
71
72
  require_relative 'familia/settings'
72
73
  require_relative 'familia/utils'
73
74
 
75
+ extend SecureIdentifier
74
76
  extend Logging
75
77
  extend Connection
76
78
  extend Settings