smplkit 3.0.95 → 3.0.96

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.
@@ -1,14 +1,39 @@
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
+ # * *Management 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.
7
32
  module Discovery
8
33
  module_function
9
34
 
10
35
  # 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,
36
+ # Hash/Struct target and when supplying a default to +get_value(id, key,
12
37
  # default)+. +true+/+false+ are checked first because Ruby's
13
38
  # +Numeric+/+Integer+ tests would not accidentally claim them.
14
39
  def value_to_item_type(value)
@@ -19,9 +44,9 @@ module Smplkit
19
44
  end
20
45
  end
21
46
 
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.
47
+ # Walk a bound target, returning +[key, type, value, description]+ tuples
48
+ # flattened to dot-notation. Nested Hashes / Structs are descended into;
49
+ # everything else is treated as an opaque leaf.
25
50
  def iter_items(target, prefix: "")
26
51
  if target.is_a?(Hash)
27
52
  iter_hash_items(target, prefix: prefix)
@@ -62,8 +87,8 @@ module Smplkit
62
87
  # Apply a server-pushed value to a bound target in place. Walks the
63
88
  # dotted key path to the leaf's parent and assigns the value via
64
89
  # +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.
90
+ # missing or not a supported container — the server may have items that
91
+ # don't line up with what the bound target declared.
67
92
  def apply_change_to_target(target, dotted_key, value)
68
93
  parts = dotted_key.split(".")
69
94
  current = walk_to_leaf_parent(target, parts[0..-2])
@@ -132,16 +157,15 @@ module Smplkit
132
157
 
133
158
  # A live, read-only, dict-like view of a config's resolved values.
134
159
  #
135
- # Returned by +ConfigClient#get(id)+ (single-arg form). Always reflects
136
- # the latest server-pushed state — every read sees current values.
160
+ # Returned by +ConfigClient#subscribe+. Always reflects the latest
161
+ # server-pushed state — every read sees current values.
137
162
  #
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.
163
+ # Supports +[]+, +key?+, +keys+, +values+, +each_pair+, +to_h+, +size+, and
164
+ # method-style attribute access for keys that don't collide with built-in
165
+ # method names. Use subscript (+proxy["values"]+) for keys that do collide.
142
166
  #
143
- # For typed access via a Struct schema, use +ConfigClient#bind+ —
144
- # bound objects stay live on the same cache, with no proxy indirection.
167
+ # For typed access via a Struct schema, use +ConfigClient#bind+ — bound
168
+ # objects stay live on the same cache, with no proxy indirection.
145
169
  class LiveConfigProxy
146
170
  # Methods that live on the proxy itself; never resolved against the
147
171
  # cached values dictionary.
@@ -208,130 +232,304 @@ module Smplkit
208
232
  end
209
233
  end
210
234
 
211
- # Synchronous runtime client for Smpl Config.
235
+ # Normalize a +parent+ argument to a config id string.
236
+ def self.resolve_parent_id(parent)
237
+ return parent if parent.nil? || parent.is_a?(String)
238
+ if parent.id.nil? || parent.id == ""
239
+ raise ArgumentError, "parent config must be saved (have an id) before being used as a parent"
240
+ end
241
+
242
+ parent.id
243
+ end
244
+
245
+ # Build a standalone config transport and resolve the app base URL.
246
+ #
247
+ # +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.
252
+ def self.config_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
253
+ cfg = ConfigResolution.resolve_management_config(
254
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
255
+ )
256
+ resolved_key = api_key.nil? ? cfg.api_key : api_key
257
+ merged = {}
258
+ merged.merge!(cfg.extra_headers || {})
259
+ merged.merge!(extra_headers || {})
260
+ tcfg = ConfigResolution::ResolvedManagementConfig.new(
261
+ api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
262
+ debug: cfg.debug, extra_headers: merged
263
+ )
264
+ app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
265
+ transport = Transport.build_api_client(SmplkitGeneratedClient::Config, "config", tcfg, base_url: base_url)
266
+ [transport, app_url, resolved_key]
267
+ end
268
+
269
+ # The Smpl Config client (sync).
212
270
  #
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+.
271
+ # One client exposes the full surface, reachable as +client.config+
272
+ # (+Smplkit::Client+) or constructed directly:
273
+ #
274
+ # config = Smplkit::ConfigClient.new(environment: "production")
275
+ # billing = config.new("billing", name: "Billing")
276
+ # billing.set_number("max_seats", 50)
277
+ # billing.save
278
+ # proxy = config.subscribe("billing")
279
+ # puts proxy["max_seats"]
280
+ #
281
+ # The management surface (+new+ / +get+ / +list+ / +delete+ and discovery)
282
+ # is pure CRUD. The live surface (+subscribe+ / +get_value+ / +bind+ /
283
+ # +on_change+ / +refresh+) connects lazily on first use — the first call
284
+ # flushes discovery, fetches and resolves all configs into the local cache,
285
+ # and opens the live-updates WebSocket. No explicit install step is
286
+ # required.
217
287
  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?+.
288
+ # Sentinel distinguishing "no default supplied" from an explicit +nil+
289
+ # default in +#get_value+.
221
290
  MISSING = Object.new.freeze
222
291
  private_constant :MISSING
223
292
 
224
- def initialize(parent, manage:, metrics:)
293
+ def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
294
+ base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
295
+ parent: nil, transport: nil, metrics: nil)
225
296
  @parent = parent
226
- @manage = manage
227
297
  @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]
298
+ @environment = parent.nil? ? environment : parent._environment
299
+ @service = parent&._service
300
+ @standalone_api_key = nil
301
+ if transport.nil?
302
+ @http, @app_base_url, @standalone_api_key = Smplkit::Config.config_transport(
303
+ api_key: api_key, base_url: base_url, profile: profile,
304
+ base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
305
+ )
306
+ @owns_transport = true
307
+ else
308
+ @http = transport
309
+ @app_base_url = nil
310
+ @owns_transport = false
311
+ end
312
+ @api = SmplkitGeneratedClient::Config::ConfigsApi.new(@http)
313
+
314
+ # Discovery buffer is owned by this client (no management delegation).
315
+ @buffer = ConfigRegistrationBuffer.new
316
+
317
+ # Live-surface state.
318
+ @config_cache = {} # config_id -> { item_key => resolved_value }
319
+ @raw_config_store = {} # config_id -> Config
320
+ @proxies = {} # config_id -> LiveConfigProxy
321
+ @bindings = {} # config_id -> Hash | Struct (bound target)
322
+ # Parent config id each binding was bound under (nil for roots) —
323
+ # drives in-memory cache seeding through the bound parent chain.
324
+ @bound_parents = {}
236
325
  @connected = false
237
326
  @lock = Mutex.new
327
+ @listeners = [] # [callback, config_id_or_nil, item_key_or_nil]
238
328
  @ws_manager = nil
329
+ @owns_ws = false
330
+ end
331
+
332
+ # ----------------------------------------------------------------
333
+ # Management surface: CRUD (no live connection)
334
+ # ----------------------------------------------------------------
335
+
336
+ # Return a new unsaved +Config+. Call +Config#save+ to persist.
337
+ #
338
+ # +parent+ accepts either a config id (string) or an existing +Config+
339
+ # instance — passing the instance lets you skip naming the id explicitly
340
+ # when you already have the parent in scope.
341
+ def new(id, name: nil, description: nil, parent: nil)
342
+ Config.new(
343
+ self,
344
+ key: id,
345
+ name: name || Smplkit::Helpers.key_to_display_name(id),
346
+ description: description,
347
+ parent_id: Smplkit::Config.resolve_parent_id(parent)
348
+ )
239
349
  end
240
350
 
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.
351
+ # Fetch the editable +Config+ resource by id.
246
352
  #
247
- # Idempotent safe to call multiple times. Invoked automatically on
248
- # the first +#get+ or +#bind+ call.
249
- def start
250
- return if @connected
353
+ # Raises +NotFoundError+ if no config with that id exists.
354
+ def get(id)
355
+ response = ApiSupport::ErrorMapping.call { @api.get_config(id) }
356
+ Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
357
+ end
251
358
 
252
- @environment = @parent._environment
359
+ # List configs for the authenticated account.
360
+ def list(page_number: nil, page_size: nil)
361
+ opts = {}
362
+ opts[:page_number] = page_number unless page_number.nil?
363
+ opts[:page_size] = page_size unless page_size.nil?
364
+ response = ApiSupport::ErrorMapping.call { @api.list_configs(opts) }
365
+ (response.data || []).map { |r| Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(r)) }
366
+ end
367
+
368
+ # Delete a config by id.
369
+ def delete(id)
370
+ ApiSupport::ErrorMapping.call { @api.delete_config(id) }
371
+ nil
372
+ end
373
+
374
+ def _create_config(config)
375
+ response = ApiSupport::ErrorMapping.call { @api.create_config(config_body(config)) }
376
+ Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
377
+ end
378
+
379
+ def _update_config(config)
380
+ response = ApiSupport::ErrorMapping.call { @api.update_config(config.key, config_body(config)) }
381
+ Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
382
+ end
253
383
 
254
- # Per ADR-037 §2.14: flush pending discovery declarations BEFORE
255
- # the initial fetch so newly-declared configs show up in the cache.
384
+ # ----------------------------------------------------------------
385
+ # Management surface: discovery buffer (owned directly)
386
+ # ----------------------------------------------------------------
387
+
388
+ # Queue a configuration declaration for bulk-discovery upload.
389
+ def register_config(config_id, service:, environment:, parent: nil, name: nil, description: nil)
390
+ @buffer.declare(config_id, service: service, environment: environment,
391
+ parent: parent, name: name, description: description)
392
+ trigger_background_flush_if_needed
393
+ end
394
+
395
+ # Queue a config item declaration. +register_config+ must run first.
396
+ def register_config_item(config_id, item_key, item_type, default, description = nil)
397
+ @buffer.add_item(config_id, item_key, item_type, default, description)
398
+ trigger_background_flush_if_needed
399
+ end
400
+
401
+ # POST pending declarations to +/api/v1/configs/bulk+.
402
+ #
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.
407
+ def flush
408
+ batch = @buffer.drain
409
+ return if batch.empty?
410
+
411
+ body = build_config_bulk_request(batch)
256
412
  begin
257
- @manage&.config&.flush
413
+ ApiSupport::ErrorMapping.call { @api.bulk_register_configs(body) }
258
414
  rescue StandardError => e
259
- Smplkit.debug("config", "pre-start discovery flush failed: #{e.class}: #{e.message}")
415
+ # Fire-and-forget per ADR-024 §2.9.
416
+ Smplkit.debug("registration", "config bulk register failed: #{e.class}: #{e.message}")
260
417
  end
418
+ end
261
419
 
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) }
420
+ # Number of pending config declarations awaiting flush.
421
+ def pending_count
422
+ @buffer.pending_count
269
423
  end
270
424
 
425
+ # ----------------------------------------------------------------
426
+ # Live surface: bind, subscribe, get_value
427
+ # ----------------------------------------------------------------
428
+
271
429
  # Bind a Hash or Struct to a config id; return the same object back, live.
272
430
  #
273
431
  # Declarative, code-first API. Two flavors:
274
432
  #
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.
433
+ # * +Hash+: keys present are leaves to register, with their values as the
434
+ # in-code defaults. Nested Hashes flatten to dot-notation. Keys the
435
+ # caller wants to inherit from +parent:+ are simply omitted.
436
+ # * +Struct+: every member is registered as an explicit override. Ruby
437
+ # Structs do not track which members were "explicitly set" vs defaulted,
438
+ # so there is no Hash-style omit-to-inherit. For omit-to-inherit, use a
439
+ # Hash target.
282
440
  #
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.
441
+ # On first boot the schema and values are registered with the server. The
442
+ # local cache is then seeded so reads work immediately: if the config
443
+ # already exists server-side (fetched on connect) its values are
444
+ # authoritative and synced onto the bound object; if it is brand-new, the
445
+ # cache entry is seeded in-memory from the bound object's values resolved
446
+ # through its bound parent chain (no network round-trip). On every
447
+ # WebSocket-delivered change thereafter the bound object is mutated in
448
+ # place. Readers always see the current resolved value with no proxy
449
+ # indirection.
288
450
  #
289
- # Idempotent. Repeat calls with the same +id+ return the
451
+ # Idempotent. Repeated calls with the same +id+ return the
290
452
  # 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}"
453
+ #
454
+ # Connects lazily on first use — no explicit install step.
455
+ def bind(id, config, parent: nil)
456
+ ensure_connected
457
+ unless config.is_a?(Hash) || config.is_a?(Struct)
458
+ raise TypeError, "bind() requires a Hash or Struct; got #{config.class.name}"
294
459
  end
295
460
 
296
461
  return @bindings[id] if @bindings.key?(id)
297
462
 
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)
463
+ parent_id = register_binding_declaration(id, config, parent)
308
464
 
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
465
+ # Register the binding BEFORE syncing so WebSocket dispatch finds it.
466
+ @bindings[id] = config
467
+ @bound_parents[id] = parent_id
468
+ seed_or_sync_binding(id, config)
469
+ config
470
+ end
312
471
 
313
- # Register the binding BEFORE start() so any WS dispatch that fires
314
- # during the initial fetch finds it.
315
- @bindings[id] = target
472
+ # Return a live, dict-like +LiveConfigProxy+ for a config id.
473
+ #
474
+ # The proxy always reflects the latest resolved values; reads happen
475
+ # through it (+proxy["key"]+, +proxy.get("key", default)+). Subscribing
476
+ # registers the config declaration for code-first observability so the
477
+ # reference appears in the smplkit console.
478
+ #
479
+ # Connects lazily on first use — no explicit install step. Raises
480
+ # +NotFoundError+ if the config is unknown.
481
+ def subscribe(id)
482
+ ensure_connected
483
+ observe_config_declaration(id, parent: nil, name: nil, description: nil)
484
+ in_cache = @lock.synchronize { @config_cache.key?(id) }
485
+ raise Smplkit::NotFoundError, "Config with id '#{id}' not found" unless in_cache
316
486
 
317
- start unless @connected
318
- sync_target_from_cache(target, id)
319
- target
487
+ @metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
488
+ cached_proxy(id)
320
489
  end
321
490
 
322
- # Read a config (full) or a single value within a config.
491
+ # Read a single resolved config value (inheritance-aware).
492
+ #
493
+ # The value comes from the locally-cached resolved chain, so parent
494
+ # configs are already folded in.
495
+ #
496
+ # Two forms:
323
497
  #
324
- # Three forms dispatched by argument count:
498
+ # * +get_value(id, key)+ returns the resolved value. Raises +NotFoundError+
499
+ # if the config is unknown and +KeyError+ if the key is absent.
500
+ # * +get_value(id, key, default)+ returns the resolved value, falling back
501
+ # to +default+ if the config or key is missing. Never raises.
502
+ # *Registers* the config (if new) and the key (inferred type, +default+
503
+ # as default) for code-first observability, so the reference appears in
504
+ # the smplkit console.
325
505
  #
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
506
+ # For a live dict-like view use +#subscribe+; for typed access via a
507
+ # Struct schema use +#bind+. Connects lazily on first use — no explicit
508
+ # install step.
509
+ def get_value(id, key, default = MISSING)
510
+ ensure_connected
511
+ key = key.to_s
512
+ has_default = !default.equal?(MISSING)
513
+ if has_default
514
+ # Register the config + key so the reference shows up in the console
515
+ # even if it's never been declared via bind(). The buffer is
516
+ # idempotent at the (config_id, item_key) level.
517
+ observe_config_declaration(id, parent: nil, name: nil, description: nil)
518
+ observe_item_declaration(id, key, Discovery.value_to_item_type(default), default, nil)
519
+ end
331
520
 
332
- return get_full_config(id) if key.equal?(MISSING)
521
+ values = @lock.synchronize { @config_cache[id]&.dup }
522
+ if values.nil?
523
+ return default if has_default
524
+
525
+ raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
526
+ end
527
+ unless values.key?(key)
528
+ return default if has_default
333
529
 
334
- get_single_value(id, key.to_s, default)
530
+ raise KeyError, "Config item '#{key}' not found in config '#{id}'"
531
+ end
532
+ values[key]
335
533
  end
336
534
 
337
535
  # Register a change listener.
@@ -341,7 +539,10 @@ module Smplkit
341
539
  # client.config.on_change { |event| ... } # global
342
540
  # client.config.on_change("id") { |event| ... } # config-scoped
343
541
  # client.config.on_change("id", item_key: "key") { |event| ... } # item-scoped
542
+ #
543
+ # Connects lazily on first use — no explicit install step.
344
544
  def on_change(config_id = nil, item_key: nil, &block)
545
+ ensure_connected
345
546
  raise ArgumentError, "on_change requires a block" unless block
346
547
 
347
548
  @listeners << [block, config_id, item_key&.to_s]
@@ -350,87 +551,156 @@ module Smplkit
350
551
 
351
552
  # Re-fetch all configs and update resolved values, firing change
352
553
  # listeners for anything that differs from the previous state.
554
+ #
555
+ # Connects lazily on first use — no explicit install step.
353
556
  def refresh
354
- start unless @connected
557
+ ensure_connected
355
558
  do_refresh("manual")
356
559
  end
357
560
 
358
- def _close
359
- # No durable resources owned by this sub-client; the parent client
360
- # tears down the WebSocket and management transports.
561
+ # Release resources — only those this client owns.
562
+ #
563
+ # Tears down the owned WebSocket (opened by a standalone client on first
564
+ # live use) and the owned HTTP transport (standalone construction). A
565
+ # wired client borrows the parent's transport and WebSocket and closes
566
+ # neither.
567
+ def close
568
+ if @owns_ws && @ws_manager
569
+ @ws_manager.stop
570
+ @ws_manager = nil
571
+ @owns_ws = false
572
+ end
573
+ nil
361
574
  end
575
+ alias _close close
362
576
 
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
577
+ # Construct, yield to the block, and close on exit.
578
+ def self.open(**kwargs)
579
+ client = new(**kwargs)
580
+ begin
581
+ yield client
582
+ ensure
583
+ client.close
368
584
  end
369
585
  end
370
586
 
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
- )
587
+ # Internal: return (a copy of) the resolved values for a config id.
588
+ # Used by +LiveConfigProxy+.
589
+ def _cached_values(config_id)
590
+ @lock.synchronize { (@config_cache[config_id] || {}).dup }
381
591
  end
382
592
 
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)
593
+ # Internal: trigger lazy connect. Used by +Client#wait_until_ready+.
594
+ def _ensure_connected
595
+ ensure_connected
386
596
  end
387
597
 
388
598
  private
389
599
 
390
- def resolve_parent_id(parent)
391
- return nil if parent.nil?
600
+ # ----------------------------------------------------------------
601
+ # Live surface: lazy connect + transport / WebSocket helpers
602
+ # ----------------------------------------------------------------
392
603
 
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
604
+ def ensure_ws
605
+ return @parent._ensure_ws unless @parent.nil?
398
606
 
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)
607
+ if @ws_manager.nil?
608
+ @ws_manager = SharedWebSocket.new(
609
+ app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
610
+ )
611
+ @ws_manager.start
612
+ @owns_ws = true
402
613
  end
403
- @metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
404
- cached_proxy(id)
614
+ @ws_manager
405
615
  end
406
616
 
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)
617
+ # Open the live connection to the running Smpl Config service.
618
+ #
619
+ # Flushes any buffered discovery declarations, fetches and resolves every
620
+ # config for the configured environment into the local cache, opens the
621
+ # shared WebSocket, and subscribes to +config_changed+ / +config_deleted+
622
+ # / +configs_changed+ events.
623
+ #
624
+ # Idempotent and internal — every live method calls it on first use, so
625
+ # the live surface auto-connects with no explicit step.
626
+ def ensure_connected
627
+ @parent&._ensure_started
628
+ return if @connected
629
+
630
+ # Flush any buffered discovery declarations BEFORE the initial fetch, so
631
+ # newly-discovered configs appear in the cache on first read.
632
+ begin
633
+ flush
634
+ rescue StandardError => e
635
+ Smplkit.debug("config", "discovery flush before connect failed: #{e.class}: #{e.message}")
412
636
  end
413
637
 
414
- values = @lock.synchronize { @config_cache[id]&.dup }
415
- if values.nil?
416
- return default if has_default
638
+ # Fetch + resolve + cache + fire change listeners (against empty
639
+ # old_cache, so any registered listeners see "initial" events).
640
+ do_refresh("initial")
641
+ @connected = true
417
642
 
418
- raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
643
+ @ws_manager = ensure_ws
644
+ @ws_manager.on("config_changed") { |data| handle_config_changed(data) }
645
+ @ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
646
+ @ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
647
+ end
648
+
649
+ # List configs directly from the API for the runtime cache.
650
+ def fetch_all_configs
651
+ rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_configs(opts) }
652
+ rows.map { |r| Helpers.config_from_json(nil, ApiSupport::ResourceShim.from_model(r)) }
653
+ end
654
+
655
+ # Fetch a single config from the API. Returns +nil+ on missing data.
656
+ def fetch_config(config_id)
657
+ response = ApiSupport::ErrorMapping.call { @api.get_config(config_id) }
658
+ Helpers.config_from_json(nil, ApiSupport::ResourceShim.from_model(response.data))
659
+ end
660
+
661
+ # ----------------------------------------------------------------
662
+ # Internal: binding helpers
663
+ # ----------------------------------------------------------------
664
+
665
+ # Validate the parent, register the config + item declarations. Returns
666
+ # the resolved parent config id (or +nil+).
667
+ def register_binding_declaration(id, config, parent)
668
+ parent_id = nil
669
+ unless parent.nil?
670
+ parent_id = config_id_for(parent)
671
+ if parent_id.nil?
672
+ raise ArgumentError,
673
+ "bind(): parent must be an object previously returned from client.config.bind(). " \
674
+ "Bind the parent first."
675
+ end
419
676
  end
420
- unless values.key?(key)
421
- return default if has_default
422
677
 
423
- raise KeyError, "Config item '#{key}' not found in config '#{id}'"
678
+ if config.is_a?(Struct)
679
+ class_name = config.class.name
680
+ config_name = class_name&.split("::")&.last
681
+ else
682
+ # Hash bind: no class to introspect for name/description.
683
+ config_name = nil
424
684
  end
425
- values[key]
426
- end
427
685
 
428
- def cached_proxy(config_id)
429
- @lock.synchronize do
430
- @proxies[config_id] ||= LiveConfigProxy.new(self, config_id)
686
+ observe_config_declaration(id, parent: parent_id, name: config_name, description: nil)
687
+
688
+ Discovery.iter_items(config).each do |item_key, item_type, value, description|
689
+ observe_item_declaration(id, item_key, item_type, value, description)
431
690
  end
691
+ parent_id
692
+ end
693
+
694
+ # Return the config_id this target was bound under, or nil.
695
+ def config_id_for(target)
696
+ @bindings.each { |cid, bound| return cid if bound.equal?(target) }
697
+ nil
432
698
  end
433
699
 
700
+ # Apply current cached values to a freshly-bound target.
701
+ #
702
+ # Handles the existing-config case: on restart, server-side values
703
+ # override the in-code defaults from the constructor (or Hash).
434
704
  def sync_target_from_cache(target, config_id)
435
705
  cache = @lock.synchronize { (@config_cache[config_id] || {}).dup }
436
706
  cache.each do |dotted_key, value|
@@ -438,9 +708,100 @@ module Smplkit
438
708
  end
439
709
  end
440
710
 
711
+ # Seed the resolved cache for a freshly-bound config, or sync from it.
712
+ #
713
+ # If +config_id+ is already in the resolved cache it existed server-side
714
+ # (fetched on connect), so server values are authoritative — sync them
715
+ # onto the bound object. Otherwise the config is brand-new: seed
716
+ # +config_cache[config_id]+ in-memory by resolving this object's values
717
+ # through its bound parent chain, so +#subscribe+ / +#get_value+ work
718
+ # immediately with no flush or refresh. Pure in-memory — no network.
719
+ def seed_or_sync_binding(config_id, target)
720
+ already_present = @lock.synchronize { @config_cache.key?(config_id) }
721
+ if already_present
722
+ sync_target_from_cache(target, config_id)
723
+ return
724
+ end
725
+ seeded = resolve_bound_chain(config_id)
726
+ @lock.synchronize { @config_cache[config_id] = seeded }
727
+ end
728
+
729
+ # Resolve a bound config's values through its bound parent chain.
730
+ #
731
+ # Walks +bound_parents+ from the child up through already-bound ancestors,
732
+ # flattening each bound object's in-code values, then runs the same
733
+ # deep-merge resolve used everywhere else (child wins over parent).
734
+ # Ancestors that aren't bound objects stop the walk.
735
+ def resolve_bound_chain(config_id)
736
+ chain = []
737
+ current = config_id
738
+ seen = {}
739
+ while !current.nil? && @bindings.key?(current) && !seen.key?(current)
740
+ seen[current] = true
741
+ items = bound_items_to_flat(@bindings[current])
742
+ chain << { "items" => items, "environments" => {} }
743
+ current = @bound_parents[current]
744
+ end
745
+ Helpers.resolve_chain(chain, @environment)
746
+ end
747
+
748
+ # Flatten a bound Struct or Hash to +{dotted_key => value}+. Mirrors the
749
+ # discovery-declaration walk. Used to seed the local resolved cache from
750
+ # in-memory bindings without any network round-trip.
751
+ def bound_items_to_flat(target)
752
+ Discovery.iter_items(target).each_with_object({}) do |(item_key, _type, value, _desc), out|
753
+ out[item_key] = value
754
+ end
755
+ end
756
+
757
+ def cached_proxy(config_id)
758
+ @lock.synchronize { @proxies[config_id] ||= LiveConfigProxy.new(self, config_id) }
759
+ end
760
+
761
+ # Queue a config declaration with the owned discovery buffer.
762
+ def observe_config_declaration(config_id, parent:, name:, description:)
763
+ register_config(config_id, service: @service, environment: @environment,
764
+ parent: parent, name: name, description: description)
765
+ end
766
+
767
+ # Queue a config item declaration with the owned discovery buffer.
768
+ def observe_item_declaration(config_id, item_key, item_type, default, description)
769
+ register_config_item(config_id, item_key, item_type, default, description)
770
+ end
771
+
772
+ def trigger_background_flush_if_needed
773
+ return unless @buffer.pending_count >= CONFIG_BATCH_FLUSH_SIZE
774
+
775
+ Thread.new { threshold_flush }
776
+ end
777
+
778
+ def threshold_flush
779
+ flush
780
+ rescue StandardError => e
781
+ Smplkit.debug("registration", "threshold config flush failed: #{e.class}: #{e.message}")
782
+ end
783
+
784
+ # ----------------------------------------------------------------
785
+ # Live surface: refresh / change listeners
786
+ # ----------------------------------------------------------------
787
+
788
+ # Re-apply in-memory seeds for bound configs not yet present server-side.
789
+ #
790
+ # A freshly-bound config lives only as a seed until it is flushed and
791
+ # fetched; without this, any cache rebuild (a manual refresh, or a
792
+ # WebSocket event for another config) would drop it. Server-present
793
+ # configs are already in +new_cache+ and are authoritative — only bound
794
+ # ids missing from it are re-seeded.
795
+ def merge_pending_seeds(new_cache)
796
+ @bindings.each_key do |bound_id|
797
+ new_cache[bound_id] = resolve_bound_chain(bound_id) unless new_cache.key?(bound_id)
798
+ end
799
+ end
800
+
441
801
  def do_refresh(source)
442
- configs = @manage.config.list
802
+ configs = fetch_all_configs
443
803
  new_cache, new_store = resolve_all(configs)
804
+ merge_pending_seeds(new_cache)
444
805
  old_cache = nil
445
806
  @lock.synchronize do
446
807
  old_cache = @config_cache
@@ -456,8 +817,8 @@ module Smplkit
456
817
  new_store = {}
457
818
  configs.each do |cfg|
458
819
  chain = Helpers.build_chain(cfg, by_id)
459
- new_cache[cfg.key] = Helpers.resolve_chain(chain, @environment)
460
- new_store[cfg.key] = cfg
820
+ new_cache[cfg.id] = Helpers.resolve_chain(chain, @environment)
821
+ new_store[cfg.id] = cfg
461
822
  end
462
823
  [new_cache, new_store]
463
824
  end
@@ -479,6 +840,8 @@ module Smplkit
479
840
  new_val = new_items[i_key]
480
841
  next if old_val == new_val
481
842
 
843
+ # Apply to bound target first so listeners reading the object see the
844
+ # new value.
482
845
  Discovery.apply_change_to_target(target, i_key, new_val) unless target.nil?
483
846
  @metrics&.record("config.changes", unit: "changes", dimensions: { "config" => cfg_id })
484
847
  event = ConfigChangeEvent.new(
@@ -502,38 +865,53 @@ module Smplkit
502
865
  end
503
866
  end
504
867
 
868
+ # ----------------------------------------------------------------
869
+ # Internal: event handlers (called by SharedWebSocket)
870
+ # ----------------------------------------------------------------
871
+
872
+ # Re-resolve every config in +store+ and fire change listeners.
873
+ #
874
+ # Inheritance means a single config change can shift descendants' resolved
875
+ # values too — so whenever the raw store is mutated (config added,
876
+ # updated, or deleted), every config gets re-resolved against the new
877
+ # snapshot.
878
+ def rebuild_from_store(store, source:)
879
+ configs = store.values
880
+ new_cache, new_store = resolve_all(configs)
881
+ merge_pending_seeds(new_cache)
882
+ old_cache = nil
883
+ @lock.synchronize do
884
+ old_cache = @config_cache
885
+ @config_cache = new_cache
886
+ @raw_config_store = new_store
887
+ end
888
+ fire_change_listeners(old_cache, new_cache, source: source)
889
+ end
890
+
505
891
  def handle_config_changed(data)
506
- key = data["key"] || data["id"]
507
- return unless key
892
+ key = data["id"] || data["key"]
893
+ return handle_configs_changed(data) unless key
508
894
 
895
+ new_store = @lock.synchronize { @raw_config_store.dup }
509
896
  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
897
+ cfg = fetch_config(key)
515
898
  rescue StandardError => e
516
- Smplkit.debug("config", "failed to fetch config #{key.inspect}: #{e.class}: #{e.message}")
899
+ Smplkit.debug("config", "failed to fetch config #{key.inspect} after WS event: #{e.class}: #{e.message}")
517
900
  return
518
901
  end
902
+ return if cfg.nil?
519
903
 
520
- new_store = nil
521
- @lock.synchronize do
522
- new_store = @raw_config_store.dup
523
- new_store[key] = cfg
524
- end
904
+ new_store[key] = cfg
525
905
  rebuild_from_store(new_store, source: "websocket")
526
906
  end
527
907
 
528
908
  def handle_config_deleted(data)
529
- key = data["key"] || data["id"]
530
- return unless key
909
+ key = data["id"] || data["key"]
910
+ return handle_configs_changed(data) unless key
911
+
912
+ new_store = @lock.synchronize { @raw_config_store.dup }
913
+ return if new_store.delete(key).nil?
531
914
 
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
915
  rebuild_from_store(new_store, source: "websocket")
538
916
  end
539
917
 
@@ -543,17 +921,74 @@ module Smplkit
543
921
  Smplkit.debug("config", "configs_changed refresh failed: #{e.class}: #{e.message}")
544
922
  end
545
923
 
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
924
+ # ----------------------------------------------------------------
925
+ # Internal: request-body construction
926
+ # ----------------------------------------------------------------
927
+
928
+ def config_body(config)
929
+ SmplkitGeneratedClient::Config::ConfigResponse.new(
930
+ data: SmplkitGeneratedClient::Config::ConfigResource.new(
931
+ type: "config",
932
+ id: config.key,
933
+ attributes: SmplkitGeneratedClient::Config::Config.new(
934
+ name: config.name,
935
+ description: config.description,
936
+ parent: config.parent_id,
937
+ items: config_items_to_wire(config.items),
938
+ environments: config_envs_to_wire(config.environments)
939
+ )
940
+ )
941
+ )
942
+ end
943
+
944
+ def config_items_to_wire(items)
945
+ return nil if items.nil? || items.empty?
946
+
947
+ items.to_h do |item|
948
+ [item.name, SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
949
+ value: item.value, type: item.type, description: item.description
950
+ )]
951
+ end
952
+ end
953
+
954
+ def config_envs_to_wire(environments)
955
+ return nil if environments.empty?
956
+
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.
959
+ environments.each_with_object({}) do |(env_key, env_obj), out|
960
+ out[env_key] = env_obj.values
961
+ end
962
+ end
963
+
964
+ def build_config_bulk_request(batch)
965
+ items = batch.map do |entry|
966
+ SmplkitGeneratedClient::Config::ConfigBulkItem.new(
967
+ id: entry["id"],
968
+ service: entry["service"],
969
+ environment: entry["environment"],
970
+ parent: entry["parent"],
971
+ name: entry["name"],
972
+ description: entry["description"],
973
+ items: bulk_items_to_wire(entry["items"])
974
+ )
975
+ end
976
+ SmplkitGeneratedClient::Config::ConfigBulkRequest.new(configs: items)
977
+ end
978
+
979
+ def bulk_items_to_wire(items_hash)
980
+ return nil if items_hash.nil? || items_hash.empty?
981
+
982
+ items_hash.transform_values do |def_hash|
983
+ SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
984
+ value: def_hash["value"],
985
+ type: def_hash["type"],
986
+ description: def_hash["description"]
987
+ )
554
988
  end
555
- fire_change_listeners(old_cache, new_cache, source: source)
556
989
  end
557
990
  end
558
991
  end
992
+
993
+ ConfigClient = Config::ConfigClient
559
994
  end