Class: Smplkit::Config::ConfigClient

Inherits:
Object
  • Object
show all
Defined in:
lib/smplkit/config/client.rb

Overview

The Smpl Config client (sync).

One client exposes the full surface, reachable as client.config (Smplkit::Client) or constructed directly:

config = Smplkit::ConfigClient.new(environment: "production")
billing = config.new("billing", name: "Billing")
billing.set_number("max_seats", 50)
billing.save
proxy = config.subscribe("billing")
puts proxy["max_seats"]

The management surface (new / get / list / delete and discovery) is pure CRUD. The live surface (subscribe / get_value / bind / on_change / refresh) connects lazily on first use — the first call flushes discovery, fetches and resolves all configs into the local cache, and opens the live-updates WebSocket. No explicit install step is required.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(api_key = nil, environment: nil, base_url: nil, profile: nil, base_domain: nil, scheme: nil, debug: nil, extra_headers: nil, parent: nil, transport: nil, metrics: nil) ⇒ ConfigClient

Returns a new instance of ConfigClient.



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/smplkit/config/client.rb', line 293

def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
               base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
               parent: nil, transport: nil, metrics: nil)
  @parent = parent
  @metrics = metrics
  @environment = parent.nil? ? environment : parent._environment
  @service = parent&._service
  @standalone_api_key = nil
  if transport.nil?
    @http, @app_base_url, @standalone_api_key = Smplkit::Config.config_transport(
      api_key: api_key, base_url: base_url, profile: profile,
      base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
    )
    @owns_transport = true
  else
    @http = transport
    @app_base_url = nil
    @owns_transport = false
  end
  @api = SmplkitGeneratedClient::Config::ConfigsApi.new(@http)

  # Discovery buffer is owned by this client (no management delegation).
  @buffer = ConfigRegistrationBuffer.new

  # Live-surface state.
  @config_cache = {}      # config_id -> { item_key => resolved_value }
  @raw_config_store = {}  # config_id -> Config
  @proxies = {}           # config_id -> LiveConfigProxy
  @bindings = {}          # config_id -> Hash | Struct (bound target)
  # Parent config id each binding was bound under (nil for roots) —
  # drives in-memory cache seeding through the bound parent chain.
  @bound_parents = {}
  @connected = false
  @lock = Mutex.new
  @listeners = [] # [callback, config_id_or_nil, item_key_or_nil]
  @ws_manager = nil
  @owns_ws = false
end

Class Method Details

.open(**kwargs) ⇒ Object

Construct, yield to the block, and close on exit.



578
579
580
581
582
583
584
585
# File 'lib/smplkit/config/client.rb', line 578

def self.open(**kwargs)
  client = new(**kwargs)
  begin
    yield client
  ensure
    client.close
  end
end

Instance Method Details

#_cached_values(config_id) ⇒ Object

Internal: return (a copy of) the resolved values for a config id. Used by LiveConfigProxy.



589
590
591
# File 'lib/smplkit/config/client.rb', line 589

def _cached_values(config_id)
  @lock.synchronize { (@config_cache[config_id] || {}).dup }
end

#_create_config(config) ⇒ Object



374
375
376
377
# File 'lib/smplkit/config/client.rb', line 374

def _create_config(config)
  response = ApiSupport::ErrorMapping.call { @api.create_config(config_body(config)) }
  Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
end

#_ensure_connectedObject

Internal: trigger lazy connect. Used by Client#wait_until_ready.



594
595
596
# File 'lib/smplkit/config/client.rb', line 594

def _ensure_connected
  ensure_connected
end

#_update_config(config) ⇒ Object



379
380
381
382
# File 'lib/smplkit/config/client.rb', line 379

def _update_config(config)
  response = ApiSupport::ErrorMapping.call { @api.update_config(config.key, config_body(config)) }
  Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
end

#bind(id, config, parent: nil) ⇒ Object

Bind a Hash or Struct to a config id; return the same object back, live.

Declarative, code-first API. Two flavors:

  • Hash: keys present are leaves to register, with their values as the in-code defaults. Nested Hashes flatten to dot-notation. Keys the caller wants to inherit from parent: are simply omitted.

  • Struct: every member is registered as an explicit override. Ruby Structs do not track which members were “explicitly set” vs defaulted, so there is no Hash-style omit-to-inherit. For omit-to-inherit, use a Hash target.

On first boot the schema and values are registered with the server. The local cache is then seeded so reads work immediately: if the config already exists server-side (fetched on connect) its values are authoritative and synced onto the bound object; if it is brand-new, the cache entry is seeded in-memory from the bound object’s values resolved through its bound parent chain (no network round-trip). On every WebSocket-delivered change thereafter the bound object is mutated in place. Readers always see the current resolved value with no proxy indirection.

Idempotent. Repeated calls with the same id return the originally-bound object; the new config argument is ignored.

Connects lazily on first use — no explicit install step.



455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
# File 'lib/smplkit/config/client.rb', line 455

def bind(id, config, parent: nil)
  ensure_connected
  unless config.is_a?(Hash) || config.is_a?(Struct)
    raise TypeError, "bind() requires a Hash or Struct; got #{config.class.name}"
  end

  return @bindings[id] if @bindings.key?(id)

  parent_id = register_binding_declaration(id, config, parent)

  # Register the binding BEFORE syncing so WebSocket dispatch finds it.
  @bindings[id] = config
  @bound_parents[id] = parent_id
  seed_or_sync_binding(id, config)
  config
end

#closeObject Also known as: _close

Release resources — only those this client owns.

Tears down the owned WebSocket (opened by a standalone client on first live use) and the owned HTTP transport (standalone construction). A wired client borrows the parent’s transport and WebSocket and closes neither.



567
568
569
570
571
572
573
574
# File 'lib/smplkit/config/client.rb', line 567

def close
  if @owns_ws && @ws_manager
    @ws_manager.stop
    @ws_manager = nil
    @owns_ws = false
  end
  nil
end

#delete(id) ⇒ Object

Delete a config by id.



369
370
371
372
# File 'lib/smplkit/config/client.rb', line 369

def delete(id)
  ApiSupport::ErrorMapping.call { @api.delete_config(id) }
  nil
end

#flushObject

POST pending declarations to /api/v1/configs/bulk.

Per ADR-024 §2.9, bulk registration always lands rows as managed=false and is plan-limit-exempt — failures here never propagate to customer code. Drained entries are not requeued; the SDK will re-observe on the next process start.



407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/smplkit/config/client.rb', line 407

def flush
  batch = @buffer.drain
  return if batch.empty?

  body = build_config_bulk_request(batch)
  begin
    ApiSupport::ErrorMapping.call { @api.bulk_register_configs(body) }
  rescue StandardError => e
    # Fire-and-forget per ADR-024 §2.9.
    Smplkit.debug("registration", "config bulk register failed: #{e.class}: #{e.message}")
  end
end

#get(id) ⇒ Object

Fetch the editable Config resource by id.

Raises NotFoundError if no config with that id exists.



354
355
356
357
# File 'lib/smplkit/config/client.rb', line 354

def get(id)
  response = ApiSupport::ErrorMapping.call { @api.get_config(id) }
  Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
end

#get_value(id, key, default = MISSING) ⇒ Object

Read a single resolved config value (inheritance-aware).

The value comes from the locally-cached resolved chain, so parent configs are already folded in.

Two forms:

  • get_value(id, key) returns the resolved value. Raises NotFoundError if the config is unknown and KeyError if the key is absent.

  • get_value(id, key, default) returns the resolved value, falling back to default if the config or key is missing. Never raises. Registers the config (if new) and the key (inferred type, default as default) for code-first observability, so the reference appears in the smplkit console.

For a live dict-like view use #subscribe; for typed access via a Struct schema use #bind. Connects lazily on first use — no explicit install step.



509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
# File 'lib/smplkit/config/client.rb', line 509

def get_value(id, key, default = MISSING)
  ensure_connected
  key = key.to_s
  has_default = !default.equal?(MISSING)
  if has_default
    # Register the config + key so the reference shows up in the console
    # even if it's never been declared via bind(). The buffer is
    # idempotent at the (config_id, item_key) level.
    observe_config_declaration(id, parent: nil, name: nil, description: nil)
    observe_item_declaration(id, key, Discovery.value_to_item_type(default), default, nil)
  end

  values = @lock.synchronize { @config_cache[id]&.dup }
  if values.nil?
    return default if has_default

    raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
  end
  unless values.key?(key)
    return default if has_default

    raise KeyError, "Config item '#{key}' not found in config '#{id}'"
  end
  values[key]
end

#list(page_number: nil, page_size: nil) ⇒ Object

List configs for the authenticated account.



360
361
362
363
364
365
366
# File 'lib/smplkit/config/client.rb', line 360

def list(page_number: nil, page_size: nil)
  opts = {}
  opts[:page_number] = page_number unless page_number.nil?
  opts[:page_size] = page_size unless page_size.nil?
  response = ApiSupport::ErrorMapping.call { @api.list_configs(opts) }
  (response.data || []).map { |r| Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(r)) }
end

#new(id, name: nil, description: nil, parent: nil) ⇒ Object

Return a new unsaved Config. Call Config#save to persist.

parent accepts either a config id (string) or an existing Config instance — passing the instance lets you skip naming the id explicitly when you already have the parent in scope.



341
342
343
344
345
346
347
348
349
# File 'lib/smplkit/config/client.rb', line 341

def new(id, name: nil, description: nil, parent: nil)
  Config.new(
    self,
    key: id,
    name: name || Smplkit::Helpers.key_to_display_name(id),
    description: description,
    parent_id: Smplkit::Config.resolve_parent_id(parent)
  )
end

#on_change(config_id = nil, item_key: nil, &block) ⇒ Object

Register a change listener.

Three forms:

client.config.on_change { |event| ... }                          # global
client.config.on_change("id") { |event| ... }                    # config-scoped
client.config.on_change("id", item_key: "key") { |event| ... }   # item-scoped

Connects lazily on first use — no explicit install step.

Raises:

  • (ArgumentError)


544
545
546
547
548
549
550
# File 'lib/smplkit/config/client.rb', line 544

def on_change(config_id = nil, item_key: nil, &block)
  ensure_connected
  raise ArgumentError, "on_change requires a block" unless block

  @listeners << [block, config_id, item_key&.to_s]
  block
end

#pending_countObject

Number of pending config declarations awaiting flush.



421
422
423
# File 'lib/smplkit/config/client.rb', line 421

def pending_count
  @buffer.pending_count
end

#refreshObject

Re-fetch all configs and update resolved values, firing change listeners for anything that differs from the previous state.

Connects lazily on first use — no explicit install step.



556
557
558
559
# File 'lib/smplkit/config/client.rb', line 556

def refresh
  ensure_connected
  do_refresh("manual")
end

#register_config(config_id, service:, environment:, parent: nil, name: nil, description: nil) ⇒ Object

Queue a configuration declaration for bulk-discovery upload.



389
390
391
392
393
# File 'lib/smplkit/config/client.rb', line 389

def register_config(config_id, service:, environment:, parent: nil, name: nil, description: nil)
  @buffer.declare(config_id, service: service, environment: environment,
                             parent: parent, name: name, description: description)
  trigger_background_flush_if_needed
end

#register_config_item(config_id, item_key, item_type, default, description = nil) ⇒ Object

Queue a config item declaration. register_config must run first.



396
397
398
399
# File 'lib/smplkit/config/client.rb', line 396

def register_config_item(config_id, item_key, item_type, default, description = nil)
  @buffer.add_item(config_id, item_key, item_type, default, description)
  trigger_background_flush_if_needed
end

#subscribe(id) ⇒ Object

Return a live, dict-like LiveConfigProxy for a config id.

The proxy always reflects the latest resolved values; reads happen through it (+proxy, proxy.get(“key”, default)+). Subscribing registers the config declaration for code-first observability so the reference appears in the smplkit console.

Connects lazily on first use — no explicit install step. Raises NotFoundError if the config is unknown.



481
482
483
484
485
486
487
488
489
# File 'lib/smplkit/config/client.rb', line 481

def subscribe(id)
  ensure_connected
  observe_config_declaration(id, parent: nil, name: nil, description: nil)
  in_cache = @lock.synchronize { @config_cache.key?(id) }
  raise Smplkit::NotFoundError, "Config with id '#{id}' not found" unless in_cache

  @metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
  cached_proxy(id)
end