quonfig 2.0.0 → 2.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7947aba694daddefb1357930d7d1b3e63d13c3f68ad71b1e5d33eff15584aa6
4
- data.tar.gz: db349b68906829c24953f9504511200f7b837c8980ad9beb53a755586420f470
3
+ metadata.gz: 939842e745249a0ce6cee689801a6fe3a7cbdc56552ad88bdabaa0c36e79ef92
4
+ data.tar.gz: df5b171629c4b6c556f04b80a0035d5726c76929e78d11ba5382332d0e4b2293
5
5
  SHA512:
6
- metadata.gz: 687f621eeff45b850bba0ab6845963abbdb8478ac18e32e1006d9f02de3b8a3ed850b75bf37ae2e907dd9512756f98fc79ae942221aaa7c17a0abd683bb9b920
7
- data.tar.gz: 96792d273e01d2748f78435a75c3d8b4be405ff65470f465b646595539cf786ab4c204cab167350e66b42be03907739e49aaa01ad5b8a2228ebd7736fd4bb8e8
6
+ metadata.gz: 4c6822328fa6147d3816b404fe9543991d7095a5f663a2dc045f425113daba93f18da02e739c32dfce962858b00c190809acf4a62e56f1f049669bf813d8cdcd
7
+ data.tar.gz: 04a636c09573823cf0895c3cbbdcd2d9d79f22b875a877e55587244a2077bf1ae054007b729cc64bb8a2208e806fbdb962a7c55be38f2663c015b41d2685e09d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 - 2026-10-29
4
+
5
+ - **Added: `evaluate_details(key, context:)`, an untyped details read (qfg-177f).** It returns a `Quonfig::EvaluationDetails` whose `value` is the config's resolved value as stored, after ENV_VAR provision and decryption, with no type check or conversion: a duration comes back as its ISO 8601 text exactly as stored (`"PT90S"`, not milliseconds), a `string_list` as an Array, JSON as stored. The new `value_type` attribute names the stored type (`"duration"`, `"int"`, ...; `nil` when nothing resolved). Reason, variant, `flag_metadata`, error codes and telemetry are the same as the typed `*_details` getters, and it never raises. A malformed stored value (a duration `"30s"`) is returned as-is with a success reason; an unset or unparseable ENV_VAR is still `reason: "ERROR"`, as in the typed getters. Available on the client and on a `with_context` bound client. It matches sdk-go's `Client.EvaluateDetails` and is meant for OpenFeature providers and other callers that do their own typing. Existing getters are unchanged. Tests: `test/test_evaluate_details.rb`.
6
+ - **Fixed: `connection_state` no longer answers `:connected` before the SSE stream's first frame (qfg-fi1s).** On a network client with SSE on, the init fetch installs an envelope before the stream has delivered anything, and `connection_state` read that install as `:connected`. It now reports `:initializing` until the stream's first frame arrives (the connected edge), as the supervisor contract says ("after 200 headers, no frame" is initializing) and as sdk-go, sdk-java, sdk-net and sdk-python already do. Datadir clients and clients with `enable_sse: false` still read `:connected` once config is installed. Tests: `test/test_health_primitives.rb`, `test/test_sse_first_frame.rb` (Test 9a/9b now assert on the public getter).
7
+
3
8
  ## 2.0.0 - 2026-10-26
4
9
 
5
10
  The typed-getter contract (qfg-zvyy), shared by every Quonfig SDK and pinned by
data/README.md CHANGED
@@ -484,6 +484,21 @@ raise. They return `Quonfig::EvaluationDetails`; a wrong-type or malformed
484
484
  value is `reason: "ERROR"` with `error_code: "TYPE_MISMATCH"`.
485
485
  `get_duration_details` carries the duration in integer milliseconds.
486
486
 
487
+ To read a value without a type, use `evaluate_details(key, context:)`. It
488
+ returns the value as stored, with `value_type` naming its type, and the same
489
+ `reason`, `variant` and `flag_metadata` as the typed details getters. A
490
+ duration comes back as its ISO 8601 text:
491
+
492
+ ```ruby
493
+ details = client.evaluate_details('http.timeout', context: { user: { key: 'u1' } })
494
+ details.value # => "PT90S"
495
+ details.value_type # => "duration"
496
+ details.reason # => "STATIC"
497
+ ```
498
+
499
+ It does no type check and never raises; a missing key is `reason: "ERROR"`
500
+ with `error_code: "FLAG_NOT_FOUND"`.
501
+
487
502
  ## Dynamic log levels (SemanticLogger)
488
503
 
489
504
  Quonfig can drive per-class log levels at runtime. Set config keys like
@@ -607,8 +622,8 @@ Caveats:
607
622
  threads race the first request.
608
623
  - **`connection_state` never triggers the re-initialization** — a diagnostic
609
624
  must not open a socket. A child that has not used the client yet answers
610
- `:initializing`, which is exactly what it is; it flips to `:connected` on
611
- first use.
625
+ `:initializing`, which is exactly what it is; it flips to `:connected`
626
+ after its first use, once the stream delivers its first frame.
612
627
  - The child's telemetry aggregators start empty. The parent flushes the data
613
628
  it collected before the fork; the child reports only its own.
614
629
  - **Per-job forking pays per job.** A Resque-style worker that forks a child
@@ -757,7 +772,9 @@ reuse the parent's client object in a child process without it.
757
772
  envelope install (any source: datadir, initial HTTP fetch, SSE, or fallback
758
773
  polling). Returns `nil` before the first install. Preserved across `stop`.
759
774
  - `client.connection_state` — a `Symbol` describing the aggregate state:
760
- `:initializing`, `:connected`, `:disconnected`, or `:falling_back`.
775
+ `:initializing`, `:connected`, `:disconnected`, or `:falling_back`. With
776
+ SSE on, the client reads `:initializing` until the stream delivers its
777
+ first frame, even after the initial fetch has installed config.
761
778
  - `client.callback_errors_total(callback: 'on_config_update')` — how many
762
779
  times your `on_update` block raised. The SDK catches the error, logs it at
763
780
  ERROR with its stack, and keeps delivering updates; a raise is not a worker
@@ -74,6 +74,11 @@ module Quonfig
74
74
  @client.get_duration_details(key, context: @context)
75
75
  end
76
76
 
77
+ # See Quonfig::Client#evaluate_details.
78
+ def evaluate_details(key)
79
+ @client.evaluate_details(key, context: @context)
80
+ end
81
+
77
82
  def enabled?(feature_name)
78
83
  @client.enabled?(feature_name, @context)
79
84
  end
@@ -285,34 +285,48 @@ module Quonfig
285
285
  # raise — exceptions are caught and rendered as ERROR details.
286
286
 
287
287
  def get_bool_details(key, context: NO_DEFAULT_PROVIDED)
288
- evaluate_details(key, :bool, context)
288
+ build_details(key, :bool, context)
289
289
  end
290
290
 
291
291
  def get_string_details(key, context: NO_DEFAULT_PROVIDED)
292
- evaluate_details(key, String, context)
292
+ build_details(key, String, context)
293
293
  end
294
294
 
295
295
  def get_int_details(key, context: NO_DEFAULT_PROVIDED)
296
- evaluate_details(key, Integer, context)
296
+ build_details(key, Integer, context)
297
297
  end
298
298
 
299
299
  def get_float_details(key, context: NO_DEFAULT_PROVIDED)
300
- evaluate_details(key, Float, context)
300
+ build_details(key, Float, context)
301
301
  end
302
302
 
303
303
  def get_string_list_details(key, context: NO_DEFAULT_PROVIDED)
304
- evaluate_details(key, :string_list, context)
304
+ build_details(key, :string_list, context)
305
305
  end
306
306
 
307
307
  def get_json_details(key, context: NO_DEFAULT_PROVIDED)
308
- evaluate_details(key, :json, context)
308
+ build_details(key, :json, context)
309
309
  end
310
310
 
311
311
  # Integer milliseconds in +value+ (qfg-2agi.27). A config whose valueType
312
312
  # is not duration is ERROR / TYPE_MISMATCH; a malformed value (stored or
313
313
  # ENV_VAR) is ERROR with a nil value.
314
314
  def get_duration_details(key, context: NO_DEFAULT_PROVIDED)
315
- evaluate_details(key, :duration, context)
315
+ build_details(key, :duration, context)
316
+ end
317
+
318
+ # Untyped details read (matches sdk-go Client.EvaluateDetails). +value+
319
+ # is the resolved value in its stored form, after ENV_VAR provision and
320
+ # decryption, with no type check, coercion or parse validation: a
321
+ # duration is its ISO 8601 text exactly as stored ("PT90S"), a
322
+ # string_list an Array, a json value as stored. +value_type+ is the
323
+ # stored value type ('string', 'int', 'double', 'bool', 'string_list',
324
+ # 'duration', 'json', ...; nil when nothing resolved). Reason, variant,
325
+ # error_code and telemetry are the same as the typed *_details getters,
326
+ # and like them it never raises. Meant for OpenFeature providers and
327
+ # other callers that do their own typing.
328
+ def evaluate_details(key, context: NO_DEFAULT_PROVIDED)
329
+ build_details(key, nil, context)
316
330
  end
317
331
 
318
332
  # True only when the flag resolves to the bool +true+. Never raises
@@ -650,7 +664,8 @@ module Quonfig
650
664
  # Aggregate connection state. Returns one of:
651
665
  #
652
666
  # - +:initializing+ — no envelope has been installed and SSE is not yet
653
- # connected.
667
+ # connected, or (SSE clients) the stream has not delivered its first
668
+ # frame yet, even if the init fetch already installed an envelope.
654
669
  # - +:connected+ — SSE is live, or the SDK is delivering configs from a
655
670
  # loaded envelope (datadir mode or post-initial-fetch with no SSE).
656
671
  # - +:disconnected+ — +stop+ was called, or SSE errored and no fallback
@@ -681,9 +696,14 @@ module Quonfig
681
696
  next :disconnected if sse_channel_expected? && !sse_worker_alive?
682
697
  next :connected if @sse_state == :connected
683
698
  next :disconnected if @sse_state == :error
699
+ # First-frame rule (qfg-fi1s): a client that is supposed to hold an
700
+ # SSE stream is not :connected until that stream delivers its first
701
+ # frame. The init fetch's install alone does not make it connected;
702
+ # "after 200 headers, no frame" is :initializing (contract Test 6).
703
+ next :initializing if sse_channel_expected? && !@sse_ever_connected
684
704
 
685
705
  # No SSE state change yet: state is driven by whether any envelope
686
- # has been installed (datadir / initial fetch).
706
+ # has been installed (datadir / SSE disabled / initial fetch).
687
707
  @last_successful_refresh.nil? ? :initializing : :connected
688
708
  end
689
709
  end
@@ -958,7 +978,7 @@ module Quonfig
958
978
  end
959
979
 
960
980
  # Lazy post-fork re-initialization. Called from every read entry point
961
- # (+get+, +evaluate_details+, +defined?+, +keys+) — the flag read is a
981
+ # (+get+, +evaluate_details+ / the +*_details+ getters, +defined?+, +keys+) — the flag read is a
962
982
  # plain boolean, so the steady-state cost is one comparison per lookup.
963
983
  #
964
984
  # The first caller in the child does what +Client.new+ does: its own
@@ -2185,9 +2205,11 @@ module Quonfig
2185
2205
  end
2186
2206
 
2187
2207
  # Build a Quonfig::EvaluationDetails for +key+, evaluated against the
2188
- # caller's context, after coercing/checking +expected_type+. Never
2189
- # raises; all exceptions become ERROR details.
2190
- def evaluate_details(key, expected_type, context)
2208
+ # caller's context, after coercing/checking +expected_type+. A nil
2209
+ # +expected_type+ is the untyped #evaluate_details read: the stored value
2210
+ # with no type check, plus +value_type+. Never raises; all
2211
+ # exceptions become ERROR details.
2212
+ def build_details(key, expected_type, context)
2191
2213
  ensure_initialized_after_fork
2192
2214
  jit = context == NO_DEFAULT_PROVIDED ? nil : context
2193
2215
  ctx = build_context(jit)
@@ -2234,7 +2256,7 @@ module Quonfig
2234
2256
  config_type = result.config&.dig('type') || result.config&.dig(:type)
2235
2257
 
2236
2258
  begin
2237
- coerced = typed_value(key, result, expected_type)
2259
+ coerced = expected_type.nil? ? stored_value(result) : typed_value(key, result, expected_type)
2238
2260
  rescue Quonfig::Errors::TypeMismatchError, Quonfig::Errors::EnvVarParseError => e
2239
2261
  # A wrong-type value and a malformed one (the getter cannot produce
2240
2262
  # a value of its type) are both TYPE_MISMATCH, as in sdk-go.
@@ -2257,7 +2279,8 @@ module Quonfig
2257
2279
  value: coerced,
2258
2280
  reason: reason,
2259
2281
  variant: build_variant(reason, result.rule_index, result.weighted_value_index),
2260
- flag_metadata: flag_metadata
2282
+ flag_metadata: flag_metadata,
2283
+ value_type: expected_type.nil? ? stored_value_type(result) : nil
2261
2284
  )
2262
2285
  rescue StandardError => e
2263
2286
  Quonfig::EvaluationDetails.new(
@@ -2270,6 +2293,22 @@ module Quonfig
2270
2293
  )
2271
2294
  end
2272
2295
 
2296
+ # +result+'s resolved value in stored form (after ENV_VAR provision and
2297
+ # decryption) with no coercion or parse validation: a duration is its
2298
+ # ISO 8601 text as stored. Hashes and Arrays are copied so a caller
2299
+ # mutating the result cannot rewrite the cached config.
2300
+ def stored_value(result)
2301
+ copy_containers(result.raw_value)
2302
+ end
2303
+
2304
+ def copy_containers(value)
2305
+ case value
2306
+ when Hash then value.to_h { |k, v| [k, copy_containers(v)] }
2307
+ when Array then value.map { |v| copy_containers(v) }
2308
+ else value
2309
+ end
2310
+ end
2311
+
2273
2312
  # Build the variant string per the cross-SDK spec
2274
2313
  # (project/plans/openfeature-resolution-details.md §2).
2275
2314
  def build_variant(reason, rule_index, weighted_value_index)
@@ -19,6 +19,10 @@ module Quonfig
19
19
  # "PROVIDER_NOT_READY" — the client has no config yet (init continued
20
20
  # on code defaults, or a caught init failure)
21
21
  # "GENERAL" — any other failure
22
+ #
23
+ # +value_type+ is set only by Client#evaluate_details: the stored value
24
+ # type ("string", "int", "double", "bool", "string_list", "duration",
25
+ # "json", ...), or nil when nothing resolved. The typed getters leave it nil.
22
26
  class EvaluationDetails
23
27
  REASON_STATIC = 'STATIC'
24
28
  REASON_TARGETING_MATCH = 'TARGETING_MATCH'
@@ -31,16 +35,17 @@ module Quonfig
31
35
  ERROR_GENERAL = 'GENERAL'
32
36
  ERROR_PROVIDER_NOT_READY = 'PROVIDER_NOT_READY'
33
37
 
34
- attr_reader :value, :reason, :error_code, :error_message, :variant, :flag_metadata
38
+ attr_reader :value, :reason, :error_code, :error_message, :variant, :flag_metadata, :value_type
35
39
 
36
40
  def initialize(value:, reason:, error_code: nil, error_message: nil,
37
- variant: nil, flag_metadata: nil)
41
+ variant: nil, flag_metadata: nil, value_type: nil)
38
42
  @value = value
39
43
  @reason = reason
40
44
  @error_code = error_code
41
45
  @error_message = error_message
42
46
  @variant = variant
43
47
  @flag_metadata = flag_metadata
48
+ @value_type = value_type
44
49
  end
45
50
 
46
51
  def ==(other)
@@ -50,12 +55,13 @@ module Quonfig
50
55
  other.error_code == @error_code &&
51
56
  other.error_message == @error_message &&
52
57
  other.variant == @variant &&
53
- other.flag_metadata == @flag_metadata
58
+ other.flag_metadata == @flag_metadata &&
59
+ other.value_type == @value_type
54
60
  end
55
61
  alias eql? ==
56
62
 
57
63
  def hash
58
- [@value, @reason, @error_code, @error_message, @variant, @flag_metadata].hash
64
+ [@value, @reason, @error_code, @error_message, @variant, @flag_metadata, @value_type].hash
59
65
  end
60
66
 
61
67
  def inspect
@@ -64,6 +70,7 @@ module Quonfig
64
70
  parts << "error_message=#{@error_message.inspect}" if @error_message
65
71
  parts << "variant=#{@variant.inspect}" if @variant
66
72
  parts << "flag_metadata=#{@flag_metadata.inspect}" if @flag_metadata
73
+ parts << "value_type=#{@value_type.inspect}" if @value_type
67
74
  "#<Quonfig::EvaluationDetails #{parts.join(' ')}>"
68
75
  end
69
76
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '2.0.0'
4
+ VERSION = '2.1.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-27 00:00:00.000000000 Z
11
+ date: 2026-10-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport