smplkit 3.0.46 → 3.0.48

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: 4cb1e0f4a608cd5ade8c399fccc96b62f7d087b51038aa1a36ec74eb22a434bc
4
+ data.tar.gz: b024471ba9cf393fd36d123add01277c8baabb4cd44640905a669c4bc42d68b3
5
5
  SHA512:
6
- metadata.gz: 2a8a7c7a9b89f69ab9fe46285590c3b4a6483916e8283f2e2849d645b2186e48fa425004970ea61cacaa81e72f5ef7ae95136f14d47fc87ca5da3aec50911981
7
- data.tar.gz: 367b840a5c525940bcffbaaace1e271735fe6040e0479f0f4eaccca49db2f404d0d747f9db054d0fd45f15e5e8c2ca3da98835a31e899b2412a2febec3cc6dff
6
+ metadata.gz: 86ab6f3765a4f091dc200ae01f8d902158c88c2f1107ba92d55656c67ec95149507b07885143b381bcdb7ca73ac5ed1f7d4dccc6c7b0798ce5b115db5b4d67c7
7
+ data.tar.gz: 6ba7af7b036be3d9dc15f04e564690aec69e179795d485e4514f6c57aa203bab11255522ea30693aa612e74099fc4d6d490bf7da88985701f1d5a95c0a50af19
@@ -280,10 +280,11 @@ module SmplkitGeneratedClient::App
280
280
  end
281
281
 
282
282
  # List Environments
283
- # List all environments for the authenticated account. `filter[search]` does a case-insensitive substring match against the environment `key` and `name`. `filter[classification]` narrows the result to one classification (`STANDARD` or `AD_HOC`).
283
+ # List all environments for the authenticated account. `filter[search]` does a case-insensitive substring match against the environment `key` and `name`. `filter[classification]` narrows the result to one classification (`STANDARD` or `AD_HOC`). `filter[managed]` narrows by managed state (`true` or `false`).
284
284
  # @param [Hash] opts the optional parameters
285
285
  # @option opts [String] :filter_search Case-insensitive substring match against the environment `key` and `name`. An environment is returned if either field contains the search term.
286
286
  # @option opts [String] :filter_classification Narrow the result to environments with the given classification. One of `STANDARD` or `AD_HOC`.
287
+ # @option opts [Boolean] :filter_managed Narrow the result to managed (`true`) or unmanaged (`false`) environments. Omit to return both.
287
288
  # @option opts [String] :sort Field to sort by. Prefix with `-` for descending order. Default: `name`. Allowed values: `created_at`, `-created_at`, `key`, `-key`, `name`, `-name`, `updated_at`, `-updated_at`. (default to 'name')
288
289
  # @option opts [Integer] :page_number 1-based page number to return. Optional; defaults to `1` when omitted. Must be `>= 1` — requests with a smaller value are rejected with a 400 error. (default to 1)
289
290
  # @option opts [Integer] :page_size Number of items per page. Optional; defaults to `1000` when omitted. Must be between `1` and `1000` inclusive — requests outside that range are rejected with a 400 error. (default to 1000)
@@ -295,10 +296,11 @@ module SmplkitGeneratedClient::App
295
296
  end
296
297
 
297
298
  # List Environments
298
- # List all environments for the authenticated account. `filter[search]` does a case-insensitive substring match against the environment `key` and `name`. `filter[classification]` narrows the result to one classification (`STANDARD` or `AD_HOC`).
299
+ # List all environments for the authenticated account. `filter[search]` does a case-insensitive substring match against the environment `key` and `name`. `filter[classification]` narrows the result to one classification (`STANDARD` or `AD_HOC`). `filter[managed]` narrows by managed state (`true` or `false`).
299
300
  # @param [Hash] opts the optional parameters
300
301
  # @option opts [String] :filter_search Case-insensitive substring match against the environment `key` and `name`. An environment is returned if either field contains the search term.
301
302
  # @option opts [String] :filter_classification Narrow the result to environments with the given classification. One of `STANDARD` or `AD_HOC`.
303
+ # @option opts [Boolean] :filter_managed Narrow the result to managed (`true`) or unmanaged (`false`) environments. Omit to return both.
302
304
  # @option opts [String] :sort Field to sort by. Prefix with `-` for descending order. Default: `name`. Allowed values: `created_at`, `-created_at`, `key`, `-key`, `name`, `-name`, `updated_at`, `-updated_at`. (default to 'name')
303
305
  # @option opts [Integer] :page_number 1-based page number to return. Optional; defaults to `1` when omitted. Must be `>= 1` — requests with a smaller value are rejected with a 400 error. (default to 1)
304
306
  # @option opts [Integer] :page_size Number of items per page. Optional; defaults to `1000` when omitted. Must be between `1` and `1000` inclusive — requests outside that range are rejected with a 400 error. (default to 1000)
@@ -319,6 +321,7 @@ module SmplkitGeneratedClient::App
319
321
  query_params = opts[:query_params] || {}
320
322
  query_params[:'filter[search]'] = opts[:'filter_search'] if !opts[:'filter_search'].nil?
321
323
  query_params[:'filter[classification]'] = opts[:'filter_classification'] if !opts[:'filter_classification'].nil?
324
+ query_params[:'filter[managed]'] = opts[:'filter_managed'] if !opts[:'filter_managed'].nil?
322
325
  query_params[:'sort'] = opts[:'sort'] if !opts[:'sort'].nil?
323
326
  query_params[:'page[number]'] = opts[:'page_number'] if !opts[:'page_number'].nil?
324
327
  query_params[:'page[size]'] = opts[:'page_size'] if !opts[:'page_size'].nil?
@@ -22,9 +22,12 @@ module SmplkitGeneratedClient::App
22
22
  # Display color used by the console to badge the environment. Accepts any CSS color string.
23
23
  attr_accessor :color
24
24
 
25
- # `STANDARD` for environments the customer explicitly manages; `AD_HOC` for environments auto-created from SDK traffic. Case-insensitive on input.
25
+ # `STANDARD` for environments deliberately created (and shown by default in the environment grid); `AD_HOC` for auto-discovered environments seen in SDK traffic (hidden from the default view). Case-insensitive on input. Independent of the `managed` flag.
26
26
  attr_accessor :classification
27
27
 
28
+ # When `true`, per-environment resource values can be set against this environment and it counts toward the account's managed-environments quota. When `false`, the environment is view-only: existing values are displayed for comparison but no new values can be written. Promotion and demotion flip this boolean via `PUT /api/v1/environments/{id}`; promotion is subject to the quota.
29
+ attr_accessor :managed
30
+
28
31
  # When the environment was created.
29
32
  attr_accessor :created_at
30
33
 
@@ -59,6 +62,7 @@ module SmplkitGeneratedClient::App
59
62
  :'name' => :'name',
60
63
  :'color' => :'color',
61
64
  :'classification' => :'classification',
65
+ :'managed' => :'managed',
62
66
  :'created_at' => :'created_at',
63
67
  :'updated_at' => :'updated_at'
64
68
  }
@@ -80,6 +84,7 @@ module SmplkitGeneratedClient::App
80
84
  :'name' => :'String',
81
85
  :'color' => :'String',
82
86
  :'classification' => :'String',
87
+ :'managed' => :'Boolean',
83
88
  :'created_at' => :'Time',
84
89
  :'updated_at' => :'Time'
85
90
  }
@@ -123,7 +128,13 @@ module SmplkitGeneratedClient::App
123
128
  if attributes.key?(:'classification')
124
129
  self.classification = attributes[:'classification']
125
130
  else
126
- self.classification = 'AD_HOC'
131
+ self.classification = 'STANDARD'
132
+ end
133
+
134
+ if attributes.key?(:'managed')
135
+ self.managed = attributes[:'managed']
136
+ else
137
+ self.managed = false
127
138
  end
128
139
 
129
140
  if attributes.key?(:'created_at')
@@ -209,6 +220,7 @@ module SmplkitGeneratedClient::App
209
220
  name == o.name &&
210
221
  color == o.color &&
211
222
  classification == o.classification &&
223
+ managed == o.managed &&
212
224
  created_at == o.created_at &&
213
225
  updated_at == o.updated_at
214
226
  end
@@ -222,7 +234,7 @@ module SmplkitGeneratedClient::App
222
234
  # Calculates hash code according to all attributes.
223
235
  # @return [Integer] Hash code
224
236
  def hash
225
- [name, color, classification, created_at, updated_at].hash
237
+ [name, color, classification, managed, created_at, updated_at].hash
226
238
  end
227
239
 
228
240
  # Builds the object from hash
@@ -83,10 +83,11 @@ describe 'EnvironmentsApi' do
83
83
 
84
84
  # unit tests for list_environments
85
85
  # List Environments
86
- # List all environments for the authenticated account. `filter[search]` does a case-insensitive substring match against the environment `key` and `name`. `filter[classification]` narrows the result to one classification (`STANDARD` or `AD_HOC`).
86
+ # List all environments for the authenticated account. `filter[search]` does a case-insensitive substring match against the environment `key` and `name`. `filter[classification]` narrows the result to one classification (`STANDARD` or `AD_HOC`). `filter[managed]` narrows by managed state (`true` or `false`).
87
87
  # @param [Hash] opts the optional parameters
88
88
  # @option opts [String] :filter_search Case-insensitive substring match against the environment `key` and `name`. An environment is returned if either field contains the search term.
89
89
  # @option opts [String] :filter_classification Narrow the result to environments with the given classification. One of `STANDARD` or `AD_HOC`.
90
+ # @option opts [Boolean] :filter_managed Narrow the result to managed (`true`) or unmanaged (`false`) environments. Omit to return both.
90
91
  # @option opts [String] :sort Field to sort by. Prefix with `-` for descending order. Default: `name`. Allowed values: `created_at`, `-created_at`, `key`, `-key`, `name`, `-name`, `updated_at`, `-updated_at`.
91
92
  # @option opts [Integer] :page_number 1-based page number to return. Optional; defaults to `1` when omitted. Must be `>= 1` — requests with a smaller value are rejected with a 400 error.
92
93
  # @option opts [Integer] :page_size Number of items per page. Optional; defaults to `1000` when omitted. Must be between `1` and `1000` inclusive — requests outside that range are rejected with a 400 error.
@@ -49,6 +49,12 @@ describe SmplkitGeneratedClient::App::Environment do
49
49
  end
50
50
  end
51
51
 
52
+ describe 'test attribute "managed"' do
53
+ it 'should work' do
54
+ # assertion here. ref: https://rspec.info/features/3-12/rspec-expectations/built-in-matchers/
55
+ end
56
+ end
57
+
52
58
  describe 'test attribute "created_at"' do
53
59
  it 'should work' do
54
60
  # assertion here. ref: https://rspec.info/features/3-12/rspec-expectations/built-in-matchers/
@@ -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.48
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smpl Solutions LLC