quonfig 0.0.2

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 (108) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/rules/constitution.md +81 -0
  3. data/.claude/rules/git-safety.md +11 -0
  4. data/.claude/rules/issue-tracking.md +13 -0
  5. data/.claude/rules/testing-workflow.md +28 -0
  6. data/.envrc.sample +3 -0
  7. data/.github/CODEOWNERS +2 -0
  8. data/.github/pull_request_template.md +8 -0
  9. data/.github/workflows/push_gem.yml +49 -0
  10. data/.github/workflows/ruby.yml +60 -0
  11. data/.github/workflows/test.yaml +40 -0
  12. data/.rubocop.yml +13 -0
  13. data/.tool-versions +1 -0
  14. data/CHANGELOG.md +301 -0
  15. data/CLAUDE.md +29 -0
  16. data/CODEOWNERS +1 -0
  17. data/Gemfile +26 -0
  18. data/Gemfile.lock +177 -0
  19. data/LICENSE.txt +20 -0
  20. data/README.md +213 -0
  21. data/Rakefile +64 -0
  22. data/VERSION +1 -0
  23. data/dev/allocation_stats +60 -0
  24. data/dev/benchmark +40 -0
  25. data/dev/console +12 -0
  26. data/dev/script_setup.rb +18 -0
  27. data/lib/quonfig/bound_client.rb +71 -0
  28. data/lib/quonfig/caching_http_connection.rb +95 -0
  29. data/lib/quonfig/client.rb +221 -0
  30. data/lib/quonfig/config_envelope.rb +5 -0
  31. data/lib/quonfig/config_loader.rb +103 -0
  32. data/lib/quonfig/config_store.rb +42 -0
  33. data/lib/quonfig/context.rb +101 -0
  34. data/lib/quonfig/datadir.rb +101 -0
  35. data/lib/quonfig/duration.rb +58 -0
  36. data/lib/quonfig/encryption.rb +74 -0
  37. data/lib/quonfig/error.rb +6 -0
  38. data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
  39. data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
  40. data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
  41. data/lib/quonfig/errors/missing_default_error.rb +13 -0
  42. data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
  43. data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
  44. data/lib/quonfig/errors/uninitialized_error.rb +13 -0
  45. data/lib/quonfig/evaluation.rb +64 -0
  46. data/lib/quonfig/evaluator.rb +464 -0
  47. data/lib/quonfig/exponential_backoff.rb +21 -0
  48. data/lib/quonfig/fixed_size_hash.rb +14 -0
  49. data/lib/quonfig/http_connection.rb +46 -0
  50. data/lib/quonfig/internal_logger.rb +173 -0
  51. data/lib/quonfig/murmer3.rb +50 -0
  52. data/lib/quonfig/options.rb +194 -0
  53. data/lib/quonfig/periodic_sync.rb +74 -0
  54. data/lib/quonfig/quonfig.rb +58 -0
  55. data/lib/quonfig/rate_limit_cache.rb +41 -0
  56. data/lib/quonfig/reason.rb +39 -0
  57. data/lib/quonfig/resolver.rb +42 -0
  58. data/lib/quonfig/semantic_logger_filter.rb +90 -0
  59. data/lib/quonfig/semver.rb +132 -0
  60. data/lib/quonfig/sse_config_client.rb +135 -0
  61. data/lib/quonfig/time_helpers.rb +7 -0
  62. data/lib/quonfig/types.rb +56 -0
  63. data/lib/quonfig/weighted_value_resolver.rb +49 -0
  64. data/lib/quonfig.rb +57 -0
  65. data/quonfig.gemspec +149 -0
  66. data/scripts/generate_integration_tests.rb +362 -0
  67. data/test/fixtures/datafile.json +87 -0
  68. data/test/integration/test_context_precedence.rb +194 -0
  69. data/test/integration/test_datadir_environment.rb +76 -0
  70. data/test/integration/test_enabled.rb +784 -0
  71. data/test/integration/test_enabled_with_contexts.rb +94 -0
  72. data/test/integration/test_get.rb +224 -0
  73. data/test/integration/test_get_feature_flag.rb +34 -0
  74. data/test/integration/test_get_or_raise.rb +86 -0
  75. data/test/integration/test_get_weighted_values.rb +29 -0
  76. data/test/integration/test_helpers.rb +139 -0
  77. data/test/integration/test_helpers_test.rb +73 -0
  78. data/test/integration/test_post.rb +34 -0
  79. data/test/integration/test_telemetry.rb +114 -0
  80. data/test/support/common_helpers.rb +106 -0
  81. data/test/support/mock_base_client.rb +27 -0
  82. data/test/support/mock_config_loader.rb +1 -0
  83. data/test/test_bound_client.rb +109 -0
  84. data/test/test_caching_http_connection.rb +218 -0
  85. data/test/test_client.rb +255 -0
  86. data/test/test_config_loader.rb +70 -0
  87. data/test/test_context.rb +136 -0
  88. data/test/test_datadir.rb +199 -0
  89. data/test/test_duration.rb +37 -0
  90. data/test/test_encryption.rb +16 -0
  91. data/test/test_evaluator.rb +285 -0
  92. data/test/test_exponential_backoff.rb +44 -0
  93. data/test/test_fixed_size_hash.rb +119 -0
  94. data/test/test_helper.rb +17 -0
  95. data/test/test_http_connection.rb +79 -0
  96. data/test/test_internal_logger.rb +34 -0
  97. data/test/test_options.rb +167 -0
  98. data/test/test_rate_limit_cache.rb +44 -0
  99. data/test/test_reason.rb +79 -0
  100. data/test/test_rename.rb +65 -0
  101. data/test/test_resolver.rb +144 -0
  102. data/test/test_semantic_logger_filter.rb +123 -0
  103. data/test/test_semver.rb +108 -0
  104. data/test/test_sse_config_client.rb +297 -0
  105. data/test/test_typed_getters.rb +131 -0
  106. data/test/test_types.rb +141 -0
  107. data/test/test_weighted_value_resolver.rb +84 -0
  108. metadata +311 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Immutable context-bound view over a Quonfig::Client. Every lookup uses the
5
+ # bound context as the jit_context passed down to the resolver.
6
+ class BoundClient
7
+ attr_reader :client, :context
8
+
9
+ def initialize(client, context)
10
+ @client = client
11
+ @context = context || {}
12
+ freeze
13
+ end
14
+
15
+ def get_string(key, default: NO_DEFAULT_PROVIDED)
16
+ @client.get_string(key, default: default, context: @context)
17
+ end
18
+
19
+ def get_int(key, default: NO_DEFAULT_PROVIDED)
20
+ @client.get_int(key, default: default, context: @context)
21
+ end
22
+
23
+ def get_float(key, default: NO_DEFAULT_PROVIDED)
24
+ @client.get_float(key, default: default, context: @context)
25
+ end
26
+
27
+ def get_bool(key, default: NO_DEFAULT_PROVIDED)
28
+ @client.get_bool(key, default: default, context: @context)
29
+ end
30
+
31
+ def get_string_list(key, default: NO_DEFAULT_PROVIDED)
32
+ @client.get_string_list(key, default: default, context: @context)
33
+ end
34
+
35
+ def get_duration(key, default: NO_DEFAULT_PROVIDED)
36
+ @client.get_duration(key, default: default, context: @context)
37
+ end
38
+
39
+ def get_json(key, default: NO_DEFAULT_PROVIDED)
40
+ @client.get_json(key, default: default, context: @context)
41
+ end
42
+
43
+ def enabled?(feature_name)
44
+ @client.enabled?(feature_name, @context)
45
+ end
46
+
47
+ # Returns a new BoundClient whose bound context is the merge of this
48
+ # bound context and +additional+. Merge is one level deep per named
49
+ # context (mirrors sdk-node's mergeContexts): later values override
50
+ # earlier within the same named context; keys unique to each side are
51
+ # preserved.
52
+ def in_context(additional)
53
+ self.class.new(@client, merge_contexts(@context, additional || {}))
54
+ end
55
+
56
+ def inspect
57
+ "#<Quonfig::BoundClient context=#{@context.inspect}>"
58
+ end
59
+
60
+ private
61
+
62
+ def merge_contexts(left, right)
63
+ merged = {}
64
+ left.each { |name, ctx| merged[name] = ctx.dup }
65
+ right.each do |name, ctx|
66
+ merged[name] = merged[name] ? merged[name].merge(ctx) : ctx.dup
67
+ end
68
+ merged
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ class CachingHttpConnection
5
+ CACHE_SIZE = 2.freeze
6
+ CacheEntry = Struct.new(:data, :etag, :expires_at)
7
+
8
+ class << self
9
+ def cache
10
+ @cache ||= FixedSizeHash.new(CACHE_SIZE)
11
+ end
12
+
13
+ def reset_cache!
14
+ @cache = FixedSizeHash.new(CACHE_SIZE)
15
+ end
16
+ end
17
+
18
+ def initialize(uri, api_key)
19
+ @connection = HttpConnection.new(uri, api_key)
20
+ end
21
+
22
+ def get(path)
23
+ now = Time.now.to_i
24
+ cache_key = "#{@connection.uri}#{path}"
25
+ cached = self.class.cache[cache_key]
26
+
27
+ # Check if we have a valid cached response
28
+ if cached&.data && cached.expires_at && now < cached.expires_at
29
+ return Faraday::Response.new(
30
+ status: 200,
31
+ body: cached.data,
32
+ response_headers: {
33
+ 'ETag' => cached.etag,
34
+ 'X-Cache' => 'HIT',
35
+ 'X-Cache-Expires-At' => cached.expires_at.to_s
36
+ }
37
+ )
38
+ end
39
+
40
+ # Make request with conditional GET if we have an ETag
41
+ response = if cached&.etag
42
+ @connection.get(path, { 'If-None-Match' => cached.etag })
43
+ else
44
+ @connection.get(path)
45
+ end
46
+
47
+ # Handle 304 Not Modified
48
+ if response.status == 304 && cached&.data
49
+ return Faraday::Response.new(
50
+ status: 200,
51
+ body: cached.data,
52
+ response_headers: {
53
+ 'ETag' => cached.etag,
54
+ 'X-Cache' => 'HIT',
55
+ 'X-Cache-Expires-At' => cached.expires_at.to_s
56
+ }
57
+ )
58
+ end
59
+
60
+ # Parse caching headers
61
+ cache_control = response.headers['Cache-Control'].to_s
62
+ etag = response.headers['ETag']
63
+
64
+ # Always add X-Cache header
65
+ response.headers['X-Cache'] = 'MISS'
66
+
67
+ # Don't cache if no-store is present
68
+ return response if cache_control.include?('no-store')
69
+
70
+ # Calculate expiration
71
+ max_age = cache_control.match(/max-age=(\d+)/)&.captures&.first&.to_i
72
+ expires_at = max_age ? now + max_age : nil
73
+
74
+ # Cache the response if we have caching headers
75
+ if etag || expires_at
76
+ self.class.cache[cache_key] = CacheEntry.new(
77
+ response.body,
78
+ etag,
79
+ expires_at
80
+ )
81
+ end
82
+
83
+ response
84
+ end
85
+
86
+ # Delegate other methods to the underlying connection
87
+ def post(path, body)
88
+ @connection.post(path, body)
89
+ end
90
+
91
+ def uri
92
+ @connection.uri
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Quonfig
6
+ # Public Quonfig SDK client.
7
+ #
8
+ # Wires the new JSON stack: Quonfig::ConfigStore + Quonfig::Evaluator +
9
+ # Quonfig::Resolver. The legacy protobuf-driven ConfigClient/ConfigResolver
10
+ # path was removed in qfg-dk6.32. Network-mode (HTTP fetch + SSE updates) is
11
+ # not yet wired through Client; today the supported entry points are
12
+ # +datadir:+ (offline workspace) and +store:+ (caller-supplied
13
+ # Quonfig::ConfigStore, used by tests).
14
+ class Client
15
+ LOG = Quonfig::InternalLogger.new(self)
16
+
17
+ attr_reader :options, :resolver, :store, :evaluator, :instance_hash
18
+
19
+ def initialize(options = nil, store: nil, **option_kwargs)
20
+ @options =
21
+ if options.is_a?(Quonfig::Options)
22
+ options
23
+ elsif options.is_a?(Hash)
24
+ Quonfig::Options.new(options.merge(option_kwargs))
25
+ else
26
+ Quonfig::Options.new(option_kwargs)
27
+ end
28
+ @global_context = normalize_context(@options.global_context)
29
+ @instance_hash = SecureRandom.uuid
30
+ @store = store || build_store
31
+ @evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
32
+ @resolver = Quonfig::Resolver.new(@store, @evaluator)
33
+ @semantic_logger_filters = {}
34
+ end
35
+
36
+ # ---- Lookup --------------------------------------------------------
37
+
38
+ def get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
39
+ ctx = build_context(jit_context)
40
+ result = @resolver.get(key, ctx)
41
+ return handle_missing(key, default) if result.nil?
42
+
43
+ result.unwrapped_value
44
+ end
45
+
46
+ def get_string(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
47
+ typed_get(key, String, default: default, context: context)
48
+ end
49
+
50
+ def get_int(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
51
+ typed_get(key, Integer, default: default, context: context)
52
+ end
53
+
54
+ def get_float(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
55
+ typed_get(key, Float, default: default, context: context)
56
+ end
57
+
58
+ def get_bool(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
59
+ typed_get(key, :bool, default: default, context: context)
60
+ end
61
+
62
+ def get_string_list(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
63
+ typed_get(key, :string_list, default: default, context: context)
64
+ end
65
+
66
+ def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
67
+ typed_get(key, :duration, default: default, context: context)
68
+ end
69
+
70
+ def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
71
+ typed_get(key, :json, default: default, context: context)
72
+ end
73
+
74
+ def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
75
+ value = get(feature_name, false, jit_context)
76
+ value == true || value == 'true'
77
+ end
78
+
79
+ def defined?(key)
80
+ !@store.get(key).nil?
81
+ end
82
+
83
+ def keys
84
+ @store.keys
85
+ end
86
+
87
+ # ---- Context binding ----------------------------------------------
88
+
89
+ def in_context(properties)
90
+ bound = Quonfig::BoundClient.new(self, properties)
91
+ block_given? ? yield(bound) : bound
92
+ end
93
+
94
+ def with_context(properties, &block)
95
+ if block_given?
96
+ in_context(properties, &block)
97
+ else
98
+ Quonfig::BoundClient.new(self, properties)
99
+ end
100
+ end
101
+
102
+ # ---- Filters & helpers --------------------------------------------
103
+
104
+ def semantic_logger_filter(config_key:)
105
+ @semantic_logger_filters[config_key] ||=
106
+ Quonfig::SemanticLoggerFilter.new(self, config_key: config_key)
107
+ end
108
+
109
+ def on_update(&block)
110
+ @on_update = block
111
+ end
112
+
113
+ def stop
114
+ # No background threads in datadir mode; placeholder for the future
115
+ # SSE/poll path so callers can use this method symmetrically.
116
+ end
117
+
118
+ def fork
119
+ self.class.new(@options.for_fork)
120
+ end
121
+
122
+ def inspect
123
+ "#<Quonfig::Client:#{object_id} environment=#{@options.environment.inspect}>"
124
+ end
125
+
126
+ private
127
+
128
+ def build_store
129
+ if @options.datadir
130
+ Quonfig::Datadir.load_store(@options.datadir, @options.environment)
131
+ else
132
+ Quonfig::ConfigStore.new
133
+ end
134
+ end
135
+
136
+ def build_context(jit_context)
137
+ jit = jit_context == NO_DEFAULT_PROVIDED ? nil : normalize_context(jit_context)
138
+ merge_contexts(@global_context, jit)
139
+ end
140
+
141
+ def normalize_context(ctx)
142
+ return {} if ctx.nil?
143
+ return ctx if ctx.is_a?(Hash)
144
+
145
+ raise ArgumentError, "Quonfig context must be a Hash, got #{ctx.class}"
146
+ end
147
+
148
+ # One-level-deep merge per named context (mirrors sdk-node's mergeContexts):
149
+ # later values override earlier within the same named context; keys unique
150
+ # to each side are preserved.
151
+ def merge_contexts(left, right)
152
+ return right || {} if left.nil? || left.empty?
153
+ return left if right.nil? || right.empty?
154
+
155
+ merged = {}
156
+ left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
157
+ right.each do |name, ctx|
158
+ if merged[name].is_a?(Hash) && ctx.is_a?(Hash)
159
+ merged[name] = merged[name].merge(ctx)
160
+ else
161
+ merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx
162
+ end
163
+ end
164
+ merged
165
+ end
166
+
167
+ def handle_missing(key, default)
168
+ return default if default != NO_DEFAULT_PROVIDED
169
+
170
+ if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
171
+ raise Quonfig::Errors::MissingDefaultError, key
172
+ end
173
+
174
+ nil
175
+ end
176
+
177
+ def typed_get(key, expected_type, default:, context:)
178
+ jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
179
+ value = get(key, default, jit)
180
+
181
+ # Missing path: resolver returned the caller's default (or nil under
182
+ # on_no_default=:return_nil) — skip type coercion.
183
+ return value if default != NO_DEFAULT_PROVIDED && value.equal?(default)
184
+ return nil if value.nil?
185
+
186
+ coerce_and_check(key, value, expected_type)
187
+ end
188
+
189
+ def coerce_and_check(key, value, expected_type)
190
+ case expected_type
191
+ when :bool
192
+ unless value == true || value == false
193
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', value)
194
+ end
195
+ value
196
+ when :string_list
197
+ arr = value.is_a?(Array) ? value : nil
198
+ unless arr && arr.all? { |v| v.is_a?(String) }
199
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'Array<String>', value)
200
+ end
201
+ arr
202
+ when :duration
203
+ return value.to_i if value.is_a?(Numeric)
204
+ if value.is_a?(String)
205
+ return (Quonfig::Duration.parse(value) * 1000).to_i
206
+ end
207
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value)
208
+ when :json
209
+ # JSON values are returned as-is (Hash, Array, or scalar from the wire).
210
+ value
211
+ when Class
212
+ unless value.is_a?(expected_type)
213
+ raise Quonfig::Errors::TypeMismatchError.new(key, "expected #{expected_type}", value)
214
+ end
215
+ value
216
+ else
217
+ value
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ ConfigEnvelope = Struct.new(:configs, :meta, keyword_init: true)
5
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Quonfig
6
+ class ConfigLoader
7
+ LOG = Quonfig::InternalLogger.new(self)
8
+
9
+ CONFIGS_PATH = '/api/v2/configs'
10
+
11
+ attr_reader :etag
12
+
13
+ def initialize(base_client)
14
+ @base_client = base_client
15
+ @options = base_client.options
16
+ @api_config = Concurrent::Map.new
17
+ @etag = nil
18
+ end
19
+
20
+ # Fetch configs from /api/v2/configs with ETag / If-None-Match caching.
21
+ #
22
+ # Returns one of:
23
+ # :updated — 200 response; @api_config and @etag replaced
24
+ # :not_modified — 304 response; cache still valid
25
+ # :failed — every configured source failed
26
+ def fetch!
27
+ Array(@options.config_api_urls).each do |api_url|
28
+ result = fetch_from(api_url)
29
+ return result if result != :failed
30
+ end
31
+ :failed
32
+ end
33
+
34
+ def calc_config
35
+ rtn = {}
36
+ @api_config.each_key do |k|
37
+ rtn[k] = @api_config[k]
38
+ end
39
+ rtn
40
+ end
41
+
42
+ def set(config, source)
43
+ @api_config[config.key] = { source: source, config: config }
44
+ end
45
+
46
+ def rm(key)
47
+ @api_config.delete(key)
48
+ end
49
+
50
+ private
51
+
52
+ def fetch_from(source)
53
+ conn = Quonfig::HttpConnection.new(source, @options.sdk_key)
54
+ headers = {}
55
+ headers['If-None-Match'] = @etag if @etag
56
+ response = conn.get(CONFIGS_PATH, headers)
57
+
58
+ case response.status
59
+ when 200
60
+ new_etag = response.headers['ETag'] || response.headers['etag']
61
+ envelope = parse_envelope(response.body)
62
+ replace_api_config(envelope, source)
63
+ @etag = new_etag
64
+ :updated
65
+ when 304
66
+ LOG.debug "Configs not modified (304) from #{source}"
67
+ :not_modified
68
+ else
69
+ LOG.info "Config fetch failed: status #{response.status} from #{source}"
70
+ :failed
71
+ end
72
+ rescue Faraday::ConnectionFailed => e
73
+ LOG.debug "Connection failure fetching configs from #{source}: #{e.message}"
74
+ :failed
75
+ rescue StandardError => e
76
+ LOG.warn "Unexpected error fetching configs from #{source}: #{e.message}"
77
+ :failed
78
+ end
79
+
80
+ def parse_envelope(body)
81
+ data = body.is_a?(String) ? JSON.parse(body) : body
82
+ Quonfig::ConfigEnvelope.new(
83
+ configs: data['configs'] || [],
84
+ meta: data['meta'] || {}
85
+ )
86
+ end
87
+
88
+ def replace_api_config(envelope, source)
89
+ next_map = Concurrent::Map.new
90
+ envelope.configs.each do |cfg|
91
+ key = config_key(cfg)
92
+ next if key.nil?
93
+ next_map[key] = { source: source, config: cfg }
94
+ end
95
+ @api_config = next_map
96
+ end
97
+
98
+ def config_key(cfg)
99
+ return cfg['key'] || cfg[:key] if cfg.is_a?(Hash)
100
+ cfg.respond_to?(:key) ? cfg.key : nil
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # In-memory store of configs keyed by config key.
5
+ #
6
+ # Mirrors sdk-node's ConfigStore (src/store.ts). Integration tests and the
7
+ # new Resolver/Evaluator trio construct this directly, independent of any
8
+ # Client/ConfigLoader plumbing.
9
+ class ConfigStore
10
+ def initialize(initial_configs = nil)
11
+ @lock = Concurrent::ReadWriteLock.new
12
+ @configs = Concurrent::Map.new
13
+ return unless initial_configs
14
+
15
+ initial_configs.each { |k, v| @configs[k] = v }
16
+ end
17
+
18
+ def get(key)
19
+ @configs[key]
20
+ end
21
+
22
+ def set(key, config)
23
+ @lock.with_write_lock { @configs[key] = config }
24
+ end
25
+
26
+ def clear
27
+ @lock.with_write_lock do
28
+ @configs.keys.each { |k| @configs.delete(k) }
29
+ end
30
+ end
31
+
32
+ def keys
33
+ @configs.keys
34
+ end
35
+
36
+ def all_configs
37
+ h = {}
38
+ @configs.each_pair { |k, v| h[k] = v }
39
+ h
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Quonfig context: a two-level Hash (named-context → property → value) wrapped
5
+ # for evaluator consumption. The Evaluator accepts either a plain Hash or a
6
+ # Quonfig::Context — this class exists mostly to flatten lookups (`get`)
7
+ # into the dotted "context-name.property" form criterion rules use.
8
+ class Context
9
+ BLANK_CONTEXT_NAME = ''
10
+
11
+ class NamedContext
12
+ attr_reader :name
13
+
14
+ def initialize(name, hash)
15
+ @name = name.to_s
16
+ @hash = hash.transform_keys(&:to_s)
17
+ end
18
+
19
+ def to_h
20
+ @hash
21
+ end
22
+
23
+ def key
24
+ "#{@name}:#{@hash['key']}"
25
+ end
26
+
27
+ def merge!(other)
28
+ other.each { |k, v| @hash[k.to_s] = v }
29
+ self
30
+ end
31
+ end
32
+
33
+ attr_reader :contexts
34
+
35
+ def initialize(hash = {})
36
+ @contexts = {}
37
+ @flattened = {}
38
+
39
+ raise ArgumentError, 'must be a Hash' unless hash.is_a?(Hash)
40
+
41
+ hash.each do |name, values|
42
+ unless values.is_a?(Hash)
43
+ # Legacy shorthand — pre-named-contexts callers passed a flat Hash.
44
+ values = { name => values }
45
+ name = BLANK_CONTEXT_NAME
46
+ end
47
+
48
+ @contexts[name.to_s] = NamedContext.new(name, values)
49
+ values.each do |key, value|
50
+ @flattened[name.to_s + '.' + key.to_s] = value
51
+ end
52
+ end
53
+ end
54
+
55
+ def blank?
56
+ @contexts.empty?
57
+ end
58
+
59
+ def set(name, hash)
60
+ @contexts[name.to_s] = NamedContext.new(name, hash)
61
+ hash.each do |key, value|
62
+ @flattened[name.to_s + '.' + key.to_s] = value
63
+ end
64
+ end
65
+
66
+ def get(property_key, scope: nil)
67
+ property_key = BLANK_CONTEXT_NAME + '.' + property_key unless property_key.include?('.')
68
+ @flattened[property_key]
69
+ end
70
+
71
+ def to_h
72
+ @contexts.transform_values(&:to_h)
73
+ end
74
+
75
+ def to_s
76
+ "#<Quonfig::Context:#{object_id} #{to_h}>"
77
+ end
78
+
79
+ def clear
80
+ @contexts = {}
81
+ @flattened = {}
82
+ end
83
+
84
+ def context(name)
85
+ @contexts[name.to_s] || NamedContext.new(name, {})
86
+ end
87
+
88
+ def grouped_key
89
+ @contexts.map { |_, ctx| ctx.key }.sort.join('|')
90
+ end
91
+
92
+ include Comparable
93
+ def <=>(other)
94
+ if other.is_a?(Quonfig::Context)
95
+ to_h <=> other.to_h
96
+ else
97
+ super
98
+ end
99
+ end
100
+ end
101
+ end