smplkit 3.0.51 → 3.0.52

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abd3ff899376151d83d35078832ffd68b505866329852fbb2952e39e2ba81efe
4
- data.tar.gz: f9ef8eb1a3610e17402041228fa4923e6fd81cb1b78bc6e3d2ddce691776730f
3
+ metadata.gz: 9a564f091d96cfc222d7e5035f55bd9ddf23e6d8baf30c790de18790712eb698
4
+ data.tar.gz: 151c871e8025acface481cd7acd7714486923e38a2ec9cc802cf0769886c4293
5
5
  SHA512:
6
- metadata.gz: aabca79289469eee4fa671daca08aef998c673f17720fc1b958bb85980601f55cb399b78a9a3bccf6a8814f3f04f7146c86c73f9763bd324dfa602dcb4f67c51
7
- data.tar.gz: ec51b4cd5aa4d5553c2f549931f5b8fc21348fb0f7675e7486089fd4457b1758fc21f6395a91af966cdf1e910822d229b286610a5bc310cfdcd8f1497bd380c5
6
+ metadata.gz: e501e7044027b6678d9dd01bd2b77f565fa9f8edc9d386678e73cee9fb50234882c88e98e2e13862938f2a6b5b4cce4fecbd6e79d848710f842bd37f2fc2fb4d
7
+ data.tar.gz: 738d7a3681708c49b28d45af2a747c798a48fa20acc1ce71782baa363259e0068e2f6bc34e891a6aadc5ea080f2b4133507c3a011f2e62d3935bdcf7a8d72cbb
data/README.md CHANGED
@@ -50,7 +50,7 @@ flag = manage.flags.new_boolean_flag(
50
50
  "checkout-v2", default: false, description: "Controls rollout"
51
51
  )
52
52
  flag.add_rule(
53
- Smplkit::Rule.new("Enable for enterprise users", environment: "staging")
53
+ Smplkit::Rule.new("Enable for enterprise users", environment: "production")
54
54
  .when("user.plan", Smplkit::Op::EQ, "enterprise")
55
55
  .serve(true)
56
56
  )
@@ -184,7 +184,6 @@ module Smplkit
184
184
  end
185
185
 
186
186
  def _flags_transport = @manage.flags
187
- def _config_transport = @manage.config
188
187
 
189
188
  private
190
189
 
@@ -2,142 +2,225 @@
2
2
 
3
3
  module Smplkit
4
4
  module Config
5
- # Describes a config change event delivered to +on_change+ listeners.
6
- class ConfigChangeEvent
7
- attr_reader :key, :source, :deleted
8
-
9
- def initialize(key:, source:, deleted: false)
10
- @key = key
11
- @source = source
12
- @deleted = deleted
13
- freeze
5
+ # Module-level helpers for the runtime config client. Extracted so they
6
+ # can be unit-tested without spinning up the full client.
7
+ module Discovery
8
+ module_function
9
+
10
+ # 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,
12
+ # default)+. +true+/+false+ are checked first because Ruby's
13
+ # +Numeric+/+Integer+ tests would not accidentally claim them.
14
+ def value_to_item_type(value)
15
+ case value
16
+ when true, false then "BOOLEAN"
17
+ when Numeric then "NUMBER"
18
+ else "STRING"
19
+ end
14
20
  end
15
21
 
16
- def deleted? = @deleted
17
-
18
- def ==(other)
19
- other.is_a?(ConfigChangeEvent) && key == other.key && source == other.source && deleted == other.deleted
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.
25
+ def iter_items(target, prefix: "")
26
+ if target.is_a?(Hash)
27
+ iter_hash_items(target, prefix: prefix)
28
+ elsif target.is_a?(Struct)
29
+ iter_struct_items(target, prefix: prefix)
30
+ else
31
+ []
32
+ end
20
33
  end
21
- alias eql? ==
22
-
23
- def hash = [key, source, deleted].hash
24
- end
25
34
 
26
- # A live, dot-accessible view over a resolved configuration.
27
- #
28
- # Identity-stable per config key (the same instance is returned by
29
- # repeat +client.config.get+ / +get_or_create+ calls). Every read goes
30
- # through the underlying client's resolved-config cache, so WebSocket
31
- # updates are picked up automatically — there is no +subscribe+ step.
32
- class LiveConfigProxy
33
- def initialize(client, key)
34
- @client = client
35
- @key = key
35
+ def iter_hash_items(hash, prefix: "")
36
+ out = []
37
+ hash.each do |raw_key, value|
38
+ flat_key = "#{prefix}#{raw_key}"
39
+ if value.is_a?(Hash) || value.is_a?(Struct)
40
+ out.concat(iter_items(value, prefix: "#{flat_key}."))
41
+ else
42
+ out << [flat_key, value_to_item_type(value), value, nil]
43
+ end
44
+ end
45
+ out
36
46
  end
37
47
 
38
- def config_id = @key
39
-
40
- def get(item_key, default = nil)
41
- snapshot = current_values
42
- return snapshot if item_key.nil?
48
+ def iter_struct_items(struct, prefix: "")
49
+ out = []
50
+ struct.members.each do |member|
51
+ value = struct[member]
52
+ flat_key = "#{prefix}#{member}"
53
+ if value.is_a?(Hash) || value.is_a?(Struct)
54
+ out.concat(iter_items(value, prefix: "#{flat_key}."))
55
+ else
56
+ out << [flat_key, value_to_item_type(value), value, nil]
57
+ end
58
+ end
59
+ out
60
+ end
61
+
62
+ # Apply a server-pushed value to a bound target in place. Walks the
63
+ # dotted key path to the leaf's parent and assigns the value via
64
+ # +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.
67
+ def apply_change_to_target(target, dotted_key, value)
68
+ parts = dotted_key.split(".")
69
+ current = walk_to_leaf_parent(target, parts[0..-2])
70
+ return if current.nil?
71
+
72
+ last = parts.last
73
+ if current.is_a?(Struct)
74
+ assign_struct_member(current, last, value)
75
+ elsif current.is_a?(Hash)
76
+ current[last] = value
77
+ end
78
+ end
43
79
 
44
- keys = item_key.to_s.split(".")
45
- keys.reduce(snapshot) do |scope, k|
46
- break default if scope.nil?
80
+ def walk_to_leaf_parent(target, parts)
81
+ current = target
82
+ parts.each do |part|
83
+ case current
84
+ when Struct
85
+ sym = part.to_sym
86
+ return nil unless current.members.include?(sym)
47
87
 
48
- scope.is_a?(Hash) ? scope[k] : default
49
- end || default
50
- end
88
+ current = current[sym]
89
+ when Hash
90
+ return nil unless current.key?(part)
51
91
 
52
- def [](item_key)
53
- get(item_key)
92
+ current = current[part]
93
+ else
94
+ return nil
95
+ end
96
+ end
97
+ current
54
98
  end
55
99
 
56
- def to_h
57
- current_values.dup
58
- end
100
+ def assign_struct_member(struct, name, value)
101
+ sym = name.to_sym
102
+ return unless struct.members.include?(sym)
59
103
 
60
- def refresh
61
- # The cache is fully invalidated for this key — the next read
62
- # re-resolves from the parent client.
63
- @client._invalidate(@key)
64
- self
104
+ struct[sym] = value
65
105
  end
106
+ end
66
107
 
67
- # ------------------------------------------------------------------
68
- # Typed getters (ADR-037 §2.13)
69
- #
70
- # Each registers the item (key, type, default, description) on first
71
- # call within the process, then returns the resolved value. When the
72
- # resolved value can't be coerced to the getter's type — including
73
- # the "not yet set on the server" case — the in-code default is
74
- # returned and a debug message is logged.
75
- # ------------------------------------------------------------------
108
+ # Describes a single config value change. Frozen — fields are set at
109
+ # construction and cannot be mutated afterward.
110
+ class ConfigChangeEvent
111
+ attr_reader :config_id, :item_key, :old_value, :new_value, :source
76
112
 
77
- def get_bool(item_key, default, description: nil)
78
- register_item(item_key, "BOOLEAN", default, description)
79
- value = current_values[item_key.to_s]
80
- return default unless value.is_a?(TrueClass) || value.is_a?(FalseClass)
113
+ def initialize(config_id:, item_key:, old_value:, new_value:, source:)
114
+ @config_id = config_id
115
+ @item_key = item_key
116
+ @old_value = old_value
117
+ @new_value = new_value
118
+ @source = source
119
+ freeze
120
+ end
81
121
 
82
- value
122
+ def ==(other)
123
+ other.is_a?(ConfigChangeEvent) &&
124
+ config_id == other.config_id && item_key == other.item_key &&
125
+ old_value == other.old_value && new_value == other.new_value &&
126
+ source == other.source
83
127
  end
128
+ alias eql? ==
84
129
 
85
- def get_int(item_key, default, description: nil)
86
- register_item(item_key, "NUMBER", default, description)
87
- value = current_values[item_key.to_s]
88
- return default if value.is_a?(TrueClass) || value.is_a?(FalseClass)
89
- return value if value.is_a?(Integer)
90
- return value.to_i if value.is_a?(Float) && value == value.floor
130
+ def hash = [config_id, item_key, old_value, new_value, source].hash
131
+ end
91
132
 
92
- default
133
+ # A live, read-only, dict-like view of a config's resolved values.
134
+ #
135
+ # Returned by +ConfigClient#get(id)+ (single-arg form). Always reflects
136
+ # the latest server-pushed state — every read sees current values.
137
+ #
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.
142
+ #
143
+ # For typed access via a Struct schema, use +ConfigClient#bind+ —
144
+ # bound objects stay live on the same cache, with no proxy indirection.
145
+ class LiveConfigProxy
146
+ # Methods that live on the proxy itself; never resolved against the
147
+ # cached values dictionary.
148
+ OWN_METHODS = %i[config_id keys values each_pair each items to_h size length
149
+ key? include? has_key? on_change get].freeze
150
+
151
+ def initialize(client, config_id)
152
+ @client = client
153
+ @config_id = config_id
93
154
  end
94
155
 
95
- def get_float(item_key, default, description: nil)
96
- register_item(item_key, "NUMBER", default, description)
97
- value = current_values[item_key.to_s]
98
- return default if value.is_a?(TrueClass) || value.is_a?(FalseClass)
99
- return value.to_f if value.is_a?(Numeric)
156
+ attr_reader :config_id
100
157
 
101
- default
102
- end
158
+ def keys = current_values.keys
159
+ def values = current_values.values
160
+ def each_pair(&) = current_values.each_pair(&)
161
+ alias each each_pair
162
+ def items = current_values.to_a
163
+ def to_h = current_values.dup
164
+ def size = current_values.size
165
+ alias length size
166
+ def key?(key) = current_values.key?(key.to_s)
167
+ alias include? key?
168
+ alias has_key? key?
103
169
 
104
- def get_string(item_key, default, description: nil)
105
- register_item(item_key, "STRING", default, description)
106
- value = current_values[item_key.to_s]
107
- value.is_a?(String) ? value : default
170
+ def [](key)
171
+ current_values[key.to_s]
108
172
  end
109
173
 
110
- def get_json(item_key, default, description: nil)
111
- register_item(item_key, "JSON", default, description)
112
- snap = current_values
113
- snap.key?(item_key.to_s) ? snap[item_key.to_s] : default
174
+ def get(key, default = nil)
175
+ values = current_values
176
+ values.key?(key.to_s) ? values[key.to_s] : default
114
177
  end
115
178
 
116
179
  def on_change(item_key = nil, &)
117
180
  if item_key.nil?
118
- @client.on_change(@key, &)
181
+ @client.on_change(@config_id, &)
119
182
  else
120
- @client.on_change_item(@key, item_key.to_s, &)
183
+ @client.on_change(@config_id, item_key: item_key.to_s, &)
121
184
  end
122
185
  end
123
186
 
124
- private
187
+ def respond_to_missing?(name, include_private = false)
188
+ return true if OWN_METHODS.include?(name)
125
189
 
126
- def current_values
127
- @client._resolve_now(@key)
190
+ current_values.key?(name.to_s) || super
128
191
  end
129
192
 
130
- def register_item(item_key, item_type, default, description)
131
- @client._observe_item_declaration(@key, item_key.to_s, item_type, default, description)
193
+ def method_missing(name, *args)
194
+ snapshot = current_values
195
+ key = name.to_s
196
+ return snapshot[key] if snapshot.key?(key) && args.empty?
197
+
198
+ super
199
+ end
200
+
201
+ def to_s = "#<Smplkit::Config::LiveConfigProxy config_id=#{@config_id.inspect}>"
202
+ alias inspect to_s
203
+
204
+ private
205
+
206
+ def current_values
207
+ @client._cached_values(@config_id)
132
208
  end
133
209
  end
134
210
 
135
- # Synchronous config runtime namespace.
211
+ # Synchronous runtime client for Smpl Config.
136
212
  #
137
- # Obtained via +Smplkit::Client#config+. Exposes typed accessors
138
- # (+get_string+, +get_number+, +get_boolean+, +get_json+) and runtime
139
- # control (+refresh+, +on_change+).
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+.
140
217
  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?+.
221
+ MISSING = Object.new.freeze
222
+ private_constant :MISSING
223
+
141
224
  def initialize(parent, manage:, metrics:)
142
225
  @parent = parent
143
226
  @manage = manage
@@ -145,137 +228,149 @@ module Smplkit
145
228
  @environment = parent._environment
146
229
  @service = parent._service
147
230
 
148
- @snapshots = {}
149
- @raw_chains = {}
150
- @proxies = {}
151
- @global_listeners = []
152
- @key_listeners = Hash.new { |h, k| h[k] = [] }
153
- @item_listeners = Hash.new { |h, k| h[k] = Hash.new { |hh, kk| hh[kk] = [] } }
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]
154
236
  @connected = false
155
237
  @lock = Mutex.new
238
+ @ws_manager = nil
156
239
  end
157
240
 
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.
246
+ #
247
+ # Idempotent — safe to call multiple times. Invoked automatically on
248
+ # the first +#get+ or +#bind+ call.
158
249
  def start
159
250
  return if @connected
160
251
 
161
252
  @environment = @parent._environment
162
253
 
163
- # Per ADR-037 §2.14: flush any buffered discovery declarations
164
- # BEFORE the lazy init touches the runtime so newly-declared
165
- # configs are visible to the very first +get+. The flush itself
166
- # swallows server/network failures.
167
- @manage&.config&.flush
254
+ # Per ADR-037 §2.14: flush pending discovery declarations BEFORE
255
+ # the initial fetch so newly-declared configs show up in the cache.
256
+ begin
257
+ @manage&.config&.flush
258
+ rescue StandardError => e
259
+ Smplkit.debug("config", "pre-start discovery flush failed: #{e.class}: #{e.message}")
260
+ end
261
+
262
+ do_refresh("initial")
263
+ @connected = true
168
264
 
169
265
  @ws_manager = @parent._ensure_ws
170
266
  @ws_manager.on("config_changed") { |data| handle_config_changed(data) }
171
267
  @ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
172
- @connected = true
268
+ @ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
173
269
  end
174
270
 
175
- def get(config_key, model_class = nil)
176
- start unless @connected
271
+ # Bind a Hash or Struct to a config id; return the same object back, live.
272
+ #
273
+ # Declarative, code-first API. Two flavors:
274
+ #
275
+ # * +Hash+: keys present are leaves to register, with their values as
276
+ # the in-code defaults. Nested Hashes flatten to dot-notation. Keys
277
+ # the caller wants to inherit from +parent:+ are simply omitted.
278
+ # * +Struct+: every member is registered as an explicit override.
279
+ # Ruby Structs do not track which members were "explicitly set" vs
280
+ # defaulted, so there is no Hash-style omit-to-inherit. For
281
+ # omit-to-inherit, use a Hash target.
282
+ #
283
+ # On first call the schema and values are registered with the server.
284
+ # After the local cache is populated, any server-side overrides for
285
+ # this config are applied to the bound object in place. WebSocket
286
+ # events thereafter mutate the bound object in place — readers always
287
+ # see the current resolved value with no indirection.
288
+ #
289
+ # Idempotent. Repeat calls with the same +id+ return the
290
+ # 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}"
294
+ end
177
295
 
178
- snapshot = resolve(config_key)
179
- raise Smplkit::NotFoundError, "Config #{config_key.inspect} not found" if snapshot.nil?
180
- return snapshot if model_class.nil?
296
+ return @bindings[id] if @bindings.key?(id)
181
297
 
182
- model_class.new(snapshot)
183
- end
298
+ parent_id = resolve_parent_id(parent)
184
299
 
185
- # Declare a configuration from code; return a live, dict-like view.
186
- #
187
- # Idempotent — repeat calls with the same +id+ return the same
188
- # +LiveConfigProxy+ instance. The first call queues a discovery
189
- # payload (the config and any items declared via typed getters on
190
- # the returned handle) for upload to +POST /api/v1/configs/bulk+ on
191
- # next flush. Unlike +#get+, this method does not raise +NotFoundError+
192
- # when the id is absent — discovery handles that case.
193
- def get_or_create(config_id, parent: nil, name: nil, description: nil)
194
- parent_id =
195
- case parent
196
- when nil then nil
197
- when String then parent
198
- when LiveConfigProxy then parent.config_id
199
- else
200
- raise ArgumentError,
201
- "parent must be a String id or LiveConfigProxy; got #{parent.class.name}"
202
- end
203
- _observe_config_declaration(config_id, parent: parent_id, name: name, description: description)
204
- start unless @connected
205
- cached_proxy(config_id)
206
- end
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
207
306
 
208
- def get_string(item_key, default: nil, config: nil)
209
- typed_get(item_key, default, config) { |v| v.is_a?(String) ? v : v.to_s }
210
- end
307
+ _observe_config_declaration(id, parent: parent_id, name: config_name, description: nil)
211
308
 
212
- def get_number(item_key, default: nil, config: nil)
213
- typed_get(item_key, default, config) do |v|
214
- v.is_a?(Numeric) ? v : Float(v)
215
- rescue StandardError
216
- default
309
+ Discovery.iter_items(target).each do |item_key, item_type, value, description|
310
+ _observe_item_declaration(id, item_key, item_type, value, description)
217
311
  end
218
- end
219
312
 
220
- def get_boolean(item_key, default: nil, config: nil)
221
- typed_get(item_key, default, config) { |v| !!v }
222
- end
313
+ # Register the binding BEFORE start() so any WS dispatch that fires
314
+ # during the initial fetch finds it.
315
+ @bindings[id] = target
223
316
 
224
- def get_json(item_key, default: nil, config: nil)
225
- typed_get(item_key, default, config) { |v| v }
317
+ start unless @connected
318
+ sync_target_from_cache(target, id)
319
+ target
226
320
  end
227
321
 
228
- def live(config_key)
322
+ # Read a config (full) or a single value within a config.
323
+ #
324
+ # Three forms dispatched by argument count:
325
+ #
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)
229
330
  start unless @connected
230
331
 
231
- cached_proxy(config_key)
232
- end
233
-
234
- def on_change(config_key = nil, &block)
235
- raise ArgumentError, "on_change requires a block" unless block
332
+ return get_full_config(id) if key.equal?(MISSING)
236
333
 
237
- if config_key.nil?
238
- @global_listeners << block
239
- else
240
- @key_listeners[config_key] << block
241
- end
242
- block
334
+ get_single_value(id, key.to_s, default)
243
335
  end
244
336
 
245
- def on_change_item(config_key, item_key, &block)
246
- raise ArgumentError, "on_change_item requires a block" unless block
337
+ # Register a change listener.
338
+ #
339
+ # Three forms:
340
+ #
341
+ # client.config.on_change { |event| ... } # global
342
+ # client.config.on_change("id") { |event| ... } # config-scoped
343
+ # client.config.on_change("id", item_key: "key") { |event| ... } # item-scoped
344
+ def on_change(config_id = nil, item_key: nil, &block)
345
+ raise ArgumentError, "on_change requires a block" unless block
247
346
 
248
- @item_listeners[config_key][item_key.to_s] << block
347
+ @listeners << [block, config_id, item_key&.to_s]
249
348
  block
250
349
  end
251
350
 
351
+ # Re-fetch all configs and update resolved values, firing change
352
+ # listeners for anything that differs from the previous state.
252
353
  def refresh
253
- @lock.synchronize do
254
- @snapshots.clear
255
- @raw_chains.clear
256
- end
257
- fire_change_listeners_all("manual")
258
- end
259
-
260
- def _resolve_now(config_key)
261
- resolve(config_key) || {}
354
+ start unless @connected
355
+ do_refresh("manual")
262
356
  end
263
357
 
264
358
  def _close
265
- # No durable resources; symmetry stub.
359
+ # No durable resources owned by this sub-client; the parent client
360
+ # tears down the WebSocket and management transports.
266
361
  end
267
362
 
268
- # Discard cached state for +config_key+; the next resolve will refetch.
269
- def _invalidate(config_key)
363
+ # Internal: return (a copy of) the resolved values for a config id.
364
+ # Used by +LiveConfigProxy+.
365
+ def _cached_values(config_id)
270
366
  @lock.synchronize do
271
- @snapshots.delete(config_key)
272
- @raw_chains.delete(config_key)
367
+ (@config_cache[config_id] || {}).dup
273
368
  end
274
369
  end
275
370
 
276
371
  # Internal: queue a config declaration with the management buffer.
277
372
  def _observe_config_declaration(config_id, parent:, name:, description:)
278
- @manage.config.register_config(
373
+ @manage&.config&.register_config(
279
374
  config_id,
280
375
  service: @service,
281
376
  environment: @environment,
@@ -287,123 +382,177 @@ module Smplkit
287
382
 
288
383
  # Internal: queue a config item declaration with the management buffer.
289
384
  def _observe_item_declaration(config_id, item_key, item_type, default, description)
290
- @manage.config.register_config_item(config_id, item_key, item_type, default, description)
385
+ @manage&.config&.register_config_item(config_id, item_key, item_type, default, description)
291
386
  end
292
387
 
293
388
  private
294
389
 
295
- def cached_proxy(config_key)
390
+ def resolve_parent_id(parent)
391
+ return nil if parent.nil?
392
+
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
398
+
399
+ def get_full_config(id)
296
400
  @lock.synchronize do
297
- @proxies[config_key] ||= LiveConfigProxy.new(self, config_key)
401
+ raise Smplkit::NotFoundError, "Config with id '#{id}' not found" unless @config_cache.key?(id)
298
402
  end
403
+ @metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
404
+ cached_proxy(id)
299
405
  end
300
406
 
301
- def typed_get(item_key, default, config_key)
302
- snapshot = config_key ? resolve(config_key) : merged_snapshot
303
- key = item_key.to_s
304
- # Items live under flat dotted keys (e.g. +"api.host"+ — not nested
305
- # +api → host+). Match Python's +current_values.get(key)+ behavior.
306
- value = snapshot[key]
307
- if value.nil? && snapshot.is_a?(Hash)
308
- # Fallback: support callers that constructed an explicitly-nested
309
- # snapshot (rare — typed model bindings only).
310
- parts = key.split(".")
311
- if parts.length > 1
312
- value = parts.reduce(snapshot) do |scope, k|
313
- break nil unless scope.is_a?(Hash)
314
-
315
- scope[k]
316
- end
317
- end
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)
318
412
  end
319
- return default if value.nil?
320
413
 
321
- block_given? ? yield(value) : value
414
+ values = @lock.synchronize { @config_cache[id]&.dup }
415
+ if values.nil?
416
+ return default if has_default
417
+
418
+ raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
419
+ end
420
+ unless values.key?(key)
421
+ return default if has_default
422
+
423
+ raise KeyError, "Config item '#{key}' not found in config '#{id}'"
424
+ end
425
+ values[key]
322
426
  end
323
427
 
324
- def merged_snapshot
428
+ def cached_proxy(config_id)
325
429
  @lock.synchronize do
326
- @snapshots.values.reduce({}) { |acc, snap| Helpers.deep_merge(acc, snap) }
430
+ @proxies[config_id] ||= LiveConfigProxy.new(self, config_id)
327
431
  end
328
432
  end
329
433
 
330
- def resolve(config_key)
434
+ def sync_target_from_cache(target, config_id)
435
+ cache = @lock.synchronize { (@config_cache[config_id] || {}).dup }
436
+ cache.each do |dotted_key, value|
437
+ Discovery.apply_change_to_target(target, dotted_key, value)
438
+ end
439
+ end
440
+
441
+ def do_refresh(source)
442
+ configs = @manage.config.list
443
+ new_cache, new_store = resolve_all(configs)
444
+ old_cache = nil
331
445
  @lock.synchronize do
332
- return @snapshots[config_key].dup if @snapshots.key?(config_key)
446
+ old_cache = @config_cache
447
+ @config_cache = new_cache
448
+ @raw_config_store = new_store
333
449
  end
450
+ fire_change_listeners(old_cache, new_cache, source: source)
451
+ end
334
452
 
335
- chain = fetch_chain(config_key)
336
- # An empty chain means the config does not exist on the server.
337
- # Callers that hit +get(key)+ must raise +NotFoundError+; callers
338
- # that hold a +LiveConfigProxy+ get an empty Hash from
339
- # +_resolve_now+ so typed getters fall back to defaults.
340
- return nil if chain.nil? || chain.empty?
453
+ def resolve_all(configs)
454
+ by_id = configs.to_h { |c| [c.id, c] }
455
+ new_cache = {}
456
+ new_store = {}
457
+ configs.each do |cfg|
458
+ chain = Helpers.build_chain(cfg, by_id)
459
+ new_cache[cfg.key] = Helpers.resolve_chain(chain, @environment)
460
+ new_store[cfg.key] = cfg
461
+ end
462
+ [new_cache, new_store]
463
+ end
341
464
 
342
- snapshot = Helpers.resolve_chain(chain, @environment)
343
- @lock.synchronize do
344
- @raw_chains[config_key] = chain
345
- @snapshots[config_key] = snapshot
465
+ def fire_change_listeners(old_cache, new_cache, source:)
466
+ all_config_ids = old_cache.keys | new_cache.keys
467
+ all_config_ids.each do |cfg_id|
468
+ old_items = old_cache[cfg_id] || {}
469
+ new_items = new_cache[cfg_id] || {}
470
+ target = @bindings[cfg_id]
471
+ fire_config_changes(cfg_id, old_items, new_items, target, source)
346
472
  end
347
- snapshot.dup
348
473
  end
349
474
 
350
- def fetch_chain(config_key)
351
- # Stub: in the absence of a generated client, the runtime returns an
352
- # empty chain. ManagementClient wires this up properly once the
353
- # generated layer is committed.
354
- @parent._config_transport.fetch_chain(config_key)
355
- rescue Smplkit::Error
356
- raise
357
- rescue StandardError => e
358
- raise Smplkit::ConnectionError, "Failed to fetch config #{config_key.inspect}: #{e.message}"
475
+ def fire_config_changes(cfg_id, old_items, new_items, target, source)
476
+ all_keys = old_items.keys | new_items.keys
477
+ all_keys.each do |i_key|
478
+ old_val = old_items[i_key]
479
+ new_val = new_items[i_key]
480
+ next if old_val == new_val
481
+
482
+ Discovery.apply_change_to_target(target, i_key, new_val) unless target.nil?
483
+ @metrics&.record("config.changes", unit: "changes", dimensions: { "config" => cfg_id })
484
+ event = ConfigChangeEvent.new(
485
+ config_id: cfg_id, item_key: i_key,
486
+ old_value: old_val, new_value: new_val, source: source
487
+ )
488
+ dispatch_event(event, cfg_id, i_key)
489
+ end
490
+ end
491
+
492
+ def dispatch_event(event, cfg_id, i_key)
493
+ @listeners.each do |callback, ck_filter, ik_filter|
494
+ next if !ck_filter.nil? && ck_filter != cfg_id
495
+ next if !ik_filter.nil? && ik_filter != i_key
496
+
497
+ begin
498
+ callback.call(event)
499
+ rescue StandardError => e
500
+ Smplkit.debug("config", "on_change listener raised: #{e.class}: #{e.message}")
501
+ end
502
+ end
359
503
  end
360
504
 
361
505
  def handle_config_changed(data)
362
506
  key = data["key"] || data["id"]
363
507
  return unless key
364
508
 
509
+ begin
510
+ cfg = @manage.config.get(key)
511
+ rescue Smplkit::NotFoundError
512
+ # Treat as a deletion — the resource is gone.
513
+ handle_config_deleted(data)
514
+ return
515
+ rescue StandardError => e
516
+ Smplkit.debug("config", "failed to fetch config #{key.inspect}: #{e.class}: #{e.message}")
517
+ return
518
+ end
519
+
520
+ new_store = nil
365
521
  @lock.synchronize do
366
- @snapshots.delete(key)
367
- @raw_chains.delete(key)
522
+ new_store = @raw_config_store.dup
523
+ new_store[key] = cfg
368
524
  end
369
- fire_change_listeners(key, "websocket")
525
+ rebuild_from_store(new_store, source: "websocket")
370
526
  end
371
527
 
372
528
  def handle_config_deleted(data)
373
529
  key = data["key"] || data["id"]
374
530
  return unless key
375
531
 
532
+ new_store = nil
376
533
  @lock.synchronize do
377
- @snapshots.delete(key)
378
- @raw_chains.delete(key)
534
+ new_store = @raw_config_store.dup
535
+ return unless new_store.delete(key)
379
536
  end
380
- fire_change_listeners(key, "websocket", deleted: true)
537
+ rebuild_from_store(new_store, source: "websocket")
381
538
  end
382
539
 
383
- def fire_change_listeners(config_key, source, deleted: false)
384
- event = ConfigChangeEvent.new(key: config_key, source: source, deleted: deleted)
385
- (@global_listeners + @key_listeners[config_key]).each do |cb|
386
- cb.call(event)
387
- rescue StandardError => e
388
- Smplkit.debug("config", "listener raised: #{e.class}: #{e.message}")
389
- end
390
- # Item-scoped listeners — fire for every registered item on the
391
- # changed config. We don't diff old vs new values here because
392
- # the Ruby cache is invalidated wholesale per config; item-scoped
393
- # listeners on this SDK fire on the "any change to this config"
394
- # signal, mirroring the +LiveConfigProxy.on_change(item_key)+
395
- # contract.
396
- @item_listeners[config_key].each_value do |listeners|
397
- listeners.each do |cb|
398
- cb.call(event)
399
- rescue StandardError => e
400
- Smplkit.debug("config", "item listener raised: #{e.class}: #{e.message}")
401
- end
402
- end
540
+ def handle_configs_changed(_data)
541
+ do_refresh("websocket")
542
+ rescue StandardError => e
543
+ Smplkit.debug("config", "configs_changed refresh failed: #{e.class}: #{e.message}")
403
544
  end
404
545
 
405
- def fire_change_listeners_all(source)
406
- (@snapshots.keys | @key_listeners.keys).each { |key| fire_change_listeners(key, source) }
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
554
+ end
555
+ fire_change_listeners(old_cache, new_cache, source: source)
407
556
  end
408
557
  end
409
558
  end
@@ -61,6 +61,38 @@ module Smplkit
61
61
  end
62
62
  end
63
63
 
64
+ # Build the parent chain (child-first, root-last) for a +Config+,
65
+ # walking +parent_id+ pointers across the +by_id+ map. Mirrors the
66
+ # Python SDK's client-side chain construction.
67
+ def build_chain(target, by_id)
68
+ chain = []
69
+ current = target
70
+ loop do
71
+ chain << config_to_chain_entry(current)
72
+ parent_id = current.parent_id
73
+ break if parent_id.nil? || parent_id == ""
74
+
75
+ parent = by_id[parent_id]
76
+ break unless parent
77
+
78
+ current = parent
79
+ end
80
+ chain
81
+ end
82
+
83
+ # Build a single chain entry (the +id+/+items+/+environments+ Hash
84
+ # shape used by +resolve_chain+) from a +Config+ domain model.
85
+ def config_to_chain_entry(config)
86
+ items_hash = config.items.to_h do |item|
87
+ [item.name,
88
+ { "value" => item.value, "type" => item.type, "description" => item.description }.compact]
89
+ end
90
+ environments = config.environments.each_with_object({}) do |(env_key, env_obj), out|
91
+ out[env_key] = { "values" => env_obj.values_raw }
92
+ end
93
+ { "id" => config.id, "items" => items_hash, "environments" => environments }
94
+ end
95
+
64
96
  # Resolve the full configuration for an environment given a config chain.
65
97
  #
66
98
  # Walks from root (last element) to child (first element), accumulating
@@ -4,22 +4,32 @@ require "json"
4
4
 
5
5
  module Smplkit
6
6
  # A single error object from the server's JSON:API +errors+ array.
7
+ #
8
+ # +code+ is the application-specific machine-readable error code (e.g.
9
+ # +environment_unmanaged+); per JSON:API §7 and ADR-014, smplkit sets
10
+ # this on every error so callers can branch without string-matching
11
+ # the human +detail+. +meta+ carries additional structured context
12
+ # (e.g. <tt>{"environment" => "staging"}</tt>).
7
13
  class ApiErrorDetail
8
- attr_reader :status, :title, :detail, :source
14
+ attr_reader :status, :code, :title, :detail, :source, :meta
9
15
 
10
- def initialize(status: nil, title: nil, detail: nil, source: nil)
16
+ def initialize(status: nil, code: nil, title: nil, detail: nil, source: nil, meta: nil)
11
17
  @status = status
18
+ @code = code
12
19
  @title = title
13
20
  @detail = detail
14
21
  @source = source || {}
22
+ @meta = meta || {}
15
23
  end
16
24
 
17
25
  def to_h
18
26
  h = {}
19
27
  h["status"] = @status unless @status.nil?
28
+ h["code"] = @code unless @code.nil?
20
29
  h["title"] = @title unless @title.nil?
21
30
  h["detail"] = @detail unless @detail.nil?
22
31
  h["source"] = @source unless @source.empty?
32
+ h["meta"] = @meta unless @meta.empty?
23
33
  h
24
34
  end
25
35
 
@@ -85,11 +95,15 @@ module Smplkit
85
95
  raw_errors.filter_map do |item|
86
96
  next unless item.is_a?(Hash)
87
97
 
98
+ source = item["source"]
99
+ meta = item["meta"]
88
100
  ApiErrorDetail.new(
89
101
  status: item["status"],
102
+ code: item["code"],
90
103
  title: item["title"],
91
104
  detail: item["detail"],
92
- source: item["source"] || {}
105
+ source: source.is_a?(Hash) ? source : {},
106
+ meta: meta.is_a?(Hash) ? meta : {}
93
107
  )
94
108
  end
95
109
  rescue JSON::ParserError, EncodingError
@@ -461,8 +461,9 @@ module Smplkit
461
461
  # ---------------------------------------------------------------
462
462
 
463
463
  # Queue a configuration declaration for bulk-discovery upload.
464
- # Called by +ConfigClient#get_or_create+. Threshold-flushes on a
465
- # background thread once the pending buffer reaches the flush size.
464
+ # Called from +ConfigClient#bind+ and +ConfigClient#get(id, key,
465
+ # default)+. Threshold-flushes on a background thread once the
466
+ # pending buffer reaches the flush size.
466
467
  def register_config(config_id, service:, environment:, parent: nil,
467
468
  name: nil, description: nil)
468
469
  @buffer.declare(config_id, service: service, environment: environment,
@@ -546,41 +547,16 @@ module Smplkit
546
547
  Smplkit::Config::Helpers.config_from_json(self, ResourceShim.from_model(response.data))
547
548
  end
548
549
 
549
- # Build the parent-chain for a given config, walking +parent_id+
550
- # pointers across the full config list. Mirrors the Python SDK's
551
- # client-side resolution there is no server +/chain+ endpoint.
552
- #
553
- # Walks every page of +list_configs+ so an account with more than
554
- # +RUNTIME_PAGE_SIZE+ configs still resolves chains correctly.
555
- def fetch_chain(target_key)
556
- all_configs = fetch_all_configs
557
- by_key = all_configs.to_h { |c| [c.key, c] }
558
- by_id = all_configs.to_h { |c| [c.id, c] }
559
-
560
- current = by_key[target_key]
561
- return [] unless current
562
-
563
- chain = []
564
- loop do
565
- chain << config_to_chain_entry(current)
566
- parent_id = current.parent_id
567
- break if parent_id.nil? || parent_id == ""
568
-
569
- parent = by_id[parent_id]
570
- break unless parent
571
-
572
- current = parent
573
- end
574
- chain
575
- end
576
-
577
- private
578
-
579
- def fetch_all_configs
550
+ # Walk every page of +list_configs+ so an account with more than
551
+ # +RUNTIME_PAGE_SIZE+ configs still resolves to the complete set. Used
552
+ # by the runtime client to refresh the resolved cache.
553
+ def list_all
580
554
  rows = PaginatedFetch.collect { |opts| @api.list_configs(opts) }
581
555
  rows.map { |r| Smplkit::Config::Helpers.config_from_json(self, ResourceShim.from_model(r)) }
582
556
  end
583
557
 
558
+ private
559
+
584
560
  def config_body(config)
585
561
  SmplkitGeneratedClient::Config::ConfigResponse.new(
586
562
  data: SmplkitGeneratedClient::Config::ConfigResource.new(
@@ -621,23 +597,6 @@ module Smplkit
621
597
  end
622
598
  end
623
599
 
624
- def config_to_chain_entry(config)
625
- items_hash = {}
626
- config.items.each do |item|
627
- items_hash[item.name] = {
628
- "value" => item.value,
629
- "type" => item.type,
630
- "description" => item.description
631
- }.compact
632
- end
633
-
634
- environments = config.environments.each_with_object({}) do |(env_key, env_obj), out|
635
- out[env_key] = { "values" => env_obj.values_raw }
636
- end
637
-
638
- { "id" => config.id, "items" => items_hash, "environments" => environments }
639
- end
640
-
641
600
  def bulk_items_to_wire(items_hash)
642
601
  return nil if items_hash.nil? || items_hash.empty?
643
602
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smplkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.51
4
+ version: 3.0.52
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smpl Solutions LLC