smplkit 3.0.96 → 3.0.97

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.
@@ -5,7 +5,7 @@
5
5
  # Smpl Config has two surfaces on a single client, mirroring how the audit and
6
6
  # jobs clients expose their full surface from one class:
7
7
  #
8
- # * *Management surface* — pure CRUD, no live connection: +new+ / +get+ /
8
+ # * *CRUD surface* — pure CRUD, no live connection: +new+ / +get+ /
9
9
  # +list+ / +delete+ and the discovery buffer (+register_config+ /
10
10
  # +register_config_item+ / +flush+ / +pending_count+). The client owns the
11
11
  # discovery buffer directly.
@@ -29,6 +29,8 @@ module Smplkit
29
29
  module Config
30
30
  # Module-level helpers for the config client. Extracted so they can be
31
31
  # unit-tested without spinning up the full client.
32
+ #
33
+ # @api private
32
34
  module Discovery
33
35
  module_function
34
36
 
@@ -36,6 +38,10 @@ module Smplkit
36
38
  # Hash/Struct target and when supplying a default to +get_value(id, key,
37
39
  # default)+. +true+/+false+ are checked first because Ruby's
38
40
  # +Numeric+/+Integer+ tests would not accidentally claim them.
41
+ #
42
+ # @api private
43
+ # @param value [Object] The runtime value whose item type to infer.
44
+ # @return [String] One of +"BOOLEAN"+, +"NUMBER"+, or +"STRING"+.
39
45
  def value_to_item_type(value)
40
46
  case value
41
47
  when true, false then "BOOLEAN"
@@ -47,6 +53,12 @@ module Smplkit
47
53
  # Walk a bound target, returning +[key, type, value, description]+ tuples
48
54
  # flattened to dot-notation. Nested Hashes / Structs are descended into;
49
55
  # everything else is treated as an opaque leaf.
56
+ #
57
+ # @api private
58
+ # @param target [Hash, Struct] The bound target to walk.
59
+ # @param prefix [String] Dot-notation prefix accumulated during recursion.
60
+ # @return [Array<Array(String, String, Object, String, nil)>] One
61
+ # +[key, type, value, description]+ tuple per leaf.
50
62
  def iter_items(target, prefix: "")
51
63
  if target.is_a?(Hash)
52
64
  iter_hash_items(target, prefix: prefix)
@@ -57,6 +69,14 @@ module Smplkit
57
69
  end
58
70
  end
59
71
 
72
+ # Walk a Hash leaf-by-leaf, flattening nested Hashes / Structs to
73
+ # dot-notation.
74
+ #
75
+ # @api private
76
+ # @param hash [Hash] The Hash to walk.
77
+ # @param prefix [String] Dot-notation prefix accumulated during recursion.
78
+ # @return [Array<Array(String, String, Object, String, nil)>] One
79
+ # +[key, type, value, description]+ tuple per leaf.
60
80
  def iter_hash_items(hash, prefix: "")
61
81
  out = []
62
82
  hash.each do |raw_key, value|
@@ -70,6 +90,14 @@ module Smplkit
70
90
  out
71
91
  end
72
92
 
93
+ # Walk a Struct member-by-member, flattening nested Hashes / Structs to
94
+ # dot-notation.
95
+ #
96
+ # @api private
97
+ # @param struct [Struct] The Struct to walk.
98
+ # @param prefix [String] Dot-notation prefix accumulated during recursion.
99
+ # @return [Array<Array(String, String, Object, String, nil)>] One
100
+ # +[key, type, value, description]+ tuple per leaf.
73
101
  def iter_struct_items(struct, prefix: "")
74
102
  out = []
75
103
  struct.members.each do |member|
@@ -89,6 +117,12 @@ module Smplkit
89
117
  # +Hash#[]=+ or +Struct#[]=+. Bails silently if any intermediate is
90
118
  # missing or not a supported container — the server may have items that
91
119
  # don't line up with what the bound target declared.
120
+ #
121
+ # @api private
122
+ # @param target [Hash, Struct] The bound target to mutate in place.
123
+ # @param dotted_key [String] The dot-notation path to the leaf to set.
124
+ # @param value [Object] The value to assign at the leaf.
125
+ # @return [void]
92
126
  def apply_change_to_target(target, dotted_key, value)
93
127
  parts = dotted_key.split(".")
94
128
  current = walk_to_leaf_parent(target, parts[0..-2])
@@ -102,6 +136,14 @@ module Smplkit
102
136
  end
103
137
  end
104
138
 
139
+ # Walk a dotted key path to the leaf's parent container.
140
+ #
141
+ # @api private
142
+ # @param target [Hash, Struct] The root container to walk from.
143
+ # @param parts [Array<String>] Path segments leading to (but excluding)
144
+ # the leaf.
145
+ # @return [Hash, Struct, nil] The leaf's parent container, or +nil+ when
146
+ # any intermediate is missing or not a supported container.
105
147
  def walk_to_leaf_parent(target, parts)
106
148
  current = target
107
149
  parts.each do |part|
@@ -122,6 +164,14 @@ module Smplkit
122
164
  current
123
165
  end
124
166
 
167
+ # Assign a value to a Struct member, ignoring members the Struct does not
168
+ # declare.
169
+ #
170
+ # @api private
171
+ # @param struct [Struct] The Struct to mutate.
172
+ # @param name [String] The member name to assign.
173
+ # @param value [Object] The value to assign.
174
+ # @return [void]
125
175
  def assign_struct_member(struct, name, value)
126
176
  sym = name.to_sym
127
177
  return unless struct.members.include?(sym)
@@ -179,27 +229,60 @@ module Smplkit
179
229
 
180
230
  attr_reader :config_id
181
231
 
232
+ # @return [Array<String>] The current resolved item keys.
182
233
  def keys = current_values.keys
234
+
235
+ # @return [Array<Object>] The current resolved values.
183
236
  def values = current_values.values
237
+
238
+ # @yieldparam key [String] Each resolved item key.
239
+ # @yieldparam value [Object] Each resolved value.
240
+ # @return [Enumerator, void] An enumerator when no block is given.
184
241
  def each_pair(&) = current_values.each_pair(&)
185
242
  alias each each_pair
243
+
244
+ # @return [Array<Array(String, Object)>] The current resolved items as
245
+ # +[key, value]+ pairs.
186
246
  def items = current_values.to_a
247
+
248
+ # @return [Hash{String => Object}] A copy of the current resolved values.
187
249
  def to_h = current_values.dup
250
+
251
+ # @return [Integer] The number of resolved items.
188
252
  def size = current_values.size
189
253
  alias length size
254
+
255
+ # @param key [String, Symbol] The item key to test for.
256
+ # @return [Boolean] +true+ when +key+ is present in the resolved values.
190
257
  def key?(key) = current_values.key?(key.to_s)
191
258
  alias include? key?
192
259
  alias has_key? key?
193
260
 
261
+ # @param key [String, Symbol] The item key to read.
262
+ # @return [Object, nil] The current resolved value for +key+, or +nil+
263
+ # when absent.
194
264
  def [](key)
195
265
  current_values[key.to_s]
196
266
  end
197
267
 
268
+ # Return the current resolved value for +key+, or a fallback.
269
+ #
270
+ # @param key [String, Symbol] The config item key to read.
271
+ # @param default [Object] Value returned when +key+ is not present.
272
+ # @return [Object] The current resolved value for +key+, or +default+ if
273
+ # the key is absent.
198
274
  def get(key, default = nil)
199
275
  values = current_values
200
276
  values.key?(key.to_s) ? values[key.to_s] : default
201
277
  end
202
278
 
279
+ # Register a change listener scoped to this config.
280
+ #
281
+ # @param item_key [String, Symbol, nil] When given, fires only when this
282
+ # item key changes; otherwise fires on any change to this config.
283
+ # @yieldparam event [ConfigChangeEvent] The change that fired the
284
+ # listener.
285
+ # @return [Proc] The registered block, unchanged.
203
286
  def on_change(item_key = nil, &)
204
287
  if item_key.nil?
205
288
  @client.on_change(@config_id, &)
@@ -233,6 +316,13 @@ module Smplkit
233
316
  end
234
317
 
235
318
  # Normalize a +parent+ argument to a config id string.
319
+ #
320
+ # @api private
321
+ # @param parent [String, Config, nil] A config id, a saved +Config+ whose
322
+ # id is used, or +nil+.
323
+ # @return [String, nil] The resolved config id, or +nil+ when +parent+ is
324
+ # +nil+.
325
+ # @raise [ArgumentError] If +parent+ is an unsaved +Config+ (no id yet).
236
326
  def self.resolve_parent_id(parent)
237
327
  return parent if parent.nil? || parent.is_a?(String)
238
328
  if parent.id.nil? || parent.id == ""
@@ -245,19 +335,32 @@ module Smplkit
245
335
  # Build a standalone config transport and resolve the app base URL.
246
336
  #
247
337
  # +base_url+/+api_key+ are used directly when supplied (the path a top-level
248
- # client takes after it has already resolved them); otherwise the
249
- # management config resolver fills in whatever is missing (+~/.smplkit+ /
250
- # env vars / defaults). The app base URL is returned alongside so a
251
- # standalone client can open its own WebSocket against the event gateway.
338
+ # client takes after it has already resolved them); otherwise the config
339
+ # resolver fills in whatever is missing (+~/.smplkit+ / env vars /
340
+ # defaults). The app base URL is returned alongside so a standalone client
341
+ # can open its own WebSocket against the event gateway.
342
+ #
343
+ # @api private
344
+ # @param api_key [String, nil] API key, or +nil+ to resolve it.
345
+ # @param base_url [String, nil] Full config-service base URL, or +nil+ to
346
+ # resolve it from +base_domain+/+scheme+.
347
+ # @param profile [String, nil] Named +~/.smplkit+ profile section.
348
+ # @param base_domain [String, nil] Base domain for API requests.
349
+ # @param scheme [String, nil] URL scheme.
350
+ # @param debug [Boolean, nil] Enable SDK debug logging.
351
+ # @param extra_headers [Hash{String => String}, nil] Headers attached to
352
+ # every request.
353
+ # @return [Array(Object, String, String)] The transport, the app base URL,
354
+ # and the resolved API key.
252
355
  def self.config_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
253
- cfg = ConfigResolution.resolve_management_config(
356
+ cfg = ConfigResolution.resolve_client_config(
254
357
  profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
255
358
  )
256
359
  resolved_key = api_key.nil? ? cfg.api_key : api_key
257
360
  merged = {}
258
361
  merged.merge!(cfg.extra_headers || {})
259
362
  merged.merge!(extra_headers || {})
260
- tcfg = ConfigResolution::ResolvedManagementConfig.new(
363
+ tcfg = ConfigResolution::ResolvedClientConfig.new(
261
364
  api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
262
365
  debug: cfg.debug, extra_headers: merged
263
366
  )
@@ -278,7 +381,7 @@ module Smplkit
278
381
  # proxy = config.subscribe("billing")
279
382
  # puts proxy["max_seats"]
280
383
  #
281
- # The management surface (+new+ / +get+ / +list+ / +delete+ and discovery)
384
+ # The CRUD surface (+new+ / +get+ / +list+ / +delete+ and discovery)
282
385
  # is pure CRUD. The live surface (+subscribe+ / +get_value+ / +bind+ /
283
386
  # +on_change+ / +refresh+) connects lazily on first use — the first call
284
387
  # flushes discovery, fetches and resolves all configs into the local cache,
@@ -290,6 +393,26 @@ module Smplkit
290
393
  MISSING = Object.new.freeze
291
394
  private_constant :MISSING
292
395
 
396
+ # @param api_key [String, nil] API key. When omitted, resolved from
397
+ # +SMPLKIT_API_KEY+ or +~/.smplkit+.
398
+ # @param environment [String, nil] Deployment environment used to resolve
399
+ # runtime config values and to scope discovery declarations.
400
+ # @param base_url [String, nil] Full config-service base URL. Usually
401
+ # resolved from +base_domain+/+scheme+; supplied directly by the
402
+ # top-level clients which have already computed it.
403
+ # @param profile [String, nil] Named +~/.smplkit+ profile section.
404
+ # @param base_domain [String, nil] Base domain for API requests (default
405
+ # +"smplkit.com"+).
406
+ # @param scheme [String, nil] URL scheme (default +"https"+).
407
+ # @param debug [Boolean, nil] Enable SDK debug logging.
408
+ # @param extra_headers [Hash{String => String}, nil] Extra headers
409
+ # attached to every request.
410
+ # @param parent [Smplkit::Client, nil] Internal — the owning client. Not
411
+ # for direct use.
412
+ # @param transport [Object, nil] Internal — a pre-built config transport
413
+ # supplied by a top-level client so the config surface shares one
414
+ # connection pool. Not for direct use.
415
+ # @param metrics [Object, nil] Internal — the parent's metrics reporter.
293
416
  def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
294
417
  base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
295
418
  parent: nil, transport: nil, metrics: nil)
@@ -338,6 +461,16 @@ module Smplkit
338
461
  # +parent+ accepts either a config id (string) or an existing +Config+
339
462
  # instance — passing the instance lets you skip naming the id explicitly
340
463
  # when you already have the parent in scope.
464
+ #
465
+ # @param id [String] The config identifier (slug) the resource will be
466
+ # saved under.
467
+ # @param name [String, nil] Display name. Defaults to a title-cased form
468
+ # of +id+.
469
+ # @param description [String, nil] Optional human-readable description.
470
+ # @param parent [String, Config, nil] Optional parent config to inherit
471
+ # values from, as a config id or an existing +Config+ instance.
472
+ # @return [Config] A new, unsaved +Config+. Nothing is sent to the server
473
+ # until you call +Config#save+.
341
474
  def new(id, name: nil, description: nil, parent: nil)
342
475
  Config.new(
343
476
  self,
@@ -350,13 +483,22 @@ module Smplkit
350
483
 
351
484
  # Fetch the editable +Config+ resource by id.
352
485
  #
353
- # Raises +NotFoundError+ if no config with that id exists.
486
+ # @param id [String] The config identifier (slug) to fetch.
487
+ # @return [Config] The editable +Config+ resource.
488
+ # @raise [Smplkit::NotFoundError] If no config with that id exists.
354
489
  def get(id)
355
490
  response = ApiSupport::ErrorMapping.call { @api.get_config(id) }
356
491
  Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
357
492
  end
358
493
 
359
494
  # List configs for the authenticated account.
495
+ #
496
+ # @param page_number [Integer, nil] 1-based page to fetch. When omitted,
497
+ # the server's default first page is returned.
498
+ # @param page_size [Integer, nil] Number of configs per page. When
499
+ # omitted, the server's default page size is used.
500
+ # @return [Array<Config>] The configs on the requested page, or an empty
501
+ # array if there are none.
360
502
  def list(page_number: nil, page_size: nil)
361
503
  opts = {}
362
504
  opts[:page_number] = page_number unless page_number.nil?
@@ -366,6 +508,9 @@ module Smplkit
366
508
  end
367
509
 
368
510
  # Delete a config by id.
511
+ #
512
+ # @param id [String] The config identifier (slug) to delete.
513
+ # @return [void]
369
514
  def delete(id)
370
515
  ApiSupport::ErrorMapping.call { @api.delete_config(id) }
371
516
  nil
@@ -386,6 +531,20 @@ module Smplkit
386
531
  # ----------------------------------------------------------------
387
532
 
388
533
  # Queue a configuration declaration for bulk-discovery upload.
534
+ #
535
+ # The declaration is buffered and sent in the background; it surfaces the
536
+ # config in the smplkit console even if no values are set yet.
537
+ #
538
+ # @param config_id [String] The config identifier (slug) being declared.
539
+ # @param service [String, nil] Name of the service declaring the config,
540
+ # or +nil+.
541
+ # @param environment [String, nil] Environment the declaration is scoped
542
+ # to, or +nil+.
543
+ # @param parent [String, nil] Optional parent config id this config
544
+ # inherits from.
545
+ # @param name [String, nil] Optional display name for the config.
546
+ # @param description [String, nil] Optional human-readable description.
547
+ # @return [void]
389
548
  def register_config(config_id, service:, environment:, parent: nil, name: nil, description: nil)
390
549
  @buffer.declare(config_id, service: service, environment: environment,
391
550
  parent: parent, name: name, description: description)
@@ -393,17 +552,30 @@ module Smplkit
393
552
  end
394
553
 
395
554
  # Queue a config item declaration. +register_config+ must run first.
555
+ #
556
+ # The declaration is buffered and sent in the background, surfacing the
557
+ # item (with its type and default) in the smplkit console.
558
+ #
559
+ # @param config_id [String] The config identifier (slug) the item belongs
560
+ # to.
561
+ # @param item_key [String] Key of the item within the config.
562
+ # @param item_type [String] Item value type — one of +"STRING"+,
563
+ # +"NUMBER"+, +"BOOLEAN"+, or +"JSON"+.
564
+ # @param default [Object] The in-code default value for the item.
565
+ # @param description [String, nil] Optional human-readable description.
566
+ # @return [void]
396
567
  def register_config_item(config_id, item_key, item_type, default, description = nil)
397
568
  @buffer.add_item(config_id, item_key, item_type, default, description)
398
569
  trigger_background_flush_if_needed
399
570
  end
400
571
 
401
- # POST pending declarations to +/api/v1/configs/bulk+.
572
+ # Send any queued config and item declarations to the server.
573
+ #
574
+ # Discovery is best-effort — failures here never propagate to your code.
575
+ # Drained entries are not requeued; the SDK re-observes them on the next
576
+ # process start.
402
577
  #
403
- # Per ADR-024 §2.9, bulk registration always lands rows as
404
- # +managed=false+ and is plan-limit-exempt — failures here never
405
- # propagate to customer code. Drained entries are not requeued; the SDK
406
- # will re-observe on the next process start.
578
+ # @return [void]
407
579
  def flush
408
580
  batch = @buffer.drain
409
581
  return if batch.empty?
@@ -412,12 +584,14 @@ module Smplkit
412
584
  begin
413
585
  ApiSupport::ErrorMapping.call { @api.bulk_register_configs(body) }
414
586
  rescue StandardError => e
415
- # Fire-and-forget per ADR-024 §2.9.
587
+ # Fire-and-forget discovery failures never propagate to caller code.
416
588
  Smplkit.debug("registration", "config bulk register failed: #{e.class}: #{e.message}")
417
589
  end
418
590
  end
419
591
 
420
592
  # Number of pending config declarations awaiting flush.
593
+ #
594
+ # @return [Integer] The count of buffered declarations not yet flushed.
421
595
  def pending_count
422
596
  @buffer.pending_count
423
597
  end
@@ -452,6 +626,17 @@ module Smplkit
452
626
  # originally-bound object; the new +config+ argument is ignored.
453
627
  #
454
628
  # Connects lazily on first use — no explicit install step.
629
+ #
630
+ # @param id [String] The config id to register under.
631
+ # @param config [Hash, Struct] A populated Hash or Struct. Both supply
632
+ # the schema (via the keys or Struct members) and the in-code defaults.
633
+ # @param parent [Hash, Struct, nil] Optional parent — any object
634
+ # previously returned from a +#bind+ call. Activates parent-chain
635
+ # inheritance for keys the caller omitted.
636
+ # @return [Hash, Struct] The same +config+ object, registered and live.
637
+ # @raise [TypeError] If +config+ is neither a Hash nor a Struct.
638
+ # @raise [ArgumentError] If +parent+ is provided but was not previously
639
+ # bound via +#bind+.
455
640
  def bind(id, config, parent: nil)
456
641
  ensure_connected
457
642
  unless config.is_a?(Hash) || config.is_a?(Struct)
@@ -476,8 +661,12 @@ module Smplkit
476
661
  # registers the config declaration for code-first observability so the
477
662
  # reference appears in the smplkit console.
478
663
  #
479
- # Connects lazily on first use — no explicit install step. Raises
480
- # +NotFoundError+ if the config is unknown.
664
+ # Connects lazily on first use — no explicit install step.
665
+ #
666
+ # @param id [String] The config identifier (slug) to subscribe to.
667
+ # @return [LiveConfigProxy] A live proxy whose reads always see the
668
+ # current resolved values.
669
+ # @raise [Smplkit::NotFoundError] If the config is unknown.
481
670
  def subscribe(id)
482
671
  ensure_connected
483
672
  observe_config_declaration(id, parent: nil, name: nil, description: nil)
@@ -506,6 +695,19 @@ module Smplkit
506
695
  # For a live dict-like view use +#subscribe+; for typed access via a
507
696
  # Struct schema use +#bind+. Connects lazily on first use — no explicit
508
697
  # install step.
698
+ #
699
+ # @param id [String] The config identifier (slug) to read from.
700
+ # @param key [String] The item key within the config.
701
+ # @param default [Object] Value returned when the config or key is
702
+ # missing. When omitted, a missing config or key raises instead of
703
+ # returning a fallback. Supplying a default also registers the config
704
+ # (if new) and the key — with its type inferred and +default+ as its
705
+ # value — so the reference appears in the smplkit console.
706
+ # @return [Object] The resolved value. When +default+ is supplied and the
707
+ # config or key is missing, returns +default+ instead.
708
+ # @raise [Smplkit::NotFoundError] If the config is unknown and no
709
+ # +default+ was supplied.
710
+ # @raise [KeyError] If the key is absent and no +default+ was supplied.
509
711
  def get_value(id, key, default = MISSING)
510
712
  ensure_connected
511
713
  key = key.to_s
@@ -541,6 +743,14 @@ module Smplkit
541
743
  # client.config.on_change("id", item_key: "key") { |event| ... } # item-scoped
542
744
  #
543
745
  # Connects lazily on first use — no explicit install step.
746
+ #
747
+ # @param config_id [String, nil] When given, restrict the listener to
748
+ # changes of this config. Omit for a global listener.
749
+ # @param item_key [String, nil] When +config_id+ is given, restrict the
750
+ # listener to changes of this single item key.
751
+ # @yieldparam event [ConfigChangeEvent] The change that fired the
752
+ # listener.
753
+ # @return [Proc] The registered block, unchanged.
544
754
  def on_change(config_id = nil, item_key: nil, &block)
545
755
  ensure_connected
546
756
  raise ArgumentError, "on_change requires a block" unless block
@@ -553,6 +763,9 @@ module Smplkit
553
763
  # listeners for anything that differs from the previous state.
554
764
  #
555
765
  # Connects lazily on first use — no explicit install step.
766
+ #
767
+ # @return [void]
768
+ # @raise [Smplkit::ConnectionError] If the fetch fails.
556
769
  def refresh
557
770
  ensure_connected
558
771
  do_refresh("manual")
@@ -564,6 +777,8 @@ module Smplkit
564
777
  # live use) and the owned HTTP transport (standalone construction). A
565
778
  # wired client borrows the parent's transport and WebSocket and closes
566
779
  # neither.
780
+ #
781
+ # @return [void]
567
782
  def close
568
783
  if @owns_ws && @ws_manager
569
784
  @ws_manager.stop
@@ -575,6 +790,11 @@ module Smplkit
575
790
  alias _close close
576
791
 
577
792
  # Construct, yield to the block, and close on exit.
793
+ #
794
+ # @param kwargs [Hash] Keyword arguments forwarded to +#initialize+.
795
+ # @yieldparam client [ConfigClient] The constructed client, closed when
796
+ # the block returns.
797
+ # @return [Object] The value returned by the block.
578
798
  def self.open(**kwargs)
579
799
  client = new(**kwargs)
580
800
  begin
@@ -888,21 +1108,53 @@ module Smplkit
888
1108
  fire_change_listeners(old_cache, new_cache, source: source)
889
1109
  end
890
1110
 
1111
+ # Pull any referenced-but-uncached parent (and ancestors) into +store+.
1112
+ #
1113
+ # A config_changed event fetches only the changed config. If that config
1114
+ # inherits from a parent that isn't already in the store — e.g. a parent
1115
+ # created via discovery after the initial connect that never broadcast
1116
+ # its own event — the chain walk in +rebuild_from_store+ would stop at
1117
+ # the gap and the child would re-resolve missing its inherited values.
1118
+ # Walk every config's parent pointers and fetch each absent ancestor so
1119
+ # the inheritance chain resolves fully.
1120
+ def ensure_ancestors_cached(store)
1121
+ pending = store.values.filter_map { |cfg| parent_pointer(cfg) }
1122
+ until pending.empty?
1123
+ parent_id = pending.pop
1124
+ next if store.key?(parent_id)
1125
+
1126
+ parent = fetch_config(parent_id)
1127
+ next if parent.nil?
1128
+
1129
+ store[parent_id] = parent
1130
+ grandparent = parent_pointer(parent)
1131
+ pending << grandparent unless grandparent.nil?
1132
+ end
1133
+ end
1134
+
1135
+ # The parent config id a +Config+ points at, or +nil+ for a root —
1136
+ # treating an empty-string parent the same as none, matching the
1137
+ # break condition in +Helpers.build_chain+.
1138
+ def parent_pointer(config)
1139
+ pid = config.parent_id
1140
+ pid unless pid.nil? || pid == ""
1141
+ end
1142
+
891
1143
  def handle_config_changed(data)
892
1144
  key = data["id"] || data["key"]
893
1145
  return handle_configs_changed(data) unless key
894
1146
 
895
- new_store = @lock.synchronize { @raw_config_store.dup }
896
1147
  begin
1148
+ new_store = @lock.synchronize { @raw_config_store.dup }
897
1149
  cfg = fetch_config(key)
1150
+ return if cfg.nil?
1151
+
1152
+ new_store[key] = cfg
1153
+ ensure_ancestors_cached(new_store)
1154
+ rebuild_from_store(new_store, source: "websocket")
898
1155
  rescue StandardError => e
899
- Smplkit.debug("config", "failed to fetch config #{key.inspect} after WS event: #{e.class}: #{e.message}")
900
- return
1156
+ Smplkit.debug("config", "config_changed handler failed for #{key.inspect}: #{e.class}: #{e.message}")
901
1157
  end
902
- return if cfg.nil?
903
-
904
- new_store[key] = cfg
905
- rebuild_from_store(new_store, source: "websocket")
906
1158
  end
907
1159
 
908
1160
  def handle_config_deleted(data)
@@ -954,8 +1206,8 @@ module Smplkit
954
1206
  def config_envs_to_wire(environments)
955
1207
  return nil if environments.empty?
956
1208
 
957
- # Per ADR-024 §2.4 the wire shape for env overrides is a flat
958
- # +{env: {key: rawValue}}+ map — no envelope, no per-key type wrapper.
1209
+ # The wire shape for env overrides is a flat +{env: {key: rawValue}}+
1210
+ # map — no envelope, no per-key type wrapper.
959
1211
  environments.each_with_object({}) do |(env_key, env_obj), out|
960
1212
  out[env_key] = env_obj.values
961
1213
  end
@@ -991,4 +1243,5 @@ module Smplkit
991
1243
  end
992
1244
 
993
1245
  ConfigClient = Config::ConfigClient
1246
+ ConfigChangeEvent = Config::ConfigChangeEvent
994
1247
  end
@@ -2,10 +2,19 @@
2
2
 
3
3
  module Smplkit
4
4
  module Config
5
+ # Internal conversion and resolution helpers shared by the config client.
6
+ #
7
+ # @api private
5
8
  module Helpers
6
9
  module_function
7
10
 
8
11
  # Translate a JSON:API resource Hash into a Config domain model.
12
+ #
13
+ # @api private
14
+ # @param client [ConfigClient, nil] The owning client, or +nil+ for a
15
+ # detached model.
16
+ # @param resource [Hash{String => Object}] The JSON:API resource Hash.
17
+ # @return [Config] The constructed config domain model.
9
18
  def config_from_json(client, resource)
10
19
  attrs = resource["attributes"] || {}
11
20
  items = (attrs["items"] || {}).map do |name, item|
@@ -22,8 +31,8 @@ module Smplkit
22
31
  end
23
32
 
24
33
  environments = (attrs["environments"] || {}).each_with_object({}) do |(env, env_data), out|
25
- # Per ADR-024 §2.4 env_data is already the flat override map
26
- # +{key: rawValue}+ — the old +{values: {...}}+ envelope is gone.
34
+ # env_data is already the flat override map +{key: rawValue}+ — the
35
+ # old +{values: {...}}+ envelope is gone.
27
36
  env_values = env_data.is_a?(Hash) ? env_data : {}
28
37
  out[env] = ConfigEnvironment.new(values: env_values)
29
38
  end
@@ -44,6 +53,12 @@ module Smplkit
44
53
 
45
54
  # Deep-merge two Hashes, with +override+ winning. Mirrors the Python
46
55
  # +deep_merge+ helper used by the resolver.
56
+ #
57
+ # @api private
58
+ # @param base [Hash{String => Object}] The base Hash.
59
+ # @param override [Hash{String => Object}] The Hash whose values win on
60
+ # conflict.
61
+ # @return [Hash{String => Object}] A new merged Hash.
47
62
  def deep_merge(base, override)
48
63
  result = base.dup
49
64
  override.each do |key, value|
@@ -57,6 +72,10 @@ module Smplkit
57
72
  end
58
73
 
59
74
  # Unwrap typed items +{ key => { value, type, desc } }+ to +{ key => raw }+.
75
+ #
76
+ # @api private
77
+ # @param items [Hash{String => Object}] Typed items keyed by item key.
78
+ # @return [Hash{String => Object}] The items as +{ key => raw_value }+.
60
79
  def unwrap_items(items)
61
80
  items.each_with_object({}) do |(k, v), out|
62
81
  out[k] = v.is_a?(Hash) && v.key?("value") ? v["value"] : v
@@ -66,6 +85,13 @@ module Smplkit
66
85
  # Build the parent chain (child-first, root-last) for a +Config+,
67
86
  # walking +parent_id+ pointers across the +by_id+ map. Mirrors the
68
87
  # Python SDK's client-side chain construction.
88
+ #
89
+ # @api private
90
+ # @param target [Config] The config to build the chain for.
91
+ # @param by_id [Hash{String => Config}] Pre-fetched configs keyed by id,
92
+ # used to look up parents without extra network calls.
93
+ # @return [Array<Hash{String => Object}>] Chain entries from child to
94
+ # root.
69
95
  def build_chain(target, by_id)
70
96
  chain = []
71
97
  current = target
@@ -84,14 +110,19 @@ module Smplkit
84
110
 
85
111
  # Build a single chain entry (the +id+/+items+/+environments+ Hash
86
112
  # shape used by +resolve_chain+) from a +Config+ domain model.
113
+ #
114
+ # @api private
115
+ # @param config [Config] The config to convert.
116
+ # @return [Hash{String => Object}] An +id+/+items+/+environments+ chain
117
+ # entry.
87
118
  def config_to_chain_entry(config)
88
119
  items_hash = config.items.to_h do |item|
89
120
  [item.name,
90
121
  { "value" => item.value, "type" => item.type, "description" => item.description }.compact]
91
122
  end
92
123
  environments = config.environments.each_with_object({}) do |(env_key, env_obj), out|
93
- # Per ADR-024 §2.4 env entries are flat +{key: rawValue}+ maps —
94
- # no +values+ envelope, no per-key type wrapper.
124
+ # Env entries are flat +{key: rawValue}+ maps — no +values+
125
+ # envelope, no per-key type wrapper.
95
126
  out[env_key] = env_obj.values
96
127
  end
97
128
  { "id" => config.id, "items" => items_hash, "environments" => environments }
@@ -101,13 +132,20 @@ module Smplkit
101
132
  #
102
133
  # Walks from root (last element) to child (first element), accumulating
103
134
  # values via deep merge so child configs override parent configs.
135
+ #
136
+ # @api private
137
+ # @param chain [Array<Hash{String => Object}>] Chain entries from child
138
+ # to root.
139
+ # @param environment [String, nil] The environment whose overrides to
140
+ # apply, or +nil+ for base values only.
141
+ # @return [Hash{String => Object}] The resolved +{key => value}+ map.
104
142
  def resolve_chain(chain, environment)
105
143
  accumulated = {}
106
144
  chain.reverse_each do |config_data|
107
145
  raw_items = config_data["items"] || config_data["values"] || {}
108
146
  base_values = unwrap_items(raw_items)
109
- # Per ADR-024 §2.4 env entries are flat +{key: rawValue}+ maps —
110
- # the resolver reads the env entry directly as the override map.
147
+ # Env entries are flat +{key: rawValue}+ maps — the resolver reads
148
+ # the env entry directly as the override map.
111
149
  env_data = (config_data["environments"] || {})[environment] || {}
112
150
  env_values = env_data.is_a?(Hash) ? env_data : {}
113
151
  config_resolved = deep_merge(base_values, env_values)