quonfig 0.0.10 → 0.0.12

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +94 -0
  4. data/lib/quonfig/caching_http_connection.rb +3 -3
  5. data/lib/quonfig/client.rb +22 -27
  6. data/lib/quonfig/config_loader.rb +5 -1
  7. data/lib/quonfig/config_store.rb +10 -6
  8. data/lib/quonfig/context.rb +5 -4
  9. data/lib/quonfig/datadir.rb +4 -10
  10. data/lib/quonfig/dev_context.rb +4 -2
  11. data/lib/quonfig/duration.rb +2 -2
  12. data/lib/quonfig/encryption.rb +12 -16
  13. data/lib/quonfig/errors/invalid_environment_error.rb +1 -3
  14. data/lib/quonfig/errors/invalid_sdk_key_error.rb +6 -7
  15. data/lib/quonfig/errors/missing_env_var_error.rb +0 -3
  16. data/lib/quonfig/errors/missing_environment_error.rb +1 -1
  17. data/lib/quonfig/errors/uninitialized_error.rb +1 -1
  18. data/lib/quonfig/evaluation.rb +11 -8
  19. data/lib/quonfig/evaluator.rb +34 -37
  20. data/lib/quonfig/fixed_size_hash.rb +1 -0
  21. data/lib/quonfig/http_connection.rb +2 -4
  22. data/lib/quonfig/internal_logger.rb +63 -27
  23. data/lib/quonfig/murmer3.rb +2 -2
  24. data/lib/quonfig/options.rb +62 -75
  25. data/lib/quonfig/periodic_sync.rb +1 -1
  26. data/lib/quonfig/quonfig.rb +3 -3
  27. data/lib/quonfig/reason.rb +2 -1
  28. data/lib/quonfig/resolver.rb +8 -9
  29. data/lib/quonfig/semantic_logger_filter.rb +4 -3
  30. data/lib/quonfig/semver.rb +6 -8
  31. data/lib/quonfig/sse_config_client.rb +14 -15
  32. data/lib/quonfig/stdlib_formatter.rb +3 -3
  33. data/lib/quonfig/telemetry/context_shape_aggregator.rb +2 -3
  34. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +1 -1
  35. data/lib/quonfig/telemetry/telemetry_reporter.rb +1 -0
  36. data/lib/quonfig/time_helpers.rb +2 -0
  37. data/lib/quonfig/version.rb +5 -0
  38. data/lib/quonfig.rb +2 -1
  39. data/quonfig.gemspec +29 -165
  40. metadata +24 -193
  41. data/.claude/rules/constitution.md +0 -81
  42. data/.claude/rules/git-safety.md +0 -11
  43. data/.claude/rules/issue-tracking.md +0 -13
  44. data/.claude/rules/testing-workflow.md +0 -28
  45. data/.envrc.sample +0 -3
  46. data/.github/CODEOWNERS +0 -2
  47. data/.github/pull_request_template.md +0 -8
  48. data/.github/workflows/release.yml +0 -49
  49. data/.github/workflows/ruby.yml +0 -60
  50. data/.github/workflows/test.yaml +0 -40
  51. data/.rubocop.yml +0 -13
  52. data/.tool-versions +0 -1
  53. data/CLAUDE.md +0 -29
  54. data/CODEOWNERS +0 -1
  55. data/Gemfile +0 -26
  56. data/Gemfile.lock +0 -177
  57. data/Rakefile +0 -64
  58. data/VERSION +0 -1
  59. data/dev/allocation_stats +0 -60
  60. data/dev/benchmark +0 -40
  61. data/dev/console +0 -12
  62. data/dev/script_setup.rb +0 -18
  63. data/test/fixtures/datafile.json +0 -87
  64. data/test/integration/test_context_precedence.rb +0 -112
  65. data/test/integration/test_datadir_environment.rb +0 -54
  66. data/test/integration/test_dev_overrides.rb +0 -40
  67. data/test/integration/test_enabled.rb +0 -478
  68. data/test/integration/test_enabled_with_contexts.rb +0 -64
  69. data/test/integration/test_get.rb +0 -136
  70. data/test/integration/test_get_feature_flag.rb +0 -28
  71. data/test/integration/test_get_or_raise.rb +0 -60
  72. data/test/integration/test_get_weighted_values.rb +0 -34
  73. data/test/integration/test_helpers.rb +0 -667
  74. data/test/integration/test_helpers_test.rb +0 -73
  75. data/test/integration/test_post.rb +0 -44
  76. data/test/integration/test_telemetry.rb +0 -170
  77. data/test/support/common_helpers.rb +0 -106
  78. data/test/support/mock_base_client.rb +0 -27
  79. data/test/support/mock_config_loader.rb +0 -1
  80. data/test/test_bound_client.rb +0 -109
  81. data/test/test_caching_http_connection.rb +0 -218
  82. data/test/test_client.rb +0 -255
  83. data/test/test_client_network_mode.rb +0 -136
  84. data/test/test_client_telemetry.rb +0 -175
  85. data/test/test_config_loader.rb +0 -70
  86. data/test/test_context.rb +0 -139
  87. data/test/test_context_shape.rb +0 -37
  88. data/test/test_context_shape_aggregator.rb +0 -126
  89. data/test/test_datadir.rb +0 -203
  90. data/test/test_details_getters.rb +0 -242
  91. data/test/test_dev_context.rb +0 -163
  92. data/test/test_duration.rb +0 -37
  93. data/test/test_encryption.rb +0 -16
  94. data/test/test_evaluation_summaries_aggregator.rb +0 -180
  95. data/test/test_evaluator.rb +0 -285
  96. data/test/test_example_contexts_aggregator.rb +0 -119
  97. data/test/test_exponential_backoff.rb +0 -44
  98. data/test/test_fixed_size_hash.rb +0 -119
  99. data/test/test_helper.rb +0 -17
  100. data/test/test_http_connection.rb +0 -81
  101. data/test/test_internal_logger.rb +0 -34
  102. data/test/test_options.rb +0 -198
  103. data/test/test_rate_limit_cache.rb +0 -44
  104. data/test/test_reason.rb +0 -79
  105. data/test/test_rename.rb +0 -65
  106. data/test/test_resolver.rb +0 -291
  107. data/test/test_semantic_logger_filter.rb +0 -144
  108. data/test/test_semver.rb +0 -108
  109. data/test/test_should_log.rb +0 -186
  110. data/test/test_sse_config_client.rb +0 -297
  111. data/test/test_stdlib_formatter.rb +0 -195
  112. data/test/test_telemetry_reporter.rb +0 -209
  113. data/test/test_typed_getters.rb +0 -131
  114. data/test/test_types.rb +0 -141
  115. data/test/test_weighted_value_resolver.rb +0 -84
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94866b65b3e3c4e834897981847da01f350984b2f7126f8259318ba385b8fd77
4
- data.tar.gz: bc173a4300c1475f596921de2a2d24e02e5de648503318491a602c19123dd329
3
+ metadata.gz: 3ae93c1a78be83e339ff86bf9eaa58efa3376602c363ddd1d4c6e326fe102865
4
+ data.tar.gz: 38debe8196e6a9ae1f5281b810d0193a4d8a821a45bb18afeaf103732f1d92d7
5
5
  SHA512:
6
- metadata.gz: 875265c218e5a465abced6d47563ef7c86ade61ca2d98367059bc800dec55e1148badf58e60b632e468ab565ef5ec80e587b8f0fd045f5300b000f64fe12b909
7
- data.tar.gz: 6725f7b13ac6ba43d0e7903963c5de4b1bbe03f1ae7e51399e4d0cc8877f6ee637ef2850e396e65c8d5c44cac2ee7cfa0b2f1724d390d43746d421d7e465c42a
6
+ metadata.gz: de28a9ffb64be4d7a5c3c4982a13713cfd44ba0abcef4bbbcf3a385de9ddd881fe814e319d6770ec572f7b9655e8cc6f466d887040fa300e4d9513dce54e045e
7
+ data.tar.gz: 0c1ac8d2e32d95534f0f903d0fa6df1a2a627a4d7f5f68a27e14448230e67804b3c859e0e1d551cde7c7ce6e2a7c453ebfb67aee7f30e1834753130328344663
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.12 - 2026-05-03
4
+
5
+ - **Feat: pluggable `logger:` kwarg on `Quonfig::Client.new`.** Host apps can now pass `Rails.logger` (or any stdlib `Logger`-compatible instance) and have all SDK warnings/errors flow through it instead of bare stderr / SemanticLogger. Implemented as a class-level `Quonfig::InternalLogger.user_logger` override that all `LOG` constants respect at log-call time, so existing per-class `LOG` constants pick it up automatically. Duck-typed (responds to `debug`/`info`/`warn`/`error`); missing levels degrade gracefully. SemanticLogger auto-detection is unchanged when no logger is supplied. Also routes the two outlier `dev_context.rb` warns (file read / JSON parse failures) through `InternalLogger` so they pick up the host-supplied logger too. (qfg-mol-1qw.3)
6
+
7
+ ## 0.0.11 - 2026-05-02
8
+
9
+ - **Fix (telemetry): SSE clientName attribution.** The SSE client was sending
10
+ `X-Quonfig-SDK-Version: sdk-ruby-<version>`, which the api-telemetry parser
11
+ splits on the first dash, so it landed as `clientName="sdk"`,
12
+ `clientVersion="ruby-<version>"`. Now sends `ruby-<version>` to match
13
+ `http_connection.rb`, so both transports attribute consistently as
14
+ `clientName="ruby"`.
15
+ - **Release plumbing: drop juwelier, tag-triggered publish.** The gem is now
16
+ built from a hand-written `quonfig.gemspec` that reads the version from
17
+ `Quonfig::VERSION` (in `lib/quonfig/version.rb`) and lists shipped files
18
+ explicitly. The `release.yml` workflow now fires on `v*` tag pushes, not
19
+ every successful main build, and refuses to publish unless the tag matches
20
+ `Quonfig::VERSION`. Together these eliminate the gemspec-vs-VERSION drift
21
+ that prevented the original 0.0.11 publish (gem built as 0.0.10 internally
22
+ while filename said 0.0.11) and the manifest-drift bug from qfg-e588.
23
+
3
24
  ## 0.0.10 - 2026-05-01
4
25
 
5
26
  - **BREAKING (env): `QUONFIG_TELEMETRY_URL` and `QUONFIG_API_URLS` env vars
@@ -16,6 +37,15 @@
16
37
  `telemetry_url:` kwarg (was previously documented but not wired up).
17
38
  - **Default `api_urls` now includes secondary.** Was `[primary]`, now
18
39
  `[primary, secondary]` to match every other SDK and provide failover.
40
+ - **Release plumbing: pre-publish smoke check (qfg-e588).** The Rakefile
41
+ `:release` task and the `release.yml` workflow now run
42
+ `scripts/smoke_check.sh` after `gem build` and before `gem push`. The
43
+ script installs the freshly built `.gem` into a sandbox `GEM_HOME` and
44
+ shells out to `ruby -rquonfig -e 'puts Quonfig::VERSION'`. If the
45
+ require fails or the version mismatches, the publish aborts. This is
46
+ the prevention measure for qfg-e588, where 0.0.9 was published with a
47
+ stale gemspec manifest missing `lib/quonfig/evaluation_details.rb` and
48
+ every consumer hit `LoadError` at install time.
19
49
 
20
50
  ## 0.0.8 - 2026-04-26
21
51
 
data/README.md CHANGED
@@ -147,6 +147,7 @@ Quonfig::Client.new(
147
147
  | `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
148
148
  | `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
149
149
  | `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
150
+ | `logger` | Logger-like object | `nil` | Optional host-app logger (e.g. `Rails.logger`). Must respond to `debug`/`info`/`warn`/`error`. When set, all SDK warnings/errors flow through this logger instead of the default stderr / SemanticLogger backend. |
150
151
 
151
152
  ## Typed getters
152
153
 
@@ -239,6 +240,99 @@ logger.progname = 'MyApp::Services::Auth'
239
240
 
240
241
  If both are supplied, the explicit `logger_name:` wins.
241
242
 
243
+ ## Rails integration
244
+
245
+ The SDK runs a background SSE thread (and optional polling thread) that you do
246
+ not want to inherit across a `fork(2)`. Forked threads in the child process
247
+ are dead — the SSE socket is held open by a thread that no longer exists, and
248
+ the child silently stops receiving live updates.
249
+
250
+ Use `Quonfig::Client#fork` (or `Quonfig.fork` if you use the module-level
251
+ singleton) in any process that fork-spawns workers. It returns a fresh client
252
+ configured for the child: a new `ConfigStore`, a new SSE subscription, and
253
+ suppressed telemetry double-counting (`Options#is_fork` is set to `true`).
254
+
255
+ ### Puma (clustered mode)
256
+
257
+ ```ruby
258
+ # config/puma.rb
259
+ before_fork do
260
+ Quonfig.instance.stop # close the master's SSE before forking
261
+ end
262
+
263
+ on_worker_boot do
264
+ Quonfig.fork # rebuild a fresh client per worker
265
+ end
266
+ ```
267
+
268
+ If you initialize Quonfig lazily (in a Rails initializer) and run Puma in
269
+ single mode (no clustering), no fork hook is needed.
270
+
271
+ ### Sidekiq
272
+
273
+ Sidekiq's parent process forks workers. Wire the same lifecycle:
274
+
275
+ ```ruby
276
+ # config/initializers/quonfig.rb
277
+ Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))
278
+
279
+ # config/initializers/sidekiq.rb
280
+ Sidekiq.configure_server do |config|
281
+ config.on(:startup) { Quonfig.fork if Process.ppid != 1 }
282
+ config.on(:shutdown) { Quonfig.instance.stop rescue nil }
283
+ end
284
+ ```
285
+
286
+ For Sidekiq web/CLI processes that don't fork (default `concurrency: 1`),
287
+ `Quonfig.init` in the initializer is sufficient.
288
+
289
+ ### Spring / Bootsnap preloaders
290
+
291
+ Spring forks the preloader for each command. If your initializer creates a
292
+ Quonfig client at boot, the SSE thread will be inherited dead in every child.
293
+ Two options:
294
+
295
+ 1. **Recommended:** initialize lazily — wrap `Quonfig.init` so it only runs
296
+ the first time `Quonfig.instance` is called from a non-preloader process.
297
+ 2. **Or:** call `Quonfig.fork` from a `Spring.after_fork` hook.
298
+
299
+ ```ruby
300
+ # config/spring.rb
301
+ Spring.after_fork do
302
+ Quonfig.fork if defined?(Quonfig) && Quonfig.instance_variable_get(:@singleton)
303
+ end
304
+ ```
305
+
306
+ ### Code reloading (Zeitwerk, development mode)
307
+
308
+ `Quonfig::Client` is a long-lived object — keep it out of `app/` (where
309
+ Zeitwerk reloads classes on every request) and pin it to a constant set in a
310
+ Rails initializer. The client itself is reload-safe because it does not
311
+ reference any application classes; the failure mode to avoid is *creating a
312
+ new client per request*, which leaks SSE threads and quickly exhausts file
313
+ descriptors.
314
+
315
+ ```ruby
316
+ # config/initializers/quonfig.rb
317
+ # Quonfig.init is idempotent — a second call warns and returns the existing
318
+ # singleton — so it's safe to wrap in to_prepare for reload-friendliness.
319
+ Rails.application.config.to_prepare do
320
+ Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))
321
+ end
322
+ ```
323
+
324
+ ## Thread safety
325
+
326
+ `Quonfig::Client` is safe to share across threads. Reads (`get`, `enabled?`,
327
+ `get_*`) and SSE-driven writes to the underlying `ConfigStore` use
328
+ `Concurrent::Map` for per-key atomicity. Eventual consistency across an
329
+ envelope is intentional: a reader concurrent with envelope application may
330
+ observe the new value for some keys and the old value for others, then
331
+ converge once the envelope finishes applying.
332
+
333
+ `Quonfig.fork` is the only safe way to "carry" a client across `Process.fork`
334
+ — do not reuse the parent's client in a child process.
335
+
242
336
  ## Documentation
243
337
 
244
338
  Full documentation, including SPEC, SDK reference, and operational guides, is
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Quonfig
4
4
  class CachingHttpConnection
5
- CACHE_SIZE = 2.freeze
5
+ CACHE_SIZE = 2
6
6
  CacheEntry = Struct.new(:data, :etag, :expires_at)
7
7
 
8
8
  class << self
@@ -68,7 +68,7 @@ module Quonfig
68
68
  return response if cache_control.include?('no-store')
69
69
 
70
70
  # Calculate expiration
71
- max_age = cache_control.match(/max-age=(\d+)/)&.captures&.first&.to_i
71
+ max_age = cache_control[/max-age=(\d+)/, 1]&.to_i
72
72
  expires_at = max_age ? now + max_age : nil
73
73
 
74
74
  # Cache the response if we have caching headers
@@ -92,4 +92,4 @@ module Quonfig
92
92
  @connection.uri
93
93
  end
94
94
  end
95
- end
95
+ end
@@ -32,6 +32,7 @@ module Quonfig
32
32
  else
33
33
  Quonfig::Options.new(option_kwargs)
34
34
  end
35
+ Quonfig::InternalLogger.user_logger = @options.logger if @options.logger
35
36
  @global_context = build_initial_global_context(@options)
36
37
  @instance_hash = SecureRandom.uuid
37
38
  @store = store || Quonfig::ConfigStore.new
@@ -137,7 +138,7 @@ module Quonfig
137
138
 
138
139
  def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
139
140
  value = get(feature_name, false, jit_context)
140
- value == true || value == 'true'
141
+ [true, 'true'].include?(value)
141
142
  end
142
143
 
143
144
  def defined?(key)
@@ -295,19 +296,19 @@ module Quonfig
295
296
  example_aggregator = nil
296
297
  summaries_aggregator = nil
297
298
 
298
- if @options.collect_max_shapes.to_i > 0
299
+ if @options.collect_max_shapes.to_i.positive?
299
300
  shape_aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(
300
301
  max_shapes: @options.collect_max_shapes
301
302
  )
302
303
  end
303
304
 
304
- if @options.collect_max_example_contexts.to_i > 0
305
+ if @options.collect_max_example_contexts.to_i.positive?
305
306
  example_aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(
306
307
  max_contexts: @options.collect_max_example_contexts
307
308
  )
308
309
  end
309
310
 
310
- if @options.collect_max_evaluation_summaries.to_i > 0
311
+ if @options.collect_max_evaluation_summaries.to_i.positive?
311
312
  summaries_aggregator = Quonfig::Telemetry::EvaluationSummariesAggregator.new(
312
313
  max_keys: @options.collect_max_evaluation_summaries
313
314
  )
@@ -382,9 +383,7 @@ module Quonfig
382
383
  # Initialize network mode: sync HTTP fetch (bounded by
383
384
  # initialization_timeout_sec) then start SSE + polling as requested.
384
385
  def initialize_network_mode
385
- if @options.sdk_key.nil? || @options.sdk_key.to_s.strip.empty?
386
- raise Quonfig::Errors::InvalidSdkKeyError, @options.sdk_key
387
- end
386
+ raise Quonfig::Errors::InvalidSdkKeyError, @options.sdk_key if @options.sdk_key.nil? || @options.sdk_key.to_s.strip.empty?
388
387
 
389
388
  @config_loader = Quonfig::ConfigLoader.new(@store, @options)
390
389
 
@@ -433,6 +432,7 @@ module Quonfig
433
432
  @sse_client = Quonfig::SSEConfigClient.new(@options, @config_loader)
434
433
  @sse_client.start do |envelope, _event, _source|
435
434
  next if @stopped
435
+
436
436
  begin
437
437
  @config_loader.apply_envelope(envelope)
438
438
  @on_update&.call
@@ -455,6 +455,7 @@ module Quonfig
455
455
  Thread.current.name = 'quonfig-poller'
456
456
  loop do
457
457
  break if @stopped
458
+
458
459
  sleep poll_interval
459
460
  break if @stopped
460
461
 
@@ -505,11 +506,11 @@ module Quonfig
505
506
  merged = {}
506
507
  left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
507
508
  right.each do |name, ctx|
508
- if merged[name].is_a?(Hash) && ctx.is_a?(Hash)
509
- merged[name] = merged[name].merge(ctx)
510
- else
511
- merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx
512
- end
509
+ merged[name] = if merged[name].is_a?(Hash) && ctx.is_a?(Hash)
510
+ merged[name].merge(ctx)
511
+ else
512
+ ctx.is_a?(Hash) ? ctx.dup : ctx
513
+ end
513
514
  end
514
515
  merged
515
516
  end
@@ -525,9 +526,7 @@ module Quonfig
525
526
  def handle_missing(key, default)
526
527
  return default if default != NO_DEFAULT_PROVIDED
527
528
 
528
- if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
529
- raise Quonfig::Errors::MissingDefaultError, key
530
- end
529
+ raise Quonfig::Errors::MissingDefaultError, key if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
531
530
 
532
531
  nil
533
532
  end
@@ -602,29 +601,25 @@ module Quonfig
602
601
  def coerce_and_check(key, value, expected_type)
603
602
  case expected_type
604
603
  when :bool
605
- unless value == true || value == false
606
- raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', value)
607
- end
604
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', value) unless [true, false].include?(value)
605
+
608
606
  value
609
607
  when :string_list
610
608
  arr = value.is_a?(Array) ? value : nil
611
- unless arr && arr.all? { |v| v.is_a?(String) }
612
- raise Quonfig::Errors::TypeMismatchError.new(key, 'Array<String>', value)
613
- end
609
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'Array<String>', value) unless arr&.all?(String)
610
+
614
611
  arr
615
612
  when :duration
616
613
  return value.to_i if value.is_a?(Numeric)
617
- if value.is_a?(String)
618
- return (Quonfig::Duration.parse(value) * 1000).to_i
619
- end
614
+ return (Quonfig::Duration.parse(value) * 1000).to_i if value.is_a?(String)
615
+
620
616
  raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value)
621
617
  when :json
622
618
  # JSON values are returned as-is (Hash, Array, or scalar from the wire).
623
619
  value
624
620
  when Class
625
- unless value.is_a?(expected_type)
626
- raise Quonfig::Errors::TypeMismatchError.new(key, "expected #{expected_type}", value)
627
- end
621
+ raise Quonfig::Errors::TypeMismatchError.new(key, "expected #{expected_type}", value) unless value.is_a?(expected_type)
622
+
628
623
  value
629
624
  else
630
625
  value
@@ -124,8 +124,9 @@ module Quonfig
124
124
 
125
125
  def short_body(response)
126
126
  return '' if response.body.nil?
127
+
127
128
  str = response.body.to_s
128
- str.length > 200 ? str[0, 200] + '...' : str
129
+ str.length > 200 ? "#{str[0, 200]}..." : str
129
130
  end
130
131
 
131
132
  def install_envelope(envelope, source:)
@@ -134,6 +135,7 @@ module Quonfig
134
135
  envelope.configs.each do |cfg|
135
136
  key = config_key(cfg)
136
137
  next if key.nil?
138
+
137
139
  next_map[key] = { source: source, config: cfg }
138
140
  end
139
141
  @api_config = next_map
@@ -153,12 +155,14 @@ module Quonfig
153
155
  envelope.configs.each do |cfg|
154
156
  key = config_key(cfg)
155
157
  next if key.nil?
158
+
156
159
  @store.set(key, cfg)
157
160
  end
158
161
  end
159
162
 
160
163
  def config_key(cfg)
161
164
  return cfg['key'] || cfg[:key] if cfg.is_a?(Hash)
165
+
162
166
  cfg.respond_to?(:key) ? cfg.key : nil
163
167
  end
164
168
  end
@@ -6,9 +6,15 @@ module Quonfig
6
6
  # Mirrors sdk-node's ConfigStore (src/store.ts). Integration tests and the
7
7
  # new Resolver/Evaluator trio construct this directly, independent of any
8
8
  # Client/ConfigLoader plumbing.
9
+ #
10
+ # Thread-safety: backed by Concurrent::Map, whose per-key reads, writes, and
11
+ # deletes are atomic. There is no compound multi-key operation here that
12
+ # needs an outer lock — envelope application in ConfigLoader is a sequence
13
+ # of independent set/delete calls, and readers tolerate seeing the
14
+ # in-progress mix. Eventual consistency across an envelope is acceptable
15
+ # and matches sdk-node behavior.
9
16
  class ConfigStore
10
17
  def initialize(initial_configs = nil)
11
- @lock = Concurrent::ReadWriteLock.new
12
18
  @configs = Concurrent::Map.new
13
19
  return unless initial_configs
14
20
 
@@ -20,17 +26,15 @@ module Quonfig
20
26
  end
21
27
 
22
28
  def set(key, config)
23
- @lock.with_write_lock { @configs[key] = config }
29
+ @configs[key] = config
24
30
  end
25
31
 
26
32
  def delete(key)
27
- @lock.with_write_lock { @configs.delete(key) }
33
+ @configs.delete(key)
28
34
  end
29
35
 
30
36
  def clear
31
- @lock.with_write_lock do
32
- @configs.keys.each { |k| @configs.delete(k) }
33
- end
37
+ @configs.each_key { |k| @configs.delete(k) }
34
38
  end
35
39
 
36
40
  def keys
@@ -47,7 +47,7 @@ module Quonfig
47
47
 
48
48
  @contexts[name.to_s] = NamedContext.new(name, values)
49
49
  values.each do |key, value|
50
- @flattened[name.to_s + '.' + key.to_s] = value
50
+ @flattened["#{name}.#{key}"] = value
51
51
  end
52
52
  end
53
53
  end
@@ -59,12 +59,12 @@ module Quonfig
59
59
  def set(name, hash)
60
60
  @contexts[name.to_s] = NamedContext.new(name, hash)
61
61
  hash.each do |key, value|
62
- @flattened[name.to_s + '.' + key.to_s] = value
62
+ @flattened["#{name}.#{key}"] = value
63
63
  end
64
64
  end
65
65
 
66
66
  def get(property_key, scope: nil)
67
- property_key = BLANK_CONTEXT_NAME + '.' + property_key unless property_key.include?('.')
67
+ property_key = "#{BLANK_CONTEXT_NAME}.#{property_key}" unless property_key.include?('.')
68
68
  @flattened[property_key]
69
69
  end
70
70
 
@@ -94,11 +94,12 @@ module Quonfig
94
94
  @contexts.values.map do |ctx|
95
95
  h = ctx.to_h
96
96
  v = h['key'] || h[:key] || h['trackingId'] || h[:trackingId]
97
- v.nil? ? nil : v.to_s
97
+ v&.to_s
98
98
  end.compact.reject(&:empty?).sort.join('|')
99
99
  end
100
100
 
101
101
  include Comparable
102
+
102
103
  def <=>(other)
103
104
  if other.is_a?(Quonfig::Context)
104
105
  to_h <=> other.to_h
@@ -56,21 +56,15 @@ module Quonfig
56
56
  end
57
57
 
58
58
  def resolve_environment(quonfig_path, environment)
59
- environment ||= ENV['QUONFIG_ENVIRONMENT']
59
+ environment ||= ENV.fetch('QUONFIG_ENVIRONMENT', nil)
60
60
 
61
- if environment.nil? || environment.empty?
62
- raise Quonfig::Errors::MissingEnvironmentError
63
- end
61
+ raise Quonfig::Errors::MissingEnvironmentError if environment.nil? || environment.empty?
64
62
 
65
- unless File.exist?(quonfig_path)
66
- raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}"
67
- end
63
+ raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}" unless File.exist?(quonfig_path)
68
64
 
69
65
  environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
70
66
 
71
- if !environments.empty? && !environments.include?(environment)
72
- raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments)
73
- end
67
+ raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments) if !environments.empty? && !environments.include?(environment)
74
68
 
75
69
  environment
76
70
  end
@@ -12,6 +12,8 @@ module Quonfig
12
12
  # run `qfg login` and therefore have no tokens file. Rules keyed on
13
13
  # `quonfig-user.email` are dead code in prod.
14
14
  module DevContext
15
+ LOG = Quonfig::InternalLogger.new(self)
16
+
15
17
  TOKENS_BASENAME = File.join('.quonfig', 'tokens.json')
16
18
 
17
19
  def self.load_quonfig_user_context
@@ -21,14 +23,14 @@ module Quonfig
21
23
  raw = begin
22
24
  File.read(path)
23
25
  rescue StandardError => e
24
- warn "[quonfig] dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
26
+ LOG.warn "dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
25
27
  return nil
26
28
  end
27
29
 
28
30
  parsed = begin
29
31
  JSON.parse(raw)
30
32
  rescue JSON::ParserError => e
31
- warn "[quonfig] dev-context: could not parse #{path} (#{e.message}); skipping injection"
33
+ LOG.warn "dev-context: could not parse #{path} (#{e.message}); skipping injection"
32
34
  return nil
33
35
  end
34
36
 
@@ -20,7 +20,7 @@ module Quonfig
20
20
  minutes = match[:minutes]&.to_f || 0
21
21
  seconds = match[:seconds]&.to_f || 0
22
22
 
23
- (days * DAYS_IN_SECONDS + hours * HOURS_IN_SECONDS + minutes * MINUTES_IN_SECONDS + seconds)
23
+ ((days * DAYS_IN_SECONDS) + (hours * HOURS_IN_SECONDS) + (minutes * MINUTES_IN_SECONDS) + seconds)
24
24
  end
25
25
 
26
26
  def in_seconds
@@ -52,7 +52,7 @@ module Quonfig
52
52
  end
53
53
 
54
54
  def as_json
55
- { ms: in_seconds * 1000, seconds: in_seconds }
55
+ { ms: in_seconds * 1000, seconds: in_seconds }
56
56
  end
57
57
  end
58
58
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Quonfig
4
4
  class Encryption
5
- CIPHER_TYPE = "aes-256-gcm" # 32/12
6
- SEPARATOR = "--"
5
+ CIPHER_TYPE = 'aes-256-gcm' # 32/12
6
+ SEPARATOR = '--'
7
7
  AUTH_TAG_LENGTH = 16
8
8
 
9
9
  # Hexadecimal format ensures that generated keys are representable with
@@ -12,11 +12,11 @@ module Quonfig
12
12
  # To convert back to the original string with the desired length:
13
13
  # [ value ].pack("H*")
14
14
  def self.generate_new_hex_key
15
- generate_random_key.unpack("H*")[0]
15
+ generate_random_key.unpack1('H*')
16
16
  end
17
17
 
18
18
  def initialize(key_string_hex)
19
- @key = [key_string_hex].pack("H*")
19
+ @key = [key_string_hex].pack('H*')
20
20
  end
21
21
 
22
22
  def encrypt(clear_text)
@@ -27,42 +27,38 @@ module Quonfig
27
27
  # load them into the cipher
28
28
  cipher.key = @key
29
29
  cipher.iv = iv
30
- cipher.auth_data = ""
30
+ cipher.auth_data = ''
31
31
 
32
32
  # encrypt the message
33
33
  encrypted = cipher.update(clear_text)
34
34
  encrypted << cipher.final
35
35
  tag = cipher.auth_tag
36
-
36
+
37
37
  # pack and join
38
- [encrypted, iv, tag].map { |p| p.unpack("H*")[0] }.join(SEPARATOR)
38
+ [encrypted, iv, tag].map { |p| p.unpack1('H*') }.join(SEPARATOR)
39
39
  end
40
40
 
41
41
  def decrypt(encrypted_string)
42
- encrypted_data, iv, auth_tag = encrypted_string.split(SEPARATOR).map { |p| [p].pack("H*") }
43
-
42
+ encrypted_data, iv, auth_tag = encrypted_string.split(SEPARATOR).map { |p| [p].pack('H*') }
43
+
44
44
  # Currently the OpenSSL bindings do not raise an error if auth_tag is
45
45
  # truncated, which would allow an attacker to easily forge it. See
46
46
  # https://github.com/ruby/openssl/issues/63
47
- if auth_tag.bytesize != AUTH_TAG_LENGTH
48
- raise "truncated auth_tag"
49
- end
47
+ raise 'truncated auth_tag' if auth_tag.bytesize != AUTH_TAG_LENGTH
50
48
 
51
49
  cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
52
50
  cipher.decrypt
53
51
  cipher.key = @key
54
52
  cipher.iv = iv
55
-
53
+
56
54
  cipher.auth_tag = auth_tag
57
-
55
+
58
56
  # and decrypt it
59
57
  decrypted = cipher.update(encrypted_data)
60
58
  decrypted << cipher.final
61
59
  decrypted
62
60
  end
63
61
 
64
- private
65
-
66
62
  def self.generate_random_key
67
63
  SecureRandom.random_bytes(key_length)
68
64
  end
@@ -9,9 +9,7 @@ module Quonfig
9
9
  class InvalidEnvironmentError < Quonfig::Error
10
10
  def initialize(environment, available = nil)
11
11
  message = "[quonfig] Environment \"#{environment}\" not found in workspace"
12
- if available && !Array(available).empty?
13
- message += "; available environments: #{Array(available).join(', ')}"
14
- end
12
+ message += "; available environments: #{Array(available).join(', ')}" if available && !Array(available).empty?
15
13
  super(message)
16
14
  end
17
15
  end
@@ -4,15 +4,14 @@ module Quonfig
4
4
  module Errors
5
5
  class InvalidSdkKeyError < Quonfig::Error
6
6
  def initialize(key)
7
- if key.nil? || key.empty?
8
- message = 'No SDK key. Set QUONFIG_BACKEND_SDK_KEY env var or use QUONFIG_DATAFILE'
7
+ message = if key.nil? || key.empty?
8
+ 'No SDK key. Set QUONFIG_BACKEND_SDK_KEY env var or use QUONFIG_DATAFILE'
9
9
 
10
- super(message)
11
- else
12
- message = "Your SDK key format is invalid. Expecting something like 123-development-yourapikey-SDK. You provided `#{key}`"
10
+ else
11
+ "Your SDK key format is invalid. Expecting something like 123-development-yourapikey-SDK. You provided `#{key}`"
13
12
 
14
- super(message)
15
- end
13
+ end
14
+ super(message)
16
15
  end
17
16
  end
18
17
  end
@@ -3,9 +3,6 @@
3
3
  module Quonfig
4
4
  module Errors
5
5
  class MissingEnvVarError < Quonfig::Error
6
- def initialize(message)
7
- super(message)
8
- end
9
6
  end
10
7
  end
11
8
  end
@@ -11,7 +11,7 @@ module Quonfig
11
11
  def initialize(message = nil)
12
12
  message ||= '[quonfig] Environment required for datadir mode; ' \
13
13
  'set the `environment` option or QUONFIG_ENVIRONMENT env var'
14
- super(message)
14
+ super
15
15
  end
16
16
  end
17
17
  end
@@ -3,7 +3,7 @@
3
3
  module Quonfig
4
4
  module Errors
5
5
  class UninitializedError < Quonfig::Error
6
- def initialize(key=nil)
6
+ def initialize(key = nil)
7
7
  message = "Use Quonfig.initialize before calling Quonfig.get #{key}"
8
8
 
9
9
  super(message)