Class: Smplkit::Config::ConfigClient

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

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, service: nil, base_url: nil, profile: nil, base_domain: nil, scheme: nil, debug: nil, extra_headers: nil, streaming: true, 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. When omitted, resolved from SMPLKIT_ENVIRONMENT or ~/.smplkit.

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

    Service name attached to discovery declarations. When omitted, resolved from SMPLKIT_SERVICE or ~/.smplkit. Optional.

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

  • streaming (Boolean) (defaults to: true)

    Live updates over WebSocket (default true): the first live call opens a shared socket and config changes stream in. Set false for the stateless read-through surface: the first live call still fetches and resolves every config once (blocking), reads stay local, refresh re-fetches on demand, and NO socket or background thread is ever created — the right shape for serverless and edge runtimes. on_change listeners fire only from explicit refresh calls in this mode (there is no stream to drive them).

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



436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'lib/smplkit/config/client.rb', line 436

def initialize(api_key = nil, environment: nil, service: nil, base_url: nil, profile: nil,
               base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
               streaming: true, parent: nil, transport: nil, metrics: nil)
  @parent = parent
  @metrics = metrics
  @streaming = streaming ? true : false
  @standalone_api_key = nil
  if transport.nil?
    # Standalone: resolve like Smplkit::Client — defaults → ~/.smplkit →
    # SMPLKIT_* env vars → constructor args — environment and service
    # included.
    @http, @app_base_url, @standalone_api_key, resolved_env, resolved_service =
      Smplkit::Config.config_transport(
        api_key: api_key, base_url: base_url, profile: profile,
        base_domain: base_domain, scheme: scheme, environment: environment,
        service: service, debug: debug, extra_headers: extra_headers
      )
    @environment = parent.nil? ? resolved_env : parent._environment
    @service = parent.nil? ? resolved_service : parent._service
    @owns_transport = true
  else
    @http = transport
    @app_base_url = nil
    @owns_transport = false
    # Wired: the parent has already resolved environment/service once —
    # its values win over both the raw kwargs and re-resolution.
    @environment = parent.nil? ? environment : parent._environment
    @service = parent.nil? ? service : parent._service
  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.



828
829
830
831
832
833
834
835
# File 'lib/smplkit/config/client.rb', line 828

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.



839
840
841
# File 'lib/smplkit/config/client.rb', line 839

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

#_create_config(config) ⇒ Object



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

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.



844
845
846
# File 'lib/smplkit/config/client.rb', line 844

def _ensure_connected
  ensure_connected
end

#_update_config(config) ⇒ Object



554
555
556
557
# File 'lib/smplkit/config/client.rb', line 554

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.



670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
# File 'lib/smplkit/config/client.rb', line 670

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.



812
813
814
815
816
817
818
819
# File 'lib/smplkit/config/client.rb', line 812

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.



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

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.



609
610
611
612
613
614
615
616
617
618
619
620
# File 'lib/smplkit/config/client.rb', line 609

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.

  • (String)
  • (Object, nil)

Returns:

  • (Config)

    The editable Config resource.

Raises:



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

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

#get_booleanBoolean

Parameters:

  • (String)
  • default: (Boolean, nil)
  • config: (String, nil)

Returns:

  • (Boolean)


67
# File 'sig/smplkit/config.rbs', line 67

def get_boolean: (String, ?default: bool?, ?config: String?) -> bool

#get_jsonObject

Parameters:

  • (String)
  • default: (Object)
  • config: (String, nil)

Returns:

  • (Object)


68
# File 'sig/smplkit/config.rbs', line 68

def get_json: (String, ?default: untyped, ?config: String?) -> untyped

#get_numberObject

Parameters:

  • (String)
  • default: (Object)
  • config: (String, nil)

Returns:

  • (Object)


66
# File 'sig/smplkit/config.rbs', line 66

def get_number: (String, ?default: untyped, ?config: String?) -> untyped

#get_stringString?

Parameters:

  • (String)
  • default: (String, nil)
  • config: (String, nil)

Returns:

  • (String, nil)


65
# File 'sig/smplkit/config.rbs', line 65

def get_string: (String, ?default: String?, ?config: String?) -> String?

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



741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
# File 'lib/smplkit/config/client.rb', line 741

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.



532
533
534
535
536
537
538
# File 'lib/smplkit/config/client.rb', line 532

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

#liveLiveConfigProxy

Parameters:

  • (String)

Returns:



69
# File 'sig/smplkit/config.rbs', line 69

def live: (String) -> LiveConfigProxy

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



504
505
506
507
508
509
510
511
512
# File 'lib/smplkit/config/client.rb', line 504

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, arg0| ... } ⇒ 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.

  • (String, nil)

Yields:

Yield Parameters:

Yield Returns:

  • (void)

Returns:

  • (Proc)

    The registered block, unchanged.

Raises:

  • (ArgumentError)


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

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.



625
626
627
# File 'lib/smplkit/config/client.rb', line 625

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:



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

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.



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

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.



597
598
599
600
# File 'lib/smplkit/config/client.rb', line 597

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

#startvoid

This method returns an undefined value.



63
# File 'sig/smplkit/config.rbs', line 63

def start: () -> void

#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:



700
701
702
703
704
705
706
707
708
# File 'lib/smplkit/config/client.rb', line 700

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