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.
- checksums.yaml +4 -4
- data/CLAUDE.md +12 -5
- data/Gemfile +1 -1
- data/Gemfile.lock +3 -9
- data/lib/familia/core_ext.rb +2 -2
- data/lib/familia/features/expiration.rb +0 -1
- data/lib/familia/features/relatable_objects.rb +127 -0
- data/lib/familia/features.rb +7 -3
- data/lib/familia/horreum/class_methods.rb +32 -4
- data/lib/familia/secure_identifier.rb +129 -0
- data/lib/familia/utils.rb +7 -96
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +2 -0
- data/try/configuration/scenarios_try.rb +43 -31
- data/try/core/errors_try.rb +10 -10
- data/try/core/extensions_try.rb +56 -23
- data/try/core/familia_extended_try.rb +3 -3
- data/try/core/familia_try.rb +0 -4
- data/try/core/middleware_try.rb +34 -40
- data/try/core/secure_identifier_try.rb +104 -0
- data/try/core/tools_try.rb +52 -36
- data/try/core/utils_try.rb +0 -98
- data/try/datatypes/boolean_try.rb +6 -7
- data/try/datatypes/datatype_base_try.rb +2 -2
- data/try/datatypes/hash_try.rb +0 -1
- data/try/datatypes/list_try.rb +0 -1
- data/try/datatypes/set_try.rb +0 -2
- data/try/datatypes/sorted_set_try.rb +1 -2
- data/try/datatypes/string_try.rb +1 -2
- data/try/edge_cases/empty_identifiers_try.rb +42 -35
- data/try/edge_cases/hash_symbolization_try.rb +5 -5
- data/try/edge_cases/json_serialization_try.rb +12 -13
- data/try/edge_cases/race_conditions_try.rb +46 -49
- data/try/edge_cases/reserved_keywords_try.rb +103 -49
- data/try/edge_cases/string_coercion_try.rb +2 -2
- data/try/edge_cases/ttl_side_effects_try.rb +44 -25
- data/try/features/expiration_try.rb +2 -2
- data/try/features/quantization_try.rb +2 -2
- data/try/features/relatable_objects_try.rb +221 -0
- data/try/features/safe_dump_advanced_try.rb +13 -14
- data/try/features/safe_dump_try.rb +8 -8
- data/try/helpers/test_helpers.rb +10 -12
- data/try/horreum/base_try.rb +9 -9
- data/try/horreum/class_methods_try.rb +27 -30
- data/try/horreum/commands_try.rb +69 -33
- data/try/horreum/initialization_try.rb +4 -4
- data/try/horreum/relations_try.rb +13 -14
- data/try/horreum/serialization_try.rb +3 -3
- data/try/horreum/settings_try.rb +25 -31
- data/try/integration/cross_component_try.rb +45 -35
- data/try/models/customer_safe_dump_try.rb +4 -4
- data/try/models/customer_try.rb +21 -24
- data/try/models/datatype_base_try.rb +0 -1
- data/try/models/familia_object_try.rb +3 -4
- data/try/performance/benchmarks_try.rb +47 -38
- data/try/prototypes/atomic_saves_v4.rb +3 -3
- metadata +15 -12
- data/try/core/refinements_try.rb +0 -39
- /data/try/{pooling/connection_pool_test_try.rb → core/pools_try.rb} +0 -0
- /data/try/{pooling → prototypes/pooling}/README.md +0 -0
- /data/try/{pooling/configurable_stress_test_try.rb → prototypes/pooling/configurable_stress_test.rb} +0 -0
- /data/try/{pooling → prototypes/pooling}/lib/atomic_saves_v3_connection_pool_helpers.rb +0 -0
- /data/try/{pooling → prototypes/pooling}/lib/connection_pool_metrics.rb +0 -0
- /data/try/{pooling → prototypes/pooling}/lib/connection_pool_stress_test.rb +0 -0
- /data/try/{pooling → prototypes/pooling}/lib/connection_pool_threading_models.rb +0 -0
- /data/try/{pooling → prototypes/pooling}/lib/visualize_stress_results.rb +0 -0
- /data/try/{pooling/pool_siege_try.rb → prototypes/pooling/pool_siege.rb} +0 -0
- /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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c17c7c0fbd21ec7edf380a157f36bcf50632838c083474ac56291653e5e5b5f2
|
4
|
+
data.tar.gz: 496226f45b28c48a20f4a7c0901e45f048407b53cb80d4d373f5840f06673bd5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
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: `
|
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.
|
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.
|
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
|
-
|
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.
|
135
|
+
tryouts (~> 3.1.2)
|
142
136
|
yard (~> 0.9)
|
143
137
|
|
144
138
|
BUNDLED WITH
|
data/lib/familia/core_ext.rb
CHANGED
@@ -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.]+)([
|
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
|
128
|
+
while size >= 1024 && unit < units.length - 1
|
129
129
|
size /= 1024
|
130
130
|
unit += 1
|
131
131
|
end
|
@@ -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
|
data/lib/familia/features.rb
CHANGED
@@ -47,6 +47,10 @@ module Familia
|
|
47
47
|
|
48
48
|
end
|
49
49
|
|
50
|
-
|
51
|
-
|
52
|
-
|
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 ||
|
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
|
262
|
-
fobj = new(
|
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,
|
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,
|
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,
|
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
|
-
|
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,
|
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
|
data/lib/familia/version.rb
CHANGED
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
|