smplkit 3.0.95 → 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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/smplkit/account/client.rb +128 -0
  3. data/lib/smplkit/account/models.rb +71 -0
  4. data/lib/smplkit/api_support.rb +91 -0
  5. data/lib/smplkit/audit/buffer.rb +3 -1
  6. data/lib/smplkit/audit/categories.rb +21 -10
  7. data/lib/smplkit/audit/client.rb +18 -9
  8. data/lib/smplkit/audit/event_types.rb +26 -10
  9. data/lib/smplkit/audit/events.rb +93 -17
  10. data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +93 -85
  11. data/lib/smplkit/audit/models.rb +86 -32
  12. data/lib/smplkit/audit/resource_types.rb +21 -9
  13. data/lib/smplkit/buffers.rb +250 -0
  14. data/lib/smplkit/client.rb +161 -70
  15. data/lib/smplkit/config/client.rb +874 -186
  16. data/lib/smplkit/config/helpers.rb +44 -6
  17. data/lib/smplkit/config/models.rb +114 -7
  18. data/lib/smplkit/config_resolution.rb +17 -9
  19. data/lib/smplkit/errors.rb +14 -3
  20. data/lib/smplkit/flags/client.rb +602 -116
  21. data/lib/smplkit/flags/models.rb +110 -8
  22. data/lib/smplkit/flags/types.rb +8 -9
  23. data/lib/smplkit/jobs/client.rb +306 -0
  24. data/lib/smplkit/jobs/models.rb +47 -18
  25. data/lib/smplkit/logging/client.rb +755 -191
  26. data/lib/smplkit/logging/helpers.rb +5 -1
  27. data/lib/smplkit/logging/levels.rb +3 -1
  28. data/lib/smplkit/logging/models.rb +163 -6
  29. data/lib/smplkit/logging/normalize.rb +3 -1
  30. data/lib/smplkit/logging/resolution.rb +4 -4
  31. data/lib/smplkit/logging/sources.rb +1 -1
  32. data/lib/smplkit/platform/client.rb +597 -0
  33. data/lib/smplkit/platform/models.rb +282 -0
  34. data/lib/smplkit/{management → platform}/types.rb +21 -4
  35. data/lib/smplkit/transport.rb +103 -0
  36. data/lib/smplkit/ws.rb +1 -1
  37. data/lib/smplkit.rb +18 -6
  38. metadata +11 -7
  39. data/lib/smplkit/management/buffer.rb +0 -198
  40. data/lib/smplkit/management/client.rb +0 -1074
  41. data/lib/smplkit/management/jobs.rb +0 -226
  42. data/lib/smplkit/management/models.rb +0 -178
@@ -1,16 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The Smpl Config client — one unified +ConfigClient+.
4
+ #
5
+ # Smpl Config has two surfaces on a single client, mirroring how the audit and
6
+ # jobs clients expose their full surface from one class:
7
+ #
8
+ # * *CRUD surface* — pure CRUD, no live connection: +new+ / +get+ /
9
+ # +list+ / +delete+ and the discovery buffer (+register_config+ /
10
+ # +register_config_item+ / +flush+ / +pending_count+). The client owns the
11
+ # discovery buffer directly.
12
+ # * *Live surface* — lazily connects to your running service on first use:
13
+ # +subscribe+ (a live dict-like +LiveConfigProxy+), +get_value+ (an ad-hoc
14
+ # resolved read), +bind+ (a live Struct/Hash binding), +on_change+, and
15
+ # +refresh+. The first live call transparently flushes discovery, fetches and
16
+ # resolves every config into the local cache, and opens the live-updates
17
+ # WebSocket — no explicit install step.
18
+ #
19
+ # The client supports two construction shapes:
20
+ #
21
+ # * *Wired* into +Smplkit::Client+ — borrows the parent's config transport for
22
+ # both runtime fetch and CRUD and the parent's shared WebSocket for the live
23
+ # channel. This is the common path.
24
+ # * *Standalone* — +ConfigClient.new(api_key: ..., base_url: ..., ...)+ builds
25
+ # and owns its own config transport, and on first live use opens and owns its
26
+ # own WebSocket. +close+ tears down only the owned transport and owned
27
+ # WebSocket.
3
28
  module Smplkit
4
29
  module Config
5
- # Module-level helpers for the runtime config client. Extracted so they
6
- # can be unit-tested without spinning up the full client.
30
+ # Module-level helpers for the config client. Extracted so they can be
31
+ # unit-tested without spinning up the full client.
32
+ #
33
+ # @api private
7
34
  module Discovery
8
35
  module_function
9
36
 
10
37
  # Map a runtime value to a Config item type. Used both when binding a
11
- # Hash/Struct target and when supplying a default to +get(id, key,
38
+ # Hash/Struct target and when supplying a default to +get_value(id, key,
12
39
  # default)+. +true+/+false+ are checked first because Ruby's
13
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"+.
14
45
  def value_to_item_type(value)
15
46
  case value
16
47
  when true, false then "BOOLEAN"
@@ -19,9 +50,15 @@ module Smplkit
19
50
  end
20
51
  end
21
52
 
22
- # Walk a bound target, returning +[key, type, value, description]+
23
- # tuples flattened to dot-notation. Nested Hashes / Structs are
24
- # descended into; everything else is treated as an opaque leaf.
53
+ # Walk a bound target, returning +[key, type, value, description]+ tuples
54
+ # flattened to dot-notation. Nested Hashes / Structs are descended into;
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.
25
62
  def iter_items(target, prefix: "")
26
63
  if target.is_a?(Hash)
27
64
  iter_hash_items(target, prefix: prefix)
@@ -32,6 +69,14 @@ module Smplkit
32
69
  end
33
70
  end
34
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.
35
80
  def iter_hash_items(hash, prefix: "")
36
81
  out = []
37
82
  hash.each do |raw_key, value|
@@ -45,6 +90,14 @@ module Smplkit
45
90
  out
46
91
  end
47
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.
48
101
  def iter_struct_items(struct, prefix: "")
49
102
  out = []
50
103
  struct.members.each do |member|
@@ -62,8 +115,14 @@ module Smplkit
62
115
  # Apply a server-pushed value to a bound target in place. Walks the
63
116
  # dotted key path to the leaf's parent and assigns the value via
64
117
  # +Hash#[]=+ or +Struct#[]=+. Bails silently if any intermediate is
65
- # missing or not a supported container — the server may have items
66
- # that don't line up with what the bound target declared.
118
+ # missing or not a supported container — the server may have items that
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]
67
126
  def apply_change_to_target(target, dotted_key, value)
68
127
  parts = dotted_key.split(".")
69
128
  current = walk_to_leaf_parent(target, parts[0..-2])
@@ -77,6 +136,14 @@ module Smplkit
77
136
  end
78
137
  end
79
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.
80
147
  def walk_to_leaf_parent(target, parts)
81
148
  current = target
82
149
  parts.each do |part|
@@ -97,6 +164,14 @@ module Smplkit
97
164
  current
98
165
  end
99
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]
100
175
  def assign_struct_member(struct, name, value)
101
176
  sym = name.to_sym
102
177
  return unless struct.members.include?(sym)
@@ -132,16 +207,15 @@ module Smplkit
132
207
 
133
208
  # A live, read-only, dict-like view of a config's resolved values.
134
209
  #
135
- # Returned by +ConfigClient#get(id)+ (single-arg form). Always reflects
136
- # the latest server-pushed state — every read sees current values.
210
+ # Returned by +ConfigClient#subscribe+. Always reflects the latest
211
+ # server-pushed state — every read sees current values.
137
212
  #
138
- # Supports +[]+, +key?+, +keys+, +values+, +each_pair+, +to_h+, +size+,
139
- # and method-style attribute access for keys that don't collide with
140
- # built-in method names. Use subscript (+proxy["values"]+) for keys
141
- # that do collide.
213
+ # Supports +[]+, +key?+, +keys+, +values+, +each_pair+, +to_h+, +size+, and
214
+ # method-style attribute access for keys that don't collide with built-in
215
+ # method names. Use subscript (+proxy["values"]+) for keys that do collide.
142
216
  #
143
- # For typed access via a Struct schema, use +ConfigClient#bind+ —
144
- # bound objects stay live on the same cache, with no proxy indirection.
217
+ # For typed access via a Struct schema, use +ConfigClient#bind+ — bound
218
+ # objects stay live on the same cache, with no proxy indirection.
145
219
  class LiveConfigProxy
146
220
  # Methods that live on the proxy itself; never resolved against the
147
221
  # cached values dictionary.
@@ -155,27 +229,60 @@ module Smplkit
155
229
 
156
230
  attr_reader :config_id
157
231
 
232
+ # @return [Array<String>] The current resolved item keys.
158
233
  def keys = current_values.keys
234
+
235
+ # @return [Array<Object>] The current resolved values.
159
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.
160
241
  def each_pair(&) = current_values.each_pair(&)
161
242
  alias each each_pair
243
+
244
+ # @return [Array<Array(String, Object)>] The current resolved items as
245
+ # +[key, value]+ pairs.
162
246
  def items = current_values.to_a
247
+
248
+ # @return [Hash{String => Object}] A copy of the current resolved values.
163
249
  def to_h = current_values.dup
250
+
251
+ # @return [Integer] The number of resolved items.
164
252
  def size = current_values.size
165
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.
166
257
  def key?(key) = current_values.key?(key.to_s)
167
258
  alias include? key?
168
259
  alias has_key? key?
169
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.
170
264
  def [](key)
171
265
  current_values[key.to_s]
172
266
  end
173
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.
174
274
  def get(key, default = nil)
175
275
  values = current_values
176
276
  values.key?(key.to_s) ? values[key.to_s] : default
177
277
  end
178
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.
179
286
  def on_change(item_key = nil, &)
180
287
  if item_key.nil?
181
288
  @client.on_change(@config_id, &)
@@ -208,130 +315,423 @@ module Smplkit
208
315
  end
209
316
  end
210
317
 
211
- # Synchronous runtime client for Smpl Config.
318
+ # Normalize a +parent+ argument to a config id string.
212
319
  #
213
- # Obtained via +Smplkit::Client#config+. Exposes +#bind+ (the
214
- # recommended declarative API), +#get+ (lookup-only escape hatch),
215
- # +#refresh+, and +#on_change+. Management/CRUD lives on
216
- # +Smplkit::Client#manage.config+.
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).
326
+ def self.resolve_parent_id(parent)
327
+ return parent if parent.nil? || parent.is_a?(String)
328
+ if parent.id.nil? || parent.id == ""
329
+ raise ArgumentError, "parent config must be saved (have an id) before being used as a parent"
330
+ end
331
+
332
+ parent.id
333
+ end
334
+
335
+ # Build a standalone config transport and resolve the app base URL.
336
+ #
337
+ # +base_url+/+api_key+ are used directly when supplied (the path a top-level
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.
355
+ def self.config_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
356
+ cfg = ConfigResolution.resolve_client_config(
357
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
358
+ )
359
+ resolved_key = api_key.nil? ? cfg.api_key : api_key
360
+ merged = {}
361
+ merged.merge!(cfg.extra_headers || {})
362
+ merged.merge!(extra_headers || {})
363
+ tcfg = ConfigResolution::ResolvedClientConfig.new(
364
+ api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
365
+ debug: cfg.debug, extra_headers: merged
366
+ )
367
+ app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
368
+ transport = Transport.build_api_client(SmplkitGeneratedClient::Config, "config", tcfg, base_url: base_url)
369
+ [transport, app_url, resolved_key]
370
+ end
371
+
372
+ # The Smpl Config client (sync).
373
+ #
374
+ # One client exposes the full surface, reachable as +client.config+
375
+ # (+Smplkit::Client+) or constructed directly:
376
+ #
377
+ # config = Smplkit::ConfigClient.new(environment: "production")
378
+ # billing = config.new("billing", name: "Billing")
379
+ # billing.set_number("max_seats", 50)
380
+ # billing.save
381
+ # proxy = config.subscribe("billing")
382
+ # puts proxy["max_seats"]
383
+ #
384
+ # The CRUD surface (+new+ / +get+ / +list+ / +delete+ and discovery)
385
+ # is pure CRUD. The live surface (+subscribe+ / +get_value+ / +bind+ /
386
+ # +on_change+ / +refresh+) connects lazily on first use — the first call
387
+ # flushes discovery, fetches and resolves all configs into the local cache,
388
+ # and opens the live-updates WebSocket. No explicit install step is
389
+ # required.
217
390
  class ConfigClient
218
- # Sentinel used to distinguish "argument not supplied" from "argument
219
- # supplied as nil" on +#get+. A frozen +Object+ is sufficient — we
220
- # only ever identity-compare with +equal?+.
391
+ # Sentinel distinguishing "no default supplied" from an explicit +nil+
392
+ # default in +#get_value+.
221
393
  MISSING = Object.new.freeze
222
394
  private_constant :MISSING
223
395
 
224
- def initialize(parent, manage:, metrics:)
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.
416
+ def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
417
+ base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
418
+ parent: nil, transport: nil, metrics: nil)
225
419
  @parent = parent
226
- @manage = manage
227
420
  @metrics = metrics
228
- @environment = parent._environment
229
- @service = parent._service
230
-
231
- @config_cache = {} # config_key -> { item_key => resolved_value }
232
- @raw_config_store = {} # config_key -> Smplkit::Config::Config
233
- @proxies = {} # config_key -> LiveConfigProxy
234
- @bindings = {} # config_key -> Hash | Struct (bound target)
235
- @listeners = [] # [callback, config_id_or_nil, item_key_or_nil]
421
+ @environment = parent.nil? ? environment : parent._environment
422
+ @service = parent&._service
423
+ @standalone_api_key = nil
424
+ if transport.nil?
425
+ @http, @app_base_url, @standalone_api_key = Smplkit::Config.config_transport(
426
+ api_key: api_key, base_url: base_url, profile: profile,
427
+ base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
428
+ )
429
+ @owns_transport = true
430
+ else
431
+ @http = transport
432
+ @app_base_url = nil
433
+ @owns_transport = false
434
+ end
435
+ @api = SmplkitGeneratedClient::Config::ConfigsApi.new(@http)
436
+
437
+ # Discovery buffer is owned by this client (no management delegation).
438
+ @buffer = ConfigRegistrationBuffer.new
439
+
440
+ # Live-surface state.
441
+ @config_cache = {} # config_id -> { item_key => resolved_value }
442
+ @raw_config_store = {} # config_id -> Config
443
+ @proxies = {} # config_id -> LiveConfigProxy
444
+ @bindings = {} # config_id -> Hash | Struct (bound target)
445
+ # Parent config id each binding was bound under (nil for roots) —
446
+ # drives in-memory cache seeding through the bound parent chain.
447
+ @bound_parents = {}
236
448
  @connected = false
237
449
  @lock = Mutex.new
450
+ @listeners = [] # [callback, config_id_or_nil, item_key_or_nil]
238
451
  @ws_manager = nil
452
+ @owns_ws = false
239
453
  end
240
454
 
241
- # Eagerly initialize the runtime. Flushes any buffered discovery
242
- # declarations, fetches the full config list, resolves values for the
243
- # SDK's current environment into the local cache, and subscribes to
244
- # +config_changed+ / +config_deleted+ / +configs_changed+ events on
245
- # the shared WebSocket.
455
+ # ----------------------------------------------------------------
456
+ # Management surface: CRUD (no live connection)
457
+ # ----------------------------------------------------------------
458
+
459
+ # Return a new unsaved +Config+. Call +Config#save+ to persist.
246
460
  #
247
- # Idempotent safe to call multiple times. Invoked automatically on
248
- # the first +#get+ or +#bind+ call.
249
- def start
250
- return if @connected
461
+ # +parent+ accepts either a config id (string) or an existing +Config+
462
+ # instance — passing the instance lets you skip naming the id explicitly
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+.
474
+ def new(id, name: nil, description: nil, parent: nil)
475
+ Config.new(
476
+ self,
477
+ key: id,
478
+ name: name || Smplkit::Helpers.key_to_display_name(id),
479
+ description: description,
480
+ parent_id: Smplkit::Config.resolve_parent_id(parent)
481
+ )
482
+ end
483
+
484
+ # Fetch the editable +Config+ resource by id.
485
+ #
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.
489
+ def get(id)
490
+ response = ApiSupport::ErrorMapping.call { @api.get_config(id) }
491
+ Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
492
+ end
493
+
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.
502
+ def list(page_number: nil, page_size: nil)
503
+ opts = {}
504
+ opts[:page_number] = page_number unless page_number.nil?
505
+ opts[:page_size] = page_size unless page_size.nil?
506
+ response = ApiSupport::ErrorMapping.call { @api.list_configs(opts) }
507
+ (response.data || []).map { |r| Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(r)) }
508
+ end
509
+
510
+ # Delete a config by id.
511
+ #
512
+ # @param id [String] The config identifier (slug) to delete.
513
+ # @return [void]
514
+ def delete(id)
515
+ ApiSupport::ErrorMapping.call { @api.delete_config(id) }
516
+ nil
517
+ end
518
+
519
+ def _create_config(config)
520
+ response = ApiSupport::ErrorMapping.call { @api.create_config(config_body(config)) }
521
+ Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
522
+ end
523
+
524
+ def _update_config(config)
525
+ response = ApiSupport::ErrorMapping.call { @api.update_config(config.key, config_body(config)) }
526
+ Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
527
+ end
251
528
 
252
- @environment = @parent._environment
529
+ # ----------------------------------------------------------------
530
+ # Management surface: discovery buffer (owned directly)
531
+ # ----------------------------------------------------------------
253
532
 
254
- # Per ADR-037 §2.14: flush pending discovery declarations BEFORE
255
- # the initial fetch so newly-declared configs show up in the cache.
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]
548
+ def register_config(config_id, service:, environment:, parent: nil, name: nil, description: nil)
549
+ @buffer.declare(config_id, service: service, environment: environment,
550
+ parent: parent, name: name, description: description)
551
+ trigger_background_flush_if_needed
552
+ end
553
+
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]
567
+ def register_config_item(config_id, item_key, item_type, default, description = nil)
568
+ @buffer.add_item(config_id, item_key, item_type, default, description)
569
+ trigger_background_flush_if_needed
570
+ end
571
+
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.
577
+ #
578
+ # @return [void]
579
+ def flush
580
+ batch = @buffer.drain
581
+ return if batch.empty?
582
+
583
+ body = build_config_bulk_request(batch)
256
584
  begin
257
- @manage&.config&.flush
585
+ ApiSupport::ErrorMapping.call { @api.bulk_register_configs(body) }
258
586
  rescue StandardError => e
259
- Smplkit.debug("config", "pre-start discovery flush failed: #{e.class}: #{e.message}")
587
+ # Fire-and-forget discovery failures never propagate to caller code.
588
+ Smplkit.debug("registration", "config bulk register failed: #{e.class}: #{e.message}")
260
589
  end
590
+ end
261
591
 
262
- do_refresh("initial")
263
- @connected = true
264
-
265
- @ws_manager = @parent._ensure_ws
266
- @ws_manager.on("config_changed") { |data| handle_config_changed(data) }
267
- @ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
268
- @ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
592
+ # Number of pending config declarations awaiting flush.
593
+ #
594
+ # @return [Integer] The count of buffered declarations not yet flushed.
595
+ def pending_count
596
+ @buffer.pending_count
269
597
  end
270
598
 
599
+ # ----------------------------------------------------------------
600
+ # Live surface: bind, subscribe, get_value
601
+ # ----------------------------------------------------------------
602
+
271
603
  # Bind a Hash or Struct to a config id; return the same object back, live.
272
604
  #
273
605
  # Declarative, code-first API. Two flavors:
274
606
  #
275
- # * +Hash+: keys present are leaves to register, with their values as
276
- # the in-code defaults. Nested Hashes flatten to dot-notation. Keys
277
- # the caller wants to inherit from +parent:+ are simply omitted.
278
- # * +Struct+: every member is registered as an explicit override.
279
- # Ruby Structs do not track which members were "explicitly set" vs
280
- # defaulted, so there is no Hash-style omit-to-inherit. For
281
- # omit-to-inherit, use a Hash target.
282
- #
283
- # On first call the schema and values are registered with the server.
284
- # After the local cache is populated, any server-side overrides for
285
- # this config are applied to the bound object in place. WebSocket
286
- # events thereafter mutate the bound object in place readers always
287
- # see the current resolved value with no indirection.
288
- #
289
- # Idempotent. Repeat calls with the same +id+ return the
607
+ # * +Hash+: keys present are leaves to register, with their values as the
608
+ # in-code defaults. Nested Hashes flatten to dot-notation. Keys the
609
+ # caller wants to inherit from +parent:+ are simply omitted.
610
+ # * +Struct+: every member is registered as an explicit override. Ruby
611
+ # Structs do not track which members were "explicitly set" vs defaulted,
612
+ # so there is no Hash-style omit-to-inherit. For omit-to-inherit, use a
613
+ # Hash target.
614
+ #
615
+ # On first boot the schema and values are registered with the server. The
616
+ # local cache is then seeded so reads work immediately: if the config
617
+ # already exists server-side (fetched on connect) its values are
618
+ # authoritative and synced onto the bound object; if it is brand-new, the
619
+ # cache entry is seeded in-memory from the bound object's values resolved
620
+ # through its bound parent chain (no network round-trip). On every
621
+ # WebSocket-delivered change thereafter the bound object is mutated in
622
+ # place. Readers always see the current resolved value with no proxy
623
+ # indirection.
624
+ #
625
+ # Idempotent. Repeated calls with the same +id+ return the
290
626
  # originally-bound object; the new +config+ argument is ignored.
291
- def bind(id, target, parent: nil)
292
- unless target.is_a?(Hash) || target.is_a?(Struct)
293
- raise TypeError, "bind() requires a Hash or Struct; got #{target.class.name}"
627
+ #
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+.
640
+ def bind(id, config, parent: nil)
641
+ ensure_connected
642
+ unless config.is_a?(Hash) || config.is_a?(Struct)
643
+ raise TypeError, "bind() requires a Hash or Struct; got #{config.class.name}"
294
644
  end
295
645
 
296
646
  return @bindings[id] if @bindings.key?(id)
297
647
 
298
- parent_id = resolve_parent_id(parent)
299
-
300
- if target.is_a?(Struct)
301
- class_name = target.class.name
302
- config_name = class_name&.split("::")&.last
303
- else
304
- config_name = nil
305
- end
306
-
307
- _observe_config_declaration(id, parent: parent_id, name: config_name, description: nil)
648
+ parent_id = register_binding_declaration(id, config, parent)
308
649
 
309
- Discovery.iter_items(target).each do |item_key, item_type, value, description|
310
- _observe_item_declaration(id, item_key, item_type, value, description)
311
- end
650
+ # Register the binding BEFORE syncing so WebSocket dispatch finds it.
651
+ @bindings[id] = config
652
+ @bound_parents[id] = parent_id
653
+ seed_or_sync_binding(id, config)
654
+ config
655
+ end
312
656
 
313
- # Register the binding BEFORE start() so any WS dispatch that fires
314
- # during the initial fetch finds it.
315
- @bindings[id] = target
657
+ # Return a live, dict-like +LiveConfigProxy+ for a config id.
658
+ #
659
+ # The proxy always reflects the latest resolved values; reads happen
660
+ # through it (+proxy["key"]+, +proxy.get("key", default)+). Subscribing
661
+ # registers the config declaration for code-first observability so the
662
+ # reference appears in the smplkit console.
663
+ #
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.
670
+ def subscribe(id)
671
+ ensure_connected
672
+ observe_config_declaration(id, parent: nil, name: nil, description: nil)
673
+ in_cache = @lock.synchronize { @config_cache.key?(id) }
674
+ raise Smplkit::NotFoundError, "Config with id '#{id}' not found" unless in_cache
316
675
 
317
- start unless @connected
318
- sync_target_from_cache(target, id)
319
- target
676
+ @metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
677
+ cached_proxy(id)
320
678
  end
321
679
 
322
- # Read a config (full) or a single value within a config.
680
+ # Read a single resolved config value (inheritance-aware).
681
+ #
682
+ # The value comes from the locally-cached resolved chain, so parent
683
+ # configs are already folded in.
684
+ #
685
+ # Two forms:
323
686
  #
324
- # Three forms dispatched by argument count:
687
+ # * +get_value(id, key)+ returns the resolved value. Raises +NotFoundError+
688
+ # if the config is unknown and +KeyError+ if the key is absent.
689
+ # * +get_value(id, key, default)+ returns the resolved value, falling back
690
+ # to +default+ if the config or key is missing. Never raises.
691
+ # *Registers* the config (if new) and the key (inferred type, +default+
692
+ # as default) for code-first observability, so the reference appears in
693
+ # the smplkit console.
325
694
  #
326
- # get("id") # LiveConfigProxy (raises NotFoundError)
327
- # get("id", "key") # value (raises NotFoundError / KeyError)
328
- # get("id", "key", default) # value or default; auto-registers (never raises)
329
- def get(id, key = MISSING, default = MISSING)
330
- start unless @connected
695
+ # For a live dict-like view use +#subscribe+; for typed access via a
696
+ # Struct schema use +#bind+. Connects lazily on first use — no explicit
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.
711
+ def get_value(id, key, default = MISSING)
712
+ ensure_connected
713
+ key = key.to_s
714
+ has_default = !default.equal?(MISSING)
715
+ if has_default
716
+ # Register the config + key so the reference shows up in the console
717
+ # even if it's never been declared via bind(). The buffer is
718
+ # idempotent at the (config_id, item_key) level.
719
+ observe_config_declaration(id, parent: nil, name: nil, description: nil)
720
+ observe_item_declaration(id, key, Discovery.value_to_item_type(default), default, nil)
721
+ end
722
+
723
+ values = @lock.synchronize { @config_cache[id]&.dup }
724
+ if values.nil?
725
+ return default if has_default
331
726
 
332
- return get_full_config(id) if key.equal?(MISSING)
727
+ raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
728
+ end
729
+ unless values.key?(key)
730
+ return default if has_default
333
731
 
334
- get_single_value(id, key.to_s, default)
732
+ raise KeyError, "Config item '#{key}' not found in config '#{id}'"
733
+ end
734
+ values[key]
335
735
  end
336
736
 
337
737
  # Register a change listener.
@@ -341,7 +741,18 @@ module Smplkit
341
741
  # client.config.on_change { |event| ... } # global
342
742
  # client.config.on_change("id") { |event| ... } # config-scoped
343
743
  # client.config.on_change("id", item_key: "key") { |event| ... } # item-scoped
744
+ #
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.
344
754
  def on_change(config_id = nil, item_key: nil, &block)
755
+ ensure_connected
345
756
  raise ArgumentError, "on_change requires a block" unless block
346
757
 
347
758
  @listeners << [block, config_id, item_key&.to_s]
@@ -350,87 +761,166 @@ module Smplkit
350
761
 
351
762
  # Re-fetch all configs and update resolved values, firing change
352
763
  # listeners for anything that differs from the previous state.
764
+ #
765
+ # Connects lazily on first use — no explicit install step.
766
+ #
767
+ # @return [void]
768
+ # @raise [Smplkit::ConnectionError] If the fetch fails.
353
769
  def refresh
354
- start unless @connected
770
+ ensure_connected
355
771
  do_refresh("manual")
356
772
  end
357
773
 
358
- def _close
359
- # No durable resources owned by this sub-client; the parent client
360
- # tears down the WebSocket and management transports.
774
+ # Release resources — only those this client owns.
775
+ #
776
+ # Tears down the owned WebSocket (opened by a standalone client on first
777
+ # live use) and the owned HTTP transport (standalone construction). A
778
+ # wired client borrows the parent's transport and WebSocket and closes
779
+ # neither.
780
+ #
781
+ # @return [void]
782
+ def close
783
+ if @owns_ws && @ws_manager
784
+ @ws_manager.stop
785
+ @ws_manager = nil
786
+ @owns_ws = false
787
+ end
788
+ nil
361
789
  end
790
+ alias _close close
362
791
 
363
- # Internal: return (a copy of) the resolved values for a config id.
364
- # Used by +LiveConfigProxy+.
365
- def _cached_values(config_id)
366
- @lock.synchronize do
367
- (@config_cache[config_id] || {}).dup
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.
798
+ def self.open(**kwargs)
799
+ client = new(**kwargs)
800
+ begin
801
+ yield client
802
+ ensure
803
+ client.close
368
804
  end
369
805
  end
370
806
 
371
- # Internal: queue a config declaration with the management buffer.
372
- def _observe_config_declaration(config_id, parent:, name:, description:)
373
- @manage&.config&.register_config(
374
- config_id,
375
- service: @service,
376
- environment: @environment,
377
- parent: parent,
378
- name: name,
379
- description: description
380
- )
807
+ # Internal: return (a copy of) the resolved values for a config id.
808
+ # Used by +LiveConfigProxy+.
809
+ def _cached_values(config_id)
810
+ @lock.synchronize { (@config_cache[config_id] || {}).dup }
381
811
  end
382
812
 
383
- # Internal: queue a config item declaration with the management buffer.
384
- def _observe_item_declaration(config_id, item_key, item_type, default, description)
385
- @manage&.config&.register_config_item(config_id, item_key, item_type, default, description)
813
+ # Internal: trigger lazy connect. Used by +Client#wait_until_ready+.
814
+ def _ensure_connected
815
+ ensure_connected
386
816
  end
387
817
 
388
818
  private
389
819
 
390
- def resolve_parent_id(parent)
391
- return nil if parent.nil?
820
+ # ----------------------------------------------------------------
821
+ # Live surface: lazy connect + transport / WebSocket helpers
822
+ # ----------------------------------------------------------------
392
823
 
393
- @bindings.each { |cid, bound| return cid if bound.equal?(parent) }
394
- raise ArgumentError,
395
- "bind(): parent must be an object previously returned from client.config.bind(). " \
396
- "Bind the parent first."
397
- end
824
+ def ensure_ws
825
+ return @parent._ensure_ws unless @parent.nil?
398
826
 
399
- def get_full_config(id)
400
- @lock.synchronize do
401
- raise Smplkit::NotFoundError, "Config with id '#{id}' not found" unless @config_cache.key?(id)
827
+ if @ws_manager.nil?
828
+ @ws_manager = SharedWebSocket.new(
829
+ app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
830
+ )
831
+ @ws_manager.start
832
+ @owns_ws = true
402
833
  end
403
- @metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
404
- cached_proxy(id)
834
+ @ws_manager
405
835
  end
406
836
 
407
- def get_single_value(id, key, default)
408
- has_default = !default.equal?(MISSING)
409
- if has_default
410
- _observe_config_declaration(id, parent: nil, name: nil, description: nil)
411
- _observe_item_declaration(id, key, Discovery.value_to_item_type(default), default, nil)
837
+ # Open the live connection to the running Smpl Config service.
838
+ #
839
+ # Flushes any buffered discovery declarations, fetches and resolves every
840
+ # config for the configured environment into the local cache, opens the
841
+ # shared WebSocket, and subscribes to +config_changed+ / +config_deleted+
842
+ # / +configs_changed+ events.
843
+ #
844
+ # Idempotent and internal — every live method calls it on first use, so
845
+ # the live surface auto-connects with no explicit step.
846
+ def ensure_connected
847
+ @parent&._ensure_started
848
+ return if @connected
849
+
850
+ # Flush any buffered discovery declarations BEFORE the initial fetch, so
851
+ # newly-discovered configs appear in the cache on first read.
852
+ begin
853
+ flush
854
+ rescue StandardError => e
855
+ Smplkit.debug("config", "discovery flush before connect failed: #{e.class}: #{e.message}")
412
856
  end
413
857
 
414
- values = @lock.synchronize { @config_cache[id]&.dup }
415
- if values.nil?
416
- return default if has_default
858
+ # Fetch + resolve + cache + fire change listeners (against empty
859
+ # old_cache, so any registered listeners see "initial" events).
860
+ do_refresh("initial")
861
+ @connected = true
417
862
 
418
- raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
863
+ @ws_manager = ensure_ws
864
+ @ws_manager.on("config_changed") { |data| handle_config_changed(data) }
865
+ @ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
866
+ @ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
867
+ end
868
+
869
+ # List configs directly from the API for the runtime cache.
870
+ def fetch_all_configs
871
+ rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_configs(opts) }
872
+ rows.map { |r| Helpers.config_from_json(nil, ApiSupport::ResourceShim.from_model(r)) }
873
+ end
874
+
875
+ # Fetch a single config from the API. Returns +nil+ on missing data.
876
+ def fetch_config(config_id)
877
+ response = ApiSupport::ErrorMapping.call { @api.get_config(config_id) }
878
+ Helpers.config_from_json(nil, ApiSupport::ResourceShim.from_model(response.data))
879
+ end
880
+
881
+ # ----------------------------------------------------------------
882
+ # Internal: binding helpers
883
+ # ----------------------------------------------------------------
884
+
885
+ # Validate the parent, register the config + item declarations. Returns
886
+ # the resolved parent config id (or +nil+).
887
+ def register_binding_declaration(id, config, parent)
888
+ parent_id = nil
889
+ unless parent.nil?
890
+ parent_id = config_id_for(parent)
891
+ if parent_id.nil?
892
+ raise ArgumentError,
893
+ "bind(): parent must be an object previously returned from client.config.bind(). " \
894
+ "Bind the parent first."
895
+ end
419
896
  end
420
- unless values.key?(key)
421
- return default if has_default
422
897
 
423
- raise KeyError, "Config item '#{key}' not found in config '#{id}'"
898
+ if config.is_a?(Struct)
899
+ class_name = config.class.name
900
+ config_name = class_name&.split("::")&.last
901
+ else
902
+ # Hash bind: no class to introspect for name/description.
903
+ config_name = nil
424
904
  end
425
- values[key]
426
- end
427
905
 
428
- def cached_proxy(config_id)
429
- @lock.synchronize do
430
- @proxies[config_id] ||= LiveConfigProxy.new(self, config_id)
906
+ observe_config_declaration(id, parent: parent_id, name: config_name, description: nil)
907
+
908
+ Discovery.iter_items(config).each do |item_key, item_type, value, description|
909
+ observe_item_declaration(id, item_key, item_type, value, description)
431
910
  end
911
+ parent_id
912
+ end
913
+
914
+ # Return the config_id this target was bound under, or nil.
915
+ def config_id_for(target)
916
+ @bindings.each { |cid, bound| return cid if bound.equal?(target) }
917
+ nil
432
918
  end
433
919
 
920
+ # Apply current cached values to a freshly-bound target.
921
+ #
922
+ # Handles the existing-config case: on restart, server-side values
923
+ # override the in-code defaults from the constructor (or Hash).
434
924
  def sync_target_from_cache(target, config_id)
435
925
  cache = @lock.synchronize { (@config_cache[config_id] || {}).dup }
436
926
  cache.each do |dotted_key, value|
@@ -438,9 +928,100 @@ module Smplkit
438
928
  end
439
929
  end
440
930
 
931
+ # Seed the resolved cache for a freshly-bound config, or sync from it.
932
+ #
933
+ # If +config_id+ is already in the resolved cache it existed server-side
934
+ # (fetched on connect), so server values are authoritative — sync them
935
+ # onto the bound object. Otherwise the config is brand-new: seed
936
+ # +config_cache[config_id]+ in-memory by resolving this object's values
937
+ # through its bound parent chain, so +#subscribe+ / +#get_value+ work
938
+ # immediately with no flush or refresh. Pure in-memory — no network.
939
+ def seed_or_sync_binding(config_id, target)
940
+ already_present = @lock.synchronize { @config_cache.key?(config_id) }
941
+ if already_present
942
+ sync_target_from_cache(target, config_id)
943
+ return
944
+ end
945
+ seeded = resolve_bound_chain(config_id)
946
+ @lock.synchronize { @config_cache[config_id] = seeded }
947
+ end
948
+
949
+ # Resolve a bound config's values through its bound parent chain.
950
+ #
951
+ # Walks +bound_parents+ from the child up through already-bound ancestors,
952
+ # flattening each bound object's in-code values, then runs the same
953
+ # deep-merge resolve used everywhere else (child wins over parent).
954
+ # Ancestors that aren't bound objects stop the walk.
955
+ def resolve_bound_chain(config_id)
956
+ chain = []
957
+ current = config_id
958
+ seen = {}
959
+ while !current.nil? && @bindings.key?(current) && !seen.key?(current)
960
+ seen[current] = true
961
+ items = bound_items_to_flat(@bindings[current])
962
+ chain << { "items" => items, "environments" => {} }
963
+ current = @bound_parents[current]
964
+ end
965
+ Helpers.resolve_chain(chain, @environment)
966
+ end
967
+
968
+ # Flatten a bound Struct or Hash to +{dotted_key => value}+. Mirrors the
969
+ # discovery-declaration walk. Used to seed the local resolved cache from
970
+ # in-memory bindings without any network round-trip.
971
+ def bound_items_to_flat(target)
972
+ Discovery.iter_items(target).each_with_object({}) do |(item_key, _type, value, _desc), out|
973
+ out[item_key] = value
974
+ end
975
+ end
976
+
977
+ def cached_proxy(config_id)
978
+ @lock.synchronize { @proxies[config_id] ||= LiveConfigProxy.new(self, config_id) }
979
+ end
980
+
981
+ # Queue a config declaration with the owned discovery buffer.
982
+ def observe_config_declaration(config_id, parent:, name:, description:)
983
+ register_config(config_id, service: @service, environment: @environment,
984
+ parent: parent, name: name, description: description)
985
+ end
986
+
987
+ # Queue a config item declaration with the owned discovery buffer.
988
+ def observe_item_declaration(config_id, item_key, item_type, default, description)
989
+ register_config_item(config_id, item_key, item_type, default, description)
990
+ end
991
+
992
+ def trigger_background_flush_if_needed
993
+ return unless @buffer.pending_count >= CONFIG_BATCH_FLUSH_SIZE
994
+
995
+ Thread.new { threshold_flush }
996
+ end
997
+
998
+ def threshold_flush
999
+ flush
1000
+ rescue StandardError => e
1001
+ Smplkit.debug("registration", "threshold config flush failed: #{e.class}: #{e.message}")
1002
+ end
1003
+
1004
+ # ----------------------------------------------------------------
1005
+ # Live surface: refresh / change listeners
1006
+ # ----------------------------------------------------------------
1007
+
1008
+ # Re-apply in-memory seeds for bound configs not yet present server-side.
1009
+ #
1010
+ # A freshly-bound config lives only as a seed until it is flushed and
1011
+ # fetched; without this, any cache rebuild (a manual refresh, or a
1012
+ # WebSocket event for another config) would drop it. Server-present
1013
+ # configs are already in +new_cache+ and are authoritative — only bound
1014
+ # ids missing from it are re-seeded.
1015
+ def merge_pending_seeds(new_cache)
1016
+ @bindings.each_key do |bound_id|
1017
+ new_cache[bound_id] = resolve_bound_chain(bound_id) unless new_cache.key?(bound_id)
1018
+ end
1019
+ end
1020
+
441
1021
  def do_refresh(source)
442
- configs = @manage.config.list
1022
+ configs = fetch_all_configs
443
1023
  new_cache, new_store = resolve_all(configs)
1024
+ merge_pending_seeds(new_cache)
444
1025
  old_cache = nil
445
1026
  @lock.synchronize do
446
1027
  old_cache = @config_cache
@@ -456,8 +1037,8 @@ module Smplkit
456
1037
  new_store = {}
457
1038
  configs.each do |cfg|
458
1039
  chain = Helpers.build_chain(cfg, by_id)
459
- new_cache[cfg.key] = Helpers.resolve_chain(chain, @environment)
460
- new_store[cfg.key] = cfg
1040
+ new_cache[cfg.id] = Helpers.resolve_chain(chain, @environment)
1041
+ new_store[cfg.id] = cfg
461
1042
  end
462
1043
  [new_cache, new_store]
463
1044
  end
@@ -479,6 +1060,8 @@ module Smplkit
479
1060
  new_val = new_items[i_key]
480
1061
  next if old_val == new_val
481
1062
 
1063
+ # Apply to bound target first so listeners reading the object see the
1064
+ # new value.
482
1065
  Discovery.apply_change_to_target(target, i_key, new_val) unless target.nil?
483
1066
  @metrics&.record("config.changes", unit: "changes", dimensions: { "config" => cfg_id })
484
1067
  event = ConfigChangeEvent.new(
@@ -502,38 +1085,85 @@ module Smplkit
502
1085
  end
503
1086
  end
504
1087
 
1088
+ # ----------------------------------------------------------------
1089
+ # Internal: event handlers (called by SharedWebSocket)
1090
+ # ----------------------------------------------------------------
1091
+
1092
+ # Re-resolve every config in +store+ and fire change listeners.
1093
+ #
1094
+ # Inheritance means a single config change can shift descendants' resolved
1095
+ # values too — so whenever the raw store is mutated (config added,
1096
+ # updated, or deleted), every config gets re-resolved against the new
1097
+ # snapshot.
1098
+ def rebuild_from_store(store, source:)
1099
+ configs = store.values
1100
+ new_cache, new_store = resolve_all(configs)
1101
+ merge_pending_seeds(new_cache)
1102
+ old_cache = nil
1103
+ @lock.synchronize do
1104
+ old_cache = @config_cache
1105
+ @config_cache = new_cache
1106
+ @raw_config_store = new_store
1107
+ end
1108
+ fire_change_listeners(old_cache, new_cache, source: source)
1109
+ end
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
+
505
1143
  def handle_config_changed(data)
506
- key = data["key"] || data["id"]
507
- return unless key
1144
+ key = data["id"] || data["key"]
1145
+ return handle_configs_changed(data) unless key
508
1146
 
509
1147
  begin
510
- cfg = @manage.config.get(key)
511
- rescue Smplkit::NotFoundError
512
- # Treat as a deletion — the resource is gone.
513
- handle_config_deleted(data)
514
- return
515
- rescue StandardError => e
516
- Smplkit.debug("config", "failed to fetch config #{key.inspect}: #{e.class}: #{e.message}")
517
- return
518
- end
1148
+ new_store = @lock.synchronize { @raw_config_store.dup }
1149
+ cfg = fetch_config(key)
1150
+ return if cfg.nil?
519
1151
 
520
- new_store = nil
521
- @lock.synchronize do
522
- new_store = @raw_config_store.dup
523
1152
  new_store[key] = cfg
1153
+ ensure_ancestors_cached(new_store)
1154
+ rebuild_from_store(new_store, source: "websocket")
1155
+ rescue StandardError => e
1156
+ Smplkit.debug("config", "config_changed handler failed for #{key.inspect}: #{e.class}: #{e.message}")
524
1157
  end
525
- rebuild_from_store(new_store, source: "websocket")
526
1158
  end
527
1159
 
528
1160
  def handle_config_deleted(data)
529
- key = data["key"] || data["id"]
530
- return unless key
1161
+ key = data["id"] || data["key"]
1162
+ return handle_configs_changed(data) unless key
1163
+
1164
+ new_store = @lock.synchronize { @raw_config_store.dup }
1165
+ return if new_store.delete(key).nil?
531
1166
 
532
- new_store = nil
533
- @lock.synchronize do
534
- new_store = @raw_config_store.dup
535
- return unless new_store.delete(key)
536
- end
537
1167
  rebuild_from_store(new_store, source: "websocket")
538
1168
  end
539
1169
 
@@ -543,17 +1173,75 @@ module Smplkit
543
1173
  Smplkit.debug("config", "configs_changed refresh failed: #{e.class}: #{e.message}")
544
1174
  end
545
1175
 
546
- def rebuild_from_store(store, source:)
547
- configs = store.values
548
- new_cache, new_store = resolve_all(configs)
549
- old_cache = nil
550
- @lock.synchronize do
551
- old_cache = @config_cache
552
- @config_cache = new_cache
553
- @raw_config_store = new_store
1176
+ # ----------------------------------------------------------------
1177
+ # Internal: request-body construction
1178
+ # ----------------------------------------------------------------
1179
+
1180
+ def config_body(config)
1181
+ SmplkitGeneratedClient::Config::ConfigResponse.new(
1182
+ data: SmplkitGeneratedClient::Config::ConfigResource.new(
1183
+ type: "config",
1184
+ id: config.key,
1185
+ attributes: SmplkitGeneratedClient::Config::Config.new(
1186
+ name: config.name,
1187
+ description: config.description,
1188
+ parent: config.parent_id,
1189
+ items: config_items_to_wire(config.items),
1190
+ environments: config_envs_to_wire(config.environments)
1191
+ )
1192
+ )
1193
+ )
1194
+ end
1195
+
1196
+ def config_items_to_wire(items)
1197
+ return nil if items.nil? || items.empty?
1198
+
1199
+ items.to_h do |item|
1200
+ [item.name, SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
1201
+ value: item.value, type: item.type, description: item.description
1202
+ )]
1203
+ end
1204
+ end
1205
+
1206
+ def config_envs_to_wire(environments)
1207
+ return nil if environments.empty?
1208
+
1209
+ # The wire shape for env overrides is a flat +{env: {key: rawValue}}+
1210
+ # map — no envelope, no per-key type wrapper.
1211
+ environments.each_with_object({}) do |(env_key, env_obj), out|
1212
+ out[env_key] = env_obj.values
1213
+ end
1214
+ end
1215
+
1216
+ def build_config_bulk_request(batch)
1217
+ items = batch.map do |entry|
1218
+ SmplkitGeneratedClient::Config::ConfigBulkItem.new(
1219
+ id: entry["id"],
1220
+ service: entry["service"],
1221
+ environment: entry["environment"],
1222
+ parent: entry["parent"],
1223
+ name: entry["name"],
1224
+ description: entry["description"],
1225
+ items: bulk_items_to_wire(entry["items"])
1226
+ )
1227
+ end
1228
+ SmplkitGeneratedClient::Config::ConfigBulkRequest.new(configs: items)
1229
+ end
1230
+
1231
+ def bulk_items_to_wire(items_hash)
1232
+ return nil if items_hash.nil? || items_hash.empty?
1233
+
1234
+ items_hash.transform_values do |def_hash|
1235
+ SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
1236
+ value: def_hash["value"],
1237
+ type: def_hash["type"],
1238
+ description: def_hash["description"]
1239
+ )
554
1240
  end
555
- fire_change_listeners(old_cache, new_cache, source: source)
556
1241
  end
557
1242
  end
558
1243
  end
1244
+
1245
+ ConfigClient = Config::ConfigClient
1246
+ ConfigChangeEvent = Config::ConfigChangeEvent
559
1247
  end