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 CRUD 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.

Parameters:

  • api_key (String, nil) (defaults to: nil)

    API key. When omitted, resolved from SMPLKIT_API_KEY or ~/.smplkit.

  • environment (String, nil) (defaults to: nil)

    Deployment environment used to resolve runtime config values and to scope discovery declarations.

  • base_url (String, nil) (defaults to: nil)

    Full config-service base URL. Usually resolved from base_domain/scheme; supplied directly by the top-level clients which have already computed it.

  • profile (String, nil) (defaults to: nil)

    Named ~/.smplkit profile section.

  • base_domain (String, nil) (defaults to: nil)

    Base domain for API requests (default “smplkit.com”).

  • scheme (String, nil) (defaults to: nil)

    URL scheme (default “https”).

  • debug (Boolean, nil) (defaults to: nil)

    Enable SDK debug logging.

  • extra_headers (Hash{String => String}, nil) (defaults to: nil)

    Extra headers attached to every request.

  • parent (Smplkit::Client, nil) (defaults to: nil)

    Internal — the owning client. Not for direct use.

  • transport (Object, nil) (defaults to: nil)

    Internal — a pre-built config transport supplied by a top-level client so the config surface shares one connection pool. Not for direct use.

  • metrics (Object, nil) (defaults to: nil)

    Internal — the parent’s metrics reporter.



416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
# File 'lib/smplkit/config/client.rb', line 416

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) {|client| ... } ⇒ Object

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

Parameters:

  • kwargs (Hash)

    Keyword arguments forwarded to #initialize.

Yield Parameters:

  • client (ConfigClient)

    The constructed client, closed when the block returns.

Returns:

  • (Object)

    The value returned by the block.



798
799
800
801
802
803
804
805
# File 'lib/smplkit/config/client.rb', line 798

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.



809
810
811
# File 'lib/smplkit/config/client.rb', line 809

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

#_create_config(config) ⇒ Object



519
520
521
522
# File 'lib/smplkit/config/client.rb', line 519

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.



814
815
816
# File 'lib/smplkit/config/client.rb', line 814

def _ensure_connected
  ensure_connected
end

#_update_config(config) ⇒ Object



524
525
526
527
# File 'lib/smplkit/config/client.rb', line 524

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) ⇒ Hash, Struct

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.

Parameters:

  • id (String)

    The config id to register under.

  • config (Hash, Struct)

    A populated Hash or Struct. Both supply the schema (via the keys or Struct members) and the in-code defaults.

  • parent (Hash, Struct, nil) (defaults to: nil)

    Optional parent — any object previously returned from a #bind call. Activates parent-chain inheritance for keys the caller omitted.

Returns:

  • (Hash, Struct)

    The same config object, registered and live.

Raises:

  • (TypeError)

    If config is neither a Hash nor a Struct.

  • (ArgumentError)

    If parent is provided but was not previously bound via #bind.



640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
# File 'lib/smplkit/config/client.rb', line 640

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

#closevoid Also known as: _close

This method returns an undefined value.

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.



782
783
784
785
786
787
788
789
# File 'lib/smplkit/config/client.rb', line 782

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

#delete(id) ⇒ void

This method returns an undefined value.

Delete a config by id.

Parameters:

  • id (String)

    The config identifier (slug) to delete.



514
515
516
517
# File 'lib/smplkit/config/client.rb', line 514

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

#flushvoid

This method returns an undefined value.

Send any queued config and item declarations to the server.

Discovery is best-effort — failures here never propagate to your code. Drained entries are not requeued; the SDK re-observes them on the next process start.



579
580
581
582
583
584
585
586
587
588
589
590
# File 'lib/smplkit/config/client.rb', line 579

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 — discovery failures never propagate to caller code.
    Smplkit.debug("registration", "config bulk register failed: #{e.class}: #{e.message}")
  end
end

#get(id) ⇒ Config

Fetch the editable Config resource by id.

Parameters:

  • id (String)

    The config identifier (slug) to fetch.

Returns:

  • (Config)

    The editable Config resource.

Raises:



489
490
491
492
# File 'lib/smplkit/config/client.rb', line 489

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.

Parameters:

  • id (String)

    The config identifier (slug) to read from.

  • key (String)

    The item key within the config.

  • default (Object) (defaults to: MISSING)

    Value returned when the config or key is missing. When omitted, a missing config or key raises instead of returning a fallback. Supplying a default also registers the config (if new) and the key — with its type inferred and default as its value — so the reference appears in the smplkit console.

Returns:

  • (Object)

    The resolved value. When default is supplied and the config or key is missing, returns default instead.

Raises:

  • (Smplkit::NotFoundError)

    If the config is unknown and no default was supplied.

  • (KeyError)

    If the key is absent and no default was supplied.



711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
# File 'lib/smplkit/config/client.rb', line 711

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) ⇒ Array<Config>

List configs for the authenticated account.

Parameters:

  • page_number (Integer, nil) (defaults to: nil)

    1-based page to fetch. When omitted, the server’s default first page is returned.

  • page_size (Integer, nil) (defaults to: nil)

    Number of configs per page. When omitted, the server’s default page size is used.

Returns:

  • (Array<Config>)

    The configs on the requested page, or an empty array if there are none.



502
503
504
505
506
507
508
# File 'lib/smplkit/config/client.rb', line 502

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) ⇒ Config

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.

Parameters:

  • id (String)

    The config identifier (slug) the resource will be saved under.

  • name (String, nil) (defaults to: nil)

    Display name. Defaults to a title-cased form of id.

  • description (String, nil) (defaults to: nil)

    Optional human-readable description.

  • parent (String, Config, nil) (defaults to: nil)

    Optional parent config to inherit values from, as a config id or an existing Config instance.

Returns:

  • (Config)

    A new, unsaved Config. Nothing is sent to the server until you call Config#save.



474
475
476
477
478
479
480
481
482
# File 'lib/smplkit/config/client.rb', line 474

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) {|event| ... } ⇒ Proc

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.

Parameters:

  • config_id (String, nil) (defaults to: nil)

    When given, restrict the listener to changes of this config. Omit for a global listener.

  • item_key (String, nil) (defaults to: nil)

    When config_id is given, restrict the listener to changes of this single item key.

Yield Parameters:

Returns:

  • (Proc)

    The registered block, unchanged.

Raises:

  • (ArgumentError)


754
755
756
757
758
759
760
# File 'lib/smplkit/config/client.rb', line 754

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_countInteger

Number of pending config declarations awaiting flush.

Returns:

  • (Integer)

    The count of buffered declarations not yet flushed.



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

def pending_count
  @buffer.pending_count
end

#refreshvoid

This method returns an undefined value.

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.

Raises:



769
770
771
772
# File 'lib/smplkit/config/client.rb', line 769

def refresh
  ensure_connected
  do_refresh("manual")
end

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

This method returns an undefined value.

Queue a configuration declaration for bulk-discovery upload.

The declaration is buffered and sent in the background; it surfaces the config in the smplkit console even if no values are set yet.

Parameters:

  • config_id (String)

    The config identifier (slug) being declared.

  • service (String, nil)

    Name of the service declaring the config, or nil.

  • environment (String, nil)

    Environment the declaration is scoped to, or nil.

  • parent (String, nil) (defaults to: nil)

    Optional parent config id this config inherits from.

  • name (String, nil) (defaults to: nil)

    Optional display name for the config.

  • description (String, nil) (defaults to: nil)

    Optional human-readable description.



548
549
550
551
552
# File 'lib/smplkit/config/client.rb', line 548

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) ⇒ void

This method returns an undefined value.

Queue a config item declaration. register_config must run first.

The declaration is buffered and sent in the background, surfacing the item (with its type and default) in the smplkit console.

Parameters:

  • config_id (String)

    The config identifier (slug) the item belongs to.

  • item_key (String)

    Key of the item within the config.

  • item_type (String)

    Item value type — one of “STRING”, “NUMBER”, “BOOLEAN”, or “JSON”.

  • default (Object)

    The in-code default value for the item.

  • description (String, nil) (defaults to: nil)

    Optional human-readable description.



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

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) ⇒ LiveConfigProxy

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.

Parameters:

  • id (String)

    The config identifier (slug) to subscribe to.

Returns:

  • (LiveConfigProxy)

    A live proxy whose reads always see the current resolved values.

Raises:



670
671
672
673
674
675
676
677
678
# File 'lib/smplkit/config/client.rb', line 670

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