smplkit 3.0.46 → 3.0.47

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: 05451aff4b25b69dbb8559407e759b5d621eb3d3718673aa0a2ae2eea6872828
4
- data.tar.gz: 70efc7544a753f44714b9b8c9bb20eb13748b7e8cb1788dda869ff5f160afc0e
3
+ metadata.gz: 45143f97a9697f58ffc237a49e1c56c530d969a08f758acb969ed0d6ecd4c8fa
4
+ data.tar.gz: 2d678b90f48d3db9db2b8179af51e47357f96dc7d03d14eb4931e5f74543a7ca
5
5
  SHA512:
6
- metadata.gz: 2a8a7c7a9b89f69ab9fe46285590c3b4a6483916e8283f2e2849d645b2186e48fa425004970ea61cacaa81e72f5ef7ae95136f14d47fc87ca5da3aec50911981
7
- data.tar.gz: 367b840a5c525940bcffbaaace1e271735fe6040e0479f0f4eaccca49db2f404d0d747f9db054d0fd45f15e5e8c2ca3da98835a31e899b2412a2febec3cc6dff
6
+ metadata.gz: 8be90684fe43d7ac3f61111c64ed98ad686e38028fdccdc28048418c7eda0e26378447417b28d5f6b3e5c4ab1af6c323b98a969244511361a41b4f8834b86a20
7
+ data.tar.gz: 4b8e13b2b1a7646910c19cce3542695bdd1fcfd76e5598c27b25b77c166b89f31239af2c2234ce1b57b2f3b6f78a3a4cf7ccf91a5c0845614d8b5a9fb29f7d30
@@ -25,21 +25,24 @@ module Smplkit
25
25
 
26
26
  # A live, dot-accessible view over a resolved configuration.
27
27
  #
28
- # Backed by the most-recent resolved Hash for a config key. +#get+ /
29
- # +#[]+ return the resolved value at the time of access; +#refresh+
30
- # rebuilds the snapshot from the underlying client.
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.
31
32
  class LiveConfigProxy
32
33
  def initialize(client, key)
33
34
  @client = client
34
35
  @key = key
35
- @snapshot = client._resolve_now(key)
36
36
  end
37
37
 
38
+ def config_id = @key
39
+
38
40
  def get(item_key, default = nil)
39
- return @snapshot if item_key.nil?
41
+ snapshot = current_values
42
+ return snapshot if item_key.nil?
40
43
 
41
44
  keys = item_key.to_s.split(".")
42
- keys.reduce(@snapshot) do |scope, k|
45
+ keys.reduce(snapshot) do |scope, k|
43
46
  break default if scope.nil?
44
47
 
45
48
  scope.is_a?(Hash) ? scope[k] : default
@@ -51,13 +54,82 @@ module Smplkit
51
54
  end
52
55
 
53
56
  def to_h
54
- @snapshot.dup
57
+ current_values.dup
55
58
  end
56
59
 
57
60
  def refresh
58
- @snapshot = @client._resolve_now(@key)
61
+ # The cache is fully invalidated for this key — the next read
62
+ # re-resolves from the parent client.
63
+ @client._invalidate(@key)
59
64
  self
60
65
  end
66
+
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
+ # ------------------------------------------------------------------
76
+
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)
81
+
82
+ value
83
+ end
84
+
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
91
+
92
+ default
93
+ end
94
+
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)
100
+
101
+ default
102
+ end
103
+
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
108
+ end
109
+
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
114
+ end
115
+
116
+ def on_change(item_key = nil, &)
117
+ if item_key.nil?
118
+ @client.on_change(@key, &)
119
+ else
120
+ @client.on_change_item(@key, item_key.to_s, &)
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def current_values
127
+ @client._resolve_now(@key)
128
+ end
129
+
130
+ def register_item(item_key, item_type, default, description)
131
+ @client._observe_item_declaration(@key, item_key.to_s, item_type, default, description)
132
+ end
61
133
  end
62
134
 
63
135
  # Synchronous config runtime namespace.
@@ -75,8 +147,10 @@ module Smplkit
75
147
 
76
148
  @snapshots = {}
77
149
  @raw_chains = {}
150
+ @proxies = {}
78
151
  @global_listeners = []
79
152
  @key_listeners = Hash.new { |h, k| h[k] = [] }
153
+ @item_listeners = Hash.new { |h, k| h[k] = Hash.new { |hh, kk| hh[kk] = [] } }
80
154
  @connected = false
81
155
  @lock = Mutex.new
82
156
  end
@@ -85,6 +159,13 @@ module Smplkit
85
159
  return if @connected
86
160
 
87
161
  @environment = @parent._environment
162
+
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
168
+
88
169
  @ws_manager = @parent._ensure_ws
89
170
  @ws_manager.on("config_changed") { |data| handle_config_changed(data) }
90
171
  @ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
@@ -95,11 +176,35 @@ module Smplkit
95
176
  start unless @connected
96
177
 
97
178
  snapshot = resolve(config_key)
179
+ raise Smplkit::NotFoundError, "Config #{config_key.inspect} not found" if snapshot.nil?
98
180
  return snapshot if model_class.nil?
99
181
 
100
182
  model_class.new(snapshot)
101
183
  end
102
184
 
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
207
+
103
208
  def get_string(item_key, default: nil, config: nil)
104
209
  typed_get(item_key, default, config) { |v| v.is_a?(String) ? v : v.to_s }
105
210
  end
@@ -123,7 +228,7 @@ module Smplkit
123
228
  def live(config_key)
124
229
  start unless @connected
125
230
 
126
- LiveConfigProxy.new(self, config_key)
231
+ cached_proxy(config_key)
127
232
  end
128
233
 
129
234
  def on_change(config_key = nil, &block)
@@ -137,6 +242,13 @@ module Smplkit
137
242
  block
138
243
  end
139
244
 
245
+ def on_change_item(config_key, item_key, &block)
246
+ raise ArgumentError, "on_change_item requires a block" unless block
247
+
248
+ @item_listeners[config_key][item_key.to_s] << block
249
+ block
250
+ end
251
+
140
252
  def refresh
141
253
  @lock.synchronize do
142
254
  @snapshots.clear
@@ -146,15 +258,46 @@ module Smplkit
146
258
  end
147
259
 
148
260
  def _resolve_now(config_key)
149
- resolve(config_key)
261
+ resolve(config_key) || {}
150
262
  end
151
263
 
152
264
  def _close
153
265
  # No durable resources; symmetry stub.
154
266
  end
155
267
 
268
+ # Discard cached state for +config_key+; the next resolve will refetch.
269
+ def _invalidate(config_key)
270
+ @lock.synchronize do
271
+ @snapshots.delete(config_key)
272
+ @raw_chains.delete(config_key)
273
+ end
274
+ end
275
+
276
+ # Internal: queue a config declaration with the management buffer.
277
+ def _observe_config_declaration(config_id, parent:, name:, description:)
278
+ @manage.config.register_config(
279
+ config_id,
280
+ service: @service,
281
+ environment: @environment,
282
+ parent: parent,
283
+ name: name,
284
+ description: description
285
+ )
286
+ end
287
+
288
+ # Internal: queue a config item declaration with the management buffer.
289
+ 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)
291
+ end
292
+
156
293
  private
157
294
 
295
+ def cached_proxy(config_key)
296
+ @lock.synchronize do
297
+ @proxies[config_key] ||= LiveConfigProxy.new(self, config_key)
298
+ end
299
+ end
300
+
158
301
  def typed_get(item_key, default, config_key)
159
302
  snapshot = config_key ? resolve(config_key) : merged_snapshot
160
303
  key = item_key.to_s
@@ -190,6 +333,12 @@ module Smplkit
190
333
  end
191
334
 
192
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?
341
+
193
342
  snapshot = Helpers.resolve_chain(chain, @environment)
194
343
  @lock.synchronize do
195
344
  @raw_chains[config_key] = chain
@@ -238,6 +387,19 @@ module Smplkit
238
387
  rescue StandardError => e
239
388
  Smplkit.debug("config", "listener raised: #{e.class}: #{e.message}")
240
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
241
403
  end
242
404
 
243
405
  def fire_change_listeners_all(source)
@@ -6,6 +6,7 @@ module Smplkit
6
6
  CONTEXT_BATCH_FLUSH_SIZE = 100
7
7
  FLAG_BATCH_FLUSH_SIZE = 50
8
8
  LOGGER_BATCH_FLUSH_SIZE = 50
9
+ CONFIG_BATCH_FLUSH_SIZE = 50
9
10
 
10
11
  # Thread-safe batch buffer for context registration.
11
12
  class ContextRegistrationBuffer
@@ -89,6 +90,77 @@ module Smplkit
89
90
  end
90
91
  end
91
92
 
93
+ # Thread-safe batch buffer for config declarations. Mirrors Python's
94
+ # +_ConfigRegistrationBuffer+: per-config metadata is retained across
95
+ # flushes so post-drain deltas re-attribute correctly, and items are
96
+ # dedup'd per +(config_id, item_key)+ so an already-sent item is
97
+ # never re-sent.
98
+ class ConfigRegistrationBuffer
99
+ def initialize
100
+ @pending = {} # config_id -> { id:, items: {}, ...meta }
101
+ @meta = {} # config_id -> { service:, environment:, parent:, name:, description: }
102
+ @sent_items = {} # "#{config_id}::#{item_key}" -> true
103
+ @lock = Mutex.new
104
+ end
105
+
106
+ # Idempotent — first writer's metadata wins.
107
+ def declare(config_id, service:, environment:, parent: nil, name: nil, description: nil)
108
+ @lock.synchronize do
109
+ next if @meta.key?(config_id)
110
+
111
+ @meta[config_id] = {
112
+ service: service, environment: environment,
113
+ parent: parent, name: name, description: description
114
+ }
115
+ @pending[config_id] = build_entry(config_id)
116
+ end
117
+ end
118
+
119
+ # Queue an item declaration for an already-declared config. Items
120
+ # already sent in a previous +drain+ are skipped.
121
+ def add_item(config_id, item_key, item_type, default, description = nil)
122
+ @lock.synchronize do
123
+ next unless @meta.key?(config_id)
124
+ next if @sent_items.key?("#{config_id}::#{item_key}")
125
+
126
+ entry = (@pending[config_id] ||= build_entry(config_id))
127
+ next if entry["items"].key?(item_key)
128
+
129
+ item = { "value" => default, "type" => item_type }
130
+ item["description"] = description unless description.nil?
131
+ entry["items"][item_key] = item
132
+ end
133
+ end
134
+
135
+ # Returns and clears the pending batch; records sent items.
136
+ def drain
137
+ @lock.synchronize do
138
+ entries = @pending.values
139
+ entries.each do |entry|
140
+ entry["items"].each_key { |item_key| @sent_items["#{entry["id"]}::#{item_key}"] = true }
141
+ end
142
+ @pending = {}
143
+ entries
144
+ end
145
+ end
146
+
147
+ def pending_count
148
+ @lock.synchronize { @pending.size }
149
+ end
150
+
151
+ private
152
+
153
+ def build_entry(config_id)
154
+ meta = @meta[config_id]
155
+ entry = { "id" => config_id, "items" => {} }
156
+ %i[service environment parent name description].each do |k|
157
+ v = meta[k]
158
+ entry[k.to_s] = v unless v.nil?
159
+ end
160
+ entry
161
+ end
162
+ end
163
+
92
164
  # Thread-safe batch buffer for logger discovery.
93
165
  class LoggerRegistrationBuffer
94
166
  def initialize
@@ -453,6 +453,59 @@ module Smplkit
453
453
  class ConfigNamespace
454
454
  def initialize(api_client)
455
455
  @api = SmplkitGeneratedClient::Config::ConfigsApi.new(api_client)
456
+ @buffer = Management::ConfigRegistrationBuffer.new
457
+ end
458
+
459
+ # ---------------------------------------------------------------
460
+ # Discovery API (ADR-037 §2.13/§2.14)
461
+ # ---------------------------------------------------------------
462
+
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.
466
+ def register_config(config_id, service:, environment:, parent: nil,
467
+ name: nil, description: nil)
468
+ @buffer.declare(config_id, service: service, environment: environment,
469
+ parent: parent, name: name, description: description)
470
+ trigger_background_flush_if_needed
471
+ end
472
+
473
+ # Queue a config item declaration. +register_config+ must have run
474
+ # first; items added without a prior declaration are dropped.
475
+ def register_config_item(config_id, item_key, item_type, default, description = nil)
476
+ @buffer.add_item(config_id, item_key, item_type, default, description)
477
+ trigger_background_flush_if_needed
478
+ end
479
+
480
+ def pending_count
481
+ @buffer.pending_count
482
+ end
483
+
484
+ # Send any pending config declarations to
485
+ # +POST /api/v1/configs/bulk+. Per ADR-024 §2.9 the bulk endpoint is
486
+ # plan-limit-exempt; failures here never propagate to customer code.
487
+ def flush
488
+ batch = @buffer.drain
489
+ return if batch.empty?
490
+
491
+ items = batch.map do |entry|
492
+ SmplkitGeneratedClient::Config::ConfigBulkItem.new(
493
+ id: entry["id"],
494
+ service: entry["service"],
495
+ environment: entry["environment"],
496
+ parent: entry["parent"],
497
+ name: entry["name"],
498
+ description: entry["description"],
499
+ items: bulk_items_to_wire(entry["items"])
500
+ )
501
+ end
502
+ body = SmplkitGeneratedClient::Config::ConfigBulkRequest.new(configs: items)
503
+ begin
504
+ ErrorMapping.call { @api.bulk_register_configs(body) }
505
+ rescue StandardError => e
506
+ # Fire-and-forget per ADR-024 §2.9.
507
+ Smplkit.debug("registration", "config bulk register failed: #{e.class}: #{e.message}")
508
+ end
456
509
  end
457
510
 
458
511
  def list(page_number: nil, page_size: nil)
@@ -584,6 +637,28 @@ module Smplkit
584
637
 
585
638
  { "id" => config.id, "items" => items_hash, "environments" => environments }
586
639
  end
640
+
641
+ def bulk_items_to_wire(items_hash)
642
+ return nil if items_hash.nil? || items_hash.empty?
643
+
644
+ items_hash.transform_values do |def_hash|
645
+ SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
646
+ value: def_hash["value"],
647
+ type: def_hash["type"],
648
+ description: def_hash["description"]
649
+ )
650
+ end
651
+ end
652
+
653
+ def trigger_background_flush_if_needed
654
+ return unless @buffer.pending_count >= Management::CONFIG_BATCH_FLUSH_SIZE
655
+
656
+ Thread.new do
657
+ flush
658
+ rescue StandardError => e
659
+ Smplkit.debug("registration", "threshold config flush failed: #{e.class}: #{e.message}")
660
+ end
661
+ end
587
662
  end
588
663
 
589
664
  class FlagsNamespace
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.46
4
+ version: 3.0.47
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smpl Solutions LLC