smplkit 3.0.95 → 3.0.96
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 +4 -4
- data/lib/smplkit/account/client.rb +121 -0
- data/lib/smplkit/account/models.rb +53 -0
- data/lib/smplkit/api_support.rb +83 -0
- data/lib/smplkit/audit/client.rb +9 -10
- data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +73 -76
- data/lib/smplkit/audit/models.rb +40 -1
- data/lib/smplkit/buffers.rb +235 -0
- data/lib/smplkit/client.rb +126 -67
- data/lib/smplkit/config/client.rb +617 -182
- data/lib/smplkit/config_resolution.rb +11 -5
- data/lib/smplkit/errors.rb +8 -0
- data/lib/smplkit/flags/client.rb +472 -114
- data/lib/smplkit/flags/types.rb +6 -7
- data/lib/smplkit/{management/jobs.rb → jobs/client.rb} +148 -89
- data/lib/smplkit/logging/client.rb +647 -192
- data/lib/smplkit/logging/helpers.rb +1 -0
- data/lib/smplkit/logging/models.rb +92 -1
- data/lib/smplkit/logging/sources.rb +1 -1
- data/lib/smplkit/platform/client.rb +472 -0
- data/lib/smplkit/platform/models.rb +182 -0
- data/lib/smplkit/{management → platform}/types.rb +7 -4
- data/lib/smplkit/transport.rb +99 -0
- data/lib/smplkit.rb +18 -6
- metadata +11 -7
- data/lib/smplkit/management/buffer.rb +0 -198
- data/lib/smplkit/management/client.rb +0 -1074
- data/lib/smplkit/management/models.rb +0 -178
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# The Smpl Config client — one unified +ConfigClient+.
|
|
4
|
+
#
|
|
5
|
+
# Smpl Config has two surfaces on a single client, mirroring how the audit and
|
|
6
|
+
# jobs clients expose their full surface from one class:
|
|
7
|
+
#
|
|
8
|
+
# * *Management surface* — pure CRUD, no live connection: +new+ / +get+ /
|
|
9
|
+
# +list+ / +delete+ and the discovery buffer (+register_config+ /
|
|
10
|
+
# +register_config_item+ / +flush+ / +pending_count+). The client owns the
|
|
11
|
+
# discovery buffer directly.
|
|
12
|
+
# * *Live surface* — lazily connects to your running service on first use:
|
|
13
|
+
# +subscribe+ (a live dict-like +LiveConfigProxy+), +get_value+ (an ad-hoc
|
|
14
|
+
# resolved read), +bind+ (a live Struct/Hash binding), +on_change+, and
|
|
15
|
+
# +refresh+. The first live call transparently flushes discovery, fetches and
|
|
16
|
+
# resolves every config into the local cache, and opens the live-updates
|
|
17
|
+
# WebSocket — no explicit install step.
|
|
18
|
+
#
|
|
19
|
+
# The client supports two construction shapes:
|
|
20
|
+
#
|
|
21
|
+
# * *Wired* into +Smplkit::Client+ — borrows the parent's config transport for
|
|
22
|
+
# both runtime fetch and CRUD and the parent's shared WebSocket for the live
|
|
23
|
+
# channel. This is the common path.
|
|
24
|
+
# * *Standalone* — +ConfigClient.new(api_key: ..., base_url: ..., ...)+ builds
|
|
25
|
+
# and owns its own config transport, and on first live use opens and owns its
|
|
26
|
+
# own WebSocket. +close+ tears down only the owned transport and owned
|
|
27
|
+
# WebSocket.
|
|
3
28
|
module Smplkit
|
|
4
29
|
module Config
|
|
5
|
-
# Module-level helpers for the
|
|
6
|
-
#
|
|
30
|
+
# Module-level helpers for the config client. Extracted so they can be
|
|
31
|
+
# unit-tested without spinning up the full client.
|
|
7
32
|
module Discovery
|
|
8
33
|
module_function
|
|
9
34
|
|
|
10
35
|
# Map a runtime value to a Config item type. Used both when binding a
|
|
11
|
-
# Hash/Struct target and when supplying a default to +
|
|
36
|
+
# Hash/Struct target and when supplying a default to +get_value(id, key,
|
|
12
37
|
# default)+. +true+/+false+ are checked first because Ruby's
|
|
13
38
|
# +Numeric+/+Integer+ tests would not accidentally claim them.
|
|
14
39
|
def value_to_item_type(value)
|
|
@@ -19,9 +44,9 @@ module Smplkit
|
|
|
19
44
|
end
|
|
20
45
|
end
|
|
21
46
|
|
|
22
|
-
# Walk a bound target, returning +[key, type, value, description]+
|
|
23
|
-
#
|
|
24
|
-
#
|
|
47
|
+
# Walk a bound target, returning +[key, type, value, description]+ tuples
|
|
48
|
+
# flattened to dot-notation. Nested Hashes / Structs are descended into;
|
|
49
|
+
# everything else is treated as an opaque leaf.
|
|
25
50
|
def iter_items(target, prefix: "")
|
|
26
51
|
if target.is_a?(Hash)
|
|
27
52
|
iter_hash_items(target, prefix: prefix)
|
|
@@ -62,8 +87,8 @@ module Smplkit
|
|
|
62
87
|
# Apply a server-pushed value to a bound target in place. Walks the
|
|
63
88
|
# dotted key path to the leaf's parent and assigns the value via
|
|
64
89
|
# +Hash#[]=+ or +Struct#[]=+. Bails silently if any intermediate is
|
|
65
|
-
# missing or not a supported container — the server may have items
|
|
66
|
-
#
|
|
90
|
+
# missing or not a supported container — the server may have items that
|
|
91
|
+
# don't line up with what the bound target declared.
|
|
67
92
|
def apply_change_to_target(target, dotted_key, value)
|
|
68
93
|
parts = dotted_key.split(".")
|
|
69
94
|
current = walk_to_leaf_parent(target, parts[0..-2])
|
|
@@ -132,16 +157,15 @@ module Smplkit
|
|
|
132
157
|
|
|
133
158
|
# A live, read-only, dict-like view of a config's resolved values.
|
|
134
159
|
#
|
|
135
|
-
# Returned by +ConfigClient#
|
|
136
|
-
#
|
|
160
|
+
# Returned by +ConfigClient#subscribe+. Always reflects the latest
|
|
161
|
+
# server-pushed state — every read sees current values.
|
|
137
162
|
#
|
|
138
|
-
# Supports +[]+, +key?+, +keys+, +values+, +each_pair+, +to_h+, +size+,
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
# that do collide.
|
|
163
|
+
# Supports +[]+, +key?+, +keys+, +values+, +each_pair+, +to_h+, +size+, and
|
|
164
|
+
# method-style attribute access for keys that don't collide with built-in
|
|
165
|
+
# method names. Use subscript (+proxy["values"]+) for keys that do collide.
|
|
142
166
|
#
|
|
143
|
-
# For typed access via a Struct schema, use +ConfigClient#bind+ —
|
|
144
|
-
#
|
|
167
|
+
# For typed access via a Struct schema, use +ConfigClient#bind+ — bound
|
|
168
|
+
# objects stay live on the same cache, with no proxy indirection.
|
|
145
169
|
class LiveConfigProxy
|
|
146
170
|
# Methods that live on the proxy itself; never resolved against the
|
|
147
171
|
# cached values dictionary.
|
|
@@ -208,130 +232,304 @@ module Smplkit
|
|
|
208
232
|
end
|
|
209
233
|
end
|
|
210
234
|
|
|
211
|
-
#
|
|
235
|
+
# Normalize a +parent+ argument to a config id string.
|
|
236
|
+
def self.resolve_parent_id(parent)
|
|
237
|
+
return parent if parent.nil? || parent.is_a?(String)
|
|
238
|
+
if parent.id.nil? || parent.id == ""
|
|
239
|
+
raise ArgumentError, "parent config must be saved (have an id) before being used as a parent"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
parent.id
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Build a standalone config transport and resolve the app base URL.
|
|
246
|
+
#
|
|
247
|
+
# +base_url+/+api_key+ are used directly when supplied (the path a top-level
|
|
248
|
+
# client takes after it has already resolved them); otherwise the
|
|
249
|
+
# management config resolver fills in whatever is missing (+~/.smplkit+ /
|
|
250
|
+
# env vars / defaults). The app base URL is returned alongside so a
|
|
251
|
+
# standalone client can open its own WebSocket against the event gateway.
|
|
252
|
+
def self.config_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
|
|
253
|
+
cfg = ConfigResolution.resolve_management_config(
|
|
254
|
+
profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
|
|
255
|
+
)
|
|
256
|
+
resolved_key = api_key.nil? ? cfg.api_key : api_key
|
|
257
|
+
merged = {}
|
|
258
|
+
merged.merge!(cfg.extra_headers || {})
|
|
259
|
+
merged.merge!(extra_headers || {})
|
|
260
|
+
tcfg = ConfigResolution::ResolvedManagementConfig.new(
|
|
261
|
+
api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
|
|
262
|
+
debug: cfg.debug, extra_headers: merged
|
|
263
|
+
)
|
|
264
|
+
app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
|
|
265
|
+
transport = Transport.build_api_client(SmplkitGeneratedClient::Config, "config", tcfg, base_url: base_url)
|
|
266
|
+
[transport, app_url, resolved_key]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The Smpl Config client (sync).
|
|
212
270
|
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
271
|
+
# One client exposes the full surface, reachable as +client.config+
|
|
272
|
+
# (+Smplkit::Client+) or constructed directly:
|
|
273
|
+
#
|
|
274
|
+
# config = Smplkit::ConfigClient.new(environment: "production")
|
|
275
|
+
# billing = config.new("billing", name: "Billing")
|
|
276
|
+
# billing.set_number("max_seats", 50)
|
|
277
|
+
# billing.save
|
|
278
|
+
# proxy = config.subscribe("billing")
|
|
279
|
+
# puts proxy["max_seats"]
|
|
280
|
+
#
|
|
281
|
+
# The management surface (+new+ / +get+ / +list+ / +delete+ and discovery)
|
|
282
|
+
# is pure CRUD. The live surface (+subscribe+ / +get_value+ / +bind+ /
|
|
283
|
+
# +on_change+ / +refresh+) connects lazily on first use — the first call
|
|
284
|
+
# flushes discovery, fetches and resolves all configs into the local cache,
|
|
285
|
+
# and opens the live-updates WebSocket. No explicit install step is
|
|
286
|
+
# required.
|
|
217
287
|
class ConfigClient
|
|
218
|
-
# Sentinel
|
|
219
|
-
#
|
|
220
|
-
# only ever identity-compare with +equal?+.
|
|
288
|
+
# Sentinel distinguishing "no default supplied" from an explicit +nil+
|
|
289
|
+
# default in +#get_value+.
|
|
221
290
|
MISSING = Object.new.freeze
|
|
222
291
|
private_constant :MISSING
|
|
223
292
|
|
|
224
|
-
def initialize(
|
|
293
|
+
def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
|
|
294
|
+
base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
|
|
295
|
+
parent: nil, transport: nil, metrics: nil)
|
|
225
296
|
@parent = parent
|
|
226
|
-
@manage = manage
|
|
227
297
|
@metrics = metrics
|
|
228
|
-
@environment = parent._environment
|
|
229
|
-
@service = parent
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
298
|
+
@environment = parent.nil? ? environment : parent._environment
|
|
299
|
+
@service = parent&._service
|
|
300
|
+
@standalone_api_key = nil
|
|
301
|
+
if transport.nil?
|
|
302
|
+
@http, @app_base_url, @standalone_api_key = Smplkit::Config.config_transport(
|
|
303
|
+
api_key: api_key, base_url: base_url, profile: profile,
|
|
304
|
+
base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
|
|
305
|
+
)
|
|
306
|
+
@owns_transport = true
|
|
307
|
+
else
|
|
308
|
+
@http = transport
|
|
309
|
+
@app_base_url = nil
|
|
310
|
+
@owns_transport = false
|
|
311
|
+
end
|
|
312
|
+
@api = SmplkitGeneratedClient::Config::ConfigsApi.new(@http)
|
|
313
|
+
|
|
314
|
+
# Discovery buffer is owned by this client (no management delegation).
|
|
315
|
+
@buffer = ConfigRegistrationBuffer.new
|
|
316
|
+
|
|
317
|
+
# Live-surface state.
|
|
318
|
+
@config_cache = {} # config_id -> { item_key => resolved_value }
|
|
319
|
+
@raw_config_store = {} # config_id -> Config
|
|
320
|
+
@proxies = {} # config_id -> LiveConfigProxy
|
|
321
|
+
@bindings = {} # config_id -> Hash | Struct (bound target)
|
|
322
|
+
# Parent config id each binding was bound under (nil for roots) —
|
|
323
|
+
# drives in-memory cache seeding through the bound parent chain.
|
|
324
|
+
@bound_parents = {}
|
|
236
325
|
@connected = false
|
|
237
326
|
@lock = Mutex.new
|
|
327
|
+
@listeners = [] # [callback, config_id_or_nil, item_key_or_nil]
|
|
238
328
|
@ws_manager = nil
|
|
329
|
+
@owns_ws = false
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# ----------------------------------------------------------------
|
|
333
|
+
# Management surface: CRUD (no live connection)
|
|
334
|
+
# ----------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
# Return a new unsaved +Config+. Call +Config#save+ to persist.
|
|
337
|
+
#
|
|
338
|
+
# +parent+ accepts either a config id (string) or an existing +Config+
|
|
339
|
+
# instance — passing the instance lets you skip naming the id explicitly
|
|
340
|
+
# when you already have the parent in scope.
|
|
341
|
+
def new(id, name: nil, description: nil, parent: nil)
|
|
342
|
+
Config.new(
|
|
343
|
+
self,
|
|
344
|
+
key: id,
|
|
345
|
+
name: name || Smplkit::Helpers.key_to_display_name(id),
|
|
346
|
+
description: description,
|
|
347
|
+
parent_id: Smplkit::Config.resolve_parent_id(parent)
|
|
348
|
+
)
|
|
239
349
|
end
|
|
240
350
|
|
|
241
|
-
#
|
|
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.
|
|
351
|
+
# Fetch the editable +Config+ resource by id.
|
|
246
352
|
#
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
353
|
+
# Raises +NotFoundError+ if no config with that id exists.
|
|
354
|
+
def get(id)
|
|
355
|
+
response = ApiSupport::ErrorMapping.call { @api.get_config(id) }
|
|
356
|
+
Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
|
|
357
|
+
end
|
|
251
358
|
|
|
252
|
-
|
|
359
|
+
# List configs for the authenticated account.
|
|
360
|
+
def list(page_number: nil, page_size: nil)
|
|
361
|
+
opts = {}
|
|
362
|
+
opts[:page_number] = page_number unless page_number.nil?
|
|
363
|
+
opts[:page_size] = page_size unless page_size.nil?
|
|
364
|
+
response = ApiSupport::ErrorMapping.call { @api.list_configs(opts) }
|
|
365
|
+
(response.data || []).map { |r| Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(r)) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Delete a config by id.
|
|
369
|
+
def delete(id)
|
|
370
|
+
ApiSupport::ErrorMapping.call { @api.delete_config(id) }
|
|
371
|
+
nil
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def _create_config(config)
|
|
375
|
+
response = ApiSupport::ErrorMapping.call { @api.create_config(config_body(config)) }
|
|
376
|
+
Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def _update_config(config)
|
|
380
|
+
response = ApiSupport::ErrorMapping.call { @api.update_config(config.key, config_body(config)) }
|
|
381
|
+
Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
|
|
382
|
+
end
|
|
253
383
|
|
|
254
|
-
|
|
255
|
-
|
|
384
|
+
# ----------------------------------------------------------------
|
|
385
|
+
# Management surface: discovery buffer (owned directly)
|
|
386
|
+
# ----------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
# Queue a configuration declaration for bulk-discovery upload.
|
|
389
|
+
def register_config(config_id, service:, environment:, parent: nil, name: nil, description: nil)
|
|
390
|
+
@buffer.declare(config_id, service: service, environment: environment,
|
|
391
|
+
parent: parent, name: name, description: description)
|
|
392
|
+
trigger_background_flush_if_needed
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Queue a config item declaration. +register_config+ must run first.
|
|
396
|
+
def register_config_item(config_id, item_key, item_type, default, description = nil)
|
|
397
|
+
@buffer.add_item(config_id, item_key, item_type, default, description)
|
|
398
|
+
trigger_background_flush_if_needed
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# POST pending declarations to +/api/v1/configs/bulk+.
|
|
402
|
+
#
|
|
403
|
+
# Per ADR-024 §2.9, bulk registration always lands rows as
|
|
404
|
+
# +managed=false+ and is plan-limit-exempt — failures here never
|
|
405
|
+
# propagate to customer code. Drained entries are not requeued; the SDK
|
|
406
|
+
# will re-observe on the next process start.
|
|
407
|
+
def flush
|
|
408
|
+
batch = @buffer.drain
|
|
409
|
+
return if batch.empty?
|
|
410
|
+
|
|
411
|
+
body = build_config_bulk_request(batch)
|
|
256
412
|
begin
|
|
257
|
-
@
|
|
413
|
+
ApiSupport::ErrorMapping.call { @api.bulk_register_configs(body) }
|
|
258
414
|
rescue StandardError => e
|
|
259
|
-
|
|
415
|
+
# Fire-and-forget per ADR-024 §2.9.
|
|
416
|
+
Smplkit.debug("registration", "config bulk register failed: #{e.class}: #{e.message}")
|
|
260
417
|
end
|
|
418
|
+
end
|
|
261
419
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
@ws_manager = @parent._ensure_ws
|
|
266
|
-
@ws_manager.on("config_changed") { |data| handle_config_changed(data) }
|
|
267
|
-
@ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
|
|
268
|
-
@ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
|
|
420
|
+
# Number of pending config declarations awaiting flush.
|
|
421
|
+
def pending_count
|
|
422
|
+
@buffer.pending_count
|
|
269
423
|
end
|
|
270
424
|
|
|
425
|
+
# ----------------------------------------------------------------
|
|
426
|
+
# Live surface: bind, subscribe, get_value
|
|
427
|
+
# ----------------------------------------------------------------
|
|
428
|
+
|
|
271
429
|
# Bind a Hash or Struct to a config id; return the same object back, live.
|
|
272
430
|
#
|
|
273
431
|
# Declarative, code-first API. Two flavors:
|
|
274
432
|
#
|
|
275
|
-
# * +Hash+: keys present are leaves to register, with their values as
|
|
276
|
-
#
|
|
277
|
-
#
|
|
278
|
-
# * +Struct+: every member is registered as an explicit override.
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
#
|
|
433
|
+
# * +Hash+: keys present are leaves to register, with their values as the
|
|
434
|
+
# in-code defaults. Nested Hashes flatten to dot-notation. Keys the
|
|
435
|
+
# caller wants to inherit from +parent:+ are simply omitted.
|
|
436
|
+
# * +Struct+: every member is registered as an explicit override. Ruby
|
|
437
|
+
# Structs do not track which members were "explicitly set" vs defaulted,
|
|
438
|
+
# so there is no Hash-style omit-to-inherit. For omit-to-inherit, use a
|
|
439
|
+
# Hash target.
|
|
282
440
|
#
|
|
283
|
-
# On first
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
441
|
+
# On first boot the schema and values are registered with the server. The
|
|
442
|
+
# local cache is then seeded so reads work immediately: if the config
|
|
443
|
+
# already exists server-side (fetched on connect) its values are
|
|
444
|
+
# authoritative and synced onto the bound object; if it is brand-new, the
|
|
445
|
+
# cache entry is seeded in-memory from the bound object's values resolved
|
|
446
|
+
# through its bound parent chain (no network round-trip). On every
|
|
447
|
+
# WebSocket-delivered change thereafter the bound object is mutated in
|
|
448
|
+
# place. Readers always see the current resolved value with no proxy
|
|
449
|
+
# indirection.
|
|
288
450
|
#
|
|
289
|
-
# Idempotent.
|
|
451
|
+
# Idempotent. Repeated calls with the same +id+ return the
|
|
290
452
|
# originally-bound object; the new +config+ argument is ignored.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
453
|
+
#
|
|
454
|
+
# Connects lazily on first use — no explicit install step.
|
|
455
|
+
def bind(id, config, parent: nil)
|
|
456
|
+
ensure_connected
|
|
457
|
+
unless config.is_a?(Hash) || config.is_a?(Struct)
|
|
458
|
+
raise TypeError, "bind() requires a Hash or Struct; got #{config.class.name}"
|
|
294
459
|
end
|
|
295
460
|
|
|
296
461
|
return @bindings[id] if @bindings.key?(id)
|
|
297
462
|
|
|
298
|
-
parent_id =
|
|
299
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
_observe_config_declaration(id, parent: parent_id, name: config_name, description: nil)
|
|
463
|
+
parent_id = register_binding_declaration(id, config, parent)
|
|
308
464
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
465
|
+
# Register the binding BEFORE syncing so WebSocket dispatch finds it.
|
|
466
|
+
@bindings[id] = config
|
|
467
|
+
@bound_parents[id] = parent_id
|
|
468
|
+
seed_or_sync_binding(id, config)
|
|
469
|
+
config
|
|
470
|
+
end
|
|
312
471
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
472
|
+
# Return a live, dict-like +LiveConfigProxy+ for a config id.
|
|
473
|
+
#
|
|
474
|
+
# The proxy always reflects the latest resolved values; reads happen
|
|
475
|
+
# through it (+proxy["key"]+, +proxy.get("key", default)+). Subscribing
|
|
476
|
+
# registers the config declaration for code-first observability so the
|
|
477
|
+
# reference appears in the smplkit console.
|
|
478
|
+
#
|
|
479
|
+
# Connects lazily on first use — no explicit install step. Raises
|
|
480
|
+
# +NotFoundError+ if the config is unknown.
|
|
481
|
+
def subscribe(id)
|
|
482
|
+
ensure_connected
|
|
483
|
+
observe_config_declaration(id, parent: nil, name: nil, description: nil)
|
|
484
|
+
in_cache = @lock.synchronize { @config_cache.key?(id) }
|
|
485
|
+
raise Smplkit::NotFoundError, "Config with id '#{id}' not found" unless in_cache
|
|
316
486
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
target
|
|
487
|
+
@metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
|
|
488
|
+
cached_proxy(id)
|
|
320
489
|
end
|
|
321
490
|
|
|
322
|
-
# Read a
|
|
491
|
+
# Read a single resolved config value (inheritance-aware).
|
|
492
|
+
#
|
|
493
|
+
# The value comes from the locally-cached resolved chain, so parent
|
|
494
|
+
# configs are already folded in.
|
|
495
|
+
#
|
|
496
|
+
# Two forms:
|
|
323
497
|
#
|
|
324
|
-
#
|
|
498
|
+
# * +get_value(id, key)+ returns the resolved value. Raises +NotFoundError+
|
|
499
|
+
# if the config is unknown and +KeyError+ if the key is absent.
|
|
500
|
+
# * +get_value(id, key, default)+ returns the resolved value, falling back
|
|
501
|
+
# to +default+ if the config or key is missing. Never raises.
|
|
502
|
+
# *Registers* the config (if new) and the key (inferred type, +default+
|
|
503
|
+
# as default) for code-first observability, so the reference appears in
|
|
504
|
+
# the smplkit console.
|
|
325
505
|
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
def
|
|
330
|
-
|
|
506
|
+
# For a live dict-like view use +#subscribe+; for typed access via a
|
|
507
|
+
# Struct schema use +#bind+. Connects lazily on first use — no explicit
|
|
508
|
+
# install step.
|
|
509
|
+
def get_value(id, key, default = MISSING)
|
|
510
|
+
ensure_connected
|
|
511
|
+
key = key.to_s
|
|
512
|
+
has_default = !default.equal?(MISSING)
|
|
513
|
+
if has_default
|
|
514
|
+
# Register the config + key so the reference shows up in the console
|
|
515
|
+
# even if it's never been declared via bind(). The buffer is
|
|
516
|
+
# idempotent at the (config_id, item_key) level.
|
|
517
|
+
observe_config_declaration(id, parent: nil, name: nil, description: nil)
|
|
518
|
+
observe_item_declaration(id, key, Discovery.value_to_item_type(default), default, nil)
|
|
519
|
+
end
|
|
331
520
|
|
|
332
|
-
|
|
521
|
+
values = @lock.synchronize { @config_cache[id]&.dup }
|
|
522
|
+
if values.nil?
|
|
523
|
+
return default if has_default
|
|
524
|
+
|
|
525
|
+
raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
|
|
526
|
+
end
|
|
527
|
+
unless values.key?(key)
|
|
528
|
+
return default if has_default
|
|
333
529
|
|
|
334
|
-
|
|
530
|
+
raise KeyError, "Config item '#{key}' not found in config '#{id}'"
|
|
531
|
+
end
|
|
532
|
+
values[key]
|
|
335
533
|
end
|
|
336
534
|
|
|
337
535
|
# Register a change listener.
|
|
@@ -341,7 +539,10 @@ module Smplkit
|
|
|
341
539
|
# client.config.on_change { |event| ... } # global
|
|
342
540
|
# client.config.on_change("id") { |event| ... } # config-scoped
|
|
343
541
|
# client.config.on_change("id", item_key: "key") { |event| ... } # item-scoped
|
|
542
|
+
#
|
|
543
|
+
# Connects lazily on first use — no explicit install step.
|
|
344
544
|
def on_change(config_id = nil, item_key: nil, &block)
|
|
545
|
+
ensure_connected
|
|
345
546
|
raise ArgumentError, "on_change requires a block" unless block
|
|
346
547
|
|
|
347
548
|
@listeners << [block, config_id, item_key&.to_s]
|
|
@@ -350,87 +551,156 @@ module Smplkit
|
|
|
350
551
|
|
|
351
552
|
# Re-fetch all configs and update resolved values, firing change
|
|
352
553
|
# listeners for anything that differs from the previous state.
|
|
554
|
+
#
|
|
555
|
+
# Connects lazily on first use — no explicit install step.
|
|
353
556
|
def refresh
|
|
354
|
-
|
|
557
|
+
ensure_connected
|
|
355
558
|
do_refresh("manual")
|
|
356
559
|
end
|
|
357
560
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
561
|
+
# Release resources — only those this client owns.
|
|
562
|
+
#
|
|
563
|
+
# Tears down the owned WebSocket (opened by a standalone client on first
|
|
564
|
+
# live use) and the owned HTTP transport (standalone construction). A
|
|
565
|
+
# wired client borrows the parent's transport and WebSocket and closes
|
|
566
|
+
# neither.
|
|
567
|
+
def close
|
|
568
|
+
if @owns_ws && @ws_manager
|
|
569
|
+
@ws_manager.stop
|
|
570
|
+
@ws_manager = nil
|
|
571
|
+
@owns_ws = false
|
|
572
|
+
end
|
|
573
|
+
nil
|
|
361
574
|
end
|
|
575
|
+
alias _close close
|
|
362
576
|
|
|
363
|
-
#
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
577
|
+
# Construct, yield to the block, and close on exit.
|
|
578
|
+
def self.open(**kwargs)
|
|
579
|
+
client = new(**kwargs)
|
|
580
|
+
begin
|
|
581
|
+
yield client
|
|
582
|
+
ensure
|
|
583
|
+
client.close
|
|
368
584
|
end
|
|
369
585
|
end
|
|
370
586
|
|
|
371
|
-
# Internal:
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
service: @service,
|
|
376
|
-
environment: @environment,
|
|
377
|
-
parent: parent,
|
|
378
|
-
name: name,
|
|
379
|
-
description: description
|
|
380
|
-
)
|
|
587
|
+
# Internal: return (a copy of) the resolved values for a config id.
|
|
588
|
+
# Used by +LiveConfigProxy+.
|
|
589
|
+
def _cached_values(config_id)
|
|
590
|
+
@lock.synchronize { (@config_cache[config_id] || {}).dup }
|
|
381
591
|
end
|
|
382
592
|
|
|
383
|
-
# Internal:
|
|
384
|
-
def
|
|
385
|
-
|
|
593
|
+
# Internal: trigger lazy connect. Used by +Client#wait_until_ready+.
|
|
594
|
+
def _ensure_connected
|
|
595
|
+
ensure_connected
|
|
386
596
|
end
|
|
387
597
|
|
|
388
598
|
private
|
|
389
599
|
|
|
390
|
-
|
|
391
|
-
|
|
600
|
+
# ----------------------------------------------------------------
|
|
601
|
+
# Live surface: lazy connect + transport / WebSocket helpers
|
|
602
|
+
# ----------------------------------------------------------------
|
|
392
603
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
"bind(): parent must be an object previously returned from client.config.bind(). " \
|
|
396
|
-
"Bind the parent first."
|
|
397
|
-
end
|
|
604
|
+
def ensure_ws
|
|
605
|
+
return @parent._ensure_ws unless @parent.nil?
|
|
398
606
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
607
|
+
if @ws_manager.nil?
|
|
608
|
+
@ws_manager = SharedWebSocket.new(
|
|
609
|
+
app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
|
|
610
|
+
)
|
|
611
|
+
@ws_manager.start
|
|
612
|
+
@owns_ws = true
|
|
402
613
|
end
|
|
403
|
-
@
|
|
404
|
-
cached_proxy(id)
|
|
614
|
+
@ws_manager
|
|
405
615
|
end
|
|
406
616
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
617
|
+
# Open the live connection to the running Smpl Config service.
|
|
618
|
+
#
|
|
619
|
+
# Flushes any buffered discovery declarations, fetches and resolves every
|
|
620
|
+
# config for the configured environment into the local cache, opens the
|
|
621
|
+
# shared WebSocket, and subscribes to +config_changed+ / +config_deleted+
|
|
622
|
+
# / +configs_changed+ events.
|
|
623
|
+
#
|
|
624
|
+
# Idempotent and internal — every live method calls it on first use, so
|
|
625
|
+
# the live surface auto-connects with no explicit step.
|
|
626
|
+
def ensure_connected
|
|
627
|
+
@parent&._ensure_started
|
|
628
|
+
return if @connected
|
|
629
|
+
|
|
630
|
+
# Flush any buffered discovery declarations BEFORE the initial fetch, so
|
|
631
|
+
# newly-discovered configs appear in the cache on first read.
|
|
632
|
+
begin
|
|
633
|
+
flush
|
|
634
|
+
rescue StandardError => e
|
|
635
|
+
Smplkit.debug("config", "discovery flush before connect failed: #{e.class}: #{e.message}")
|
|
412
636
|
end
|
|
413
637
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
638
|
+
# Fetch + resolve + cache + fire change listeners (against empty
|
|
639
|
+
# old_cache, so any registered listeners see "initial" events).
|
|
640
|
+
do_refresh("initial")
|
|
641
|
+
@connected = true
|
|
417
642
|
|
|
418
|
-
|
|
643
|
+
@ws_manager = ensure_ws
|
|
644
|
+
@ws_manager.on("config_changed") { |data| handle_config_changed(data) }
|
|
645
|
+
@ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
|
|
646
|
+
@ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# List configs directly from the API for the runtime cache.
|
|
650
|
+
def fetch_all_configs
|
|
651
|
+
rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_configs(opts) }
|
|
652
|
+
rows.map { |r| Helpers.config_from_json(nil, ApiSupport::ResourceShim.from_model(r)) }
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Fetch a single config from the API. Returns +nil+ on missing data.
|
|
656
|
+
def fetch_config(config_id)
|
|
657
|
+
response = ApiSupport::ErrorMapping.call { @api.get_config(config_id) }
|
|
658
|
+
Helpers.config_from_json(nil, ApiSupport::ResourceShim.from_model(response.data))
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# ----------------------------------------------------------------
|
|
662
|
+
# Internal: binding helpers
|
|
663
|
+
# ----------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
# Validate the parent, register the config + item declarations. Returns
|
|
666
|
+
# the resolved parent config id (or +nil+).
|
|
667
|
+
def register_binding_declaration(id, config, parent)
|
|
668
|
+
parent_id = nil
|
|
669
|
+
unless parent.nil?
|
|
670
|
+
parent_id = config_id_for(parent)
|
|
671
|
+
if parent_id.nil?
|
|
672
|
+
raise ArgumentError,
|
|
673
|
+
"bind(): parent must be an object previously returned from client.config.bind(). " \
|
|
674
|
+
"Bind the parent first."
|
|
675
|
+
end
|
|
419
676
|
end
|
|
420
|
-
unless values.key?(key)
|
|
421
|
-
return default if has_default
|
|
422
677
|
|
|
423
|
-
|
|
678
|
+
if config.is_a?(Struct)
|
|
679
|
+
class_name = config.class.name
|
|
680
|
+
config_name = class_name&.split("::")&.last
|
|
681
|
+
else
|
|
682
|
+
# Hash bind: no class to introspect for name/description.
|
|
683
|
+
config_name = nil
|
|
424
684
|
end
|
|
425
|
-
values[key]
|
|
426
|
-
end
|
|
427
685
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
686
|
+
observe_config_declaration(id, parent: parent_id, name: config_name, description: nil)
|
|
687
|
+
|
|
688
|
+
Discovery.iter_items(config).each do |item_key, item_type, value, description|
|
|
689
|
+
observe_item_declaration(id, item_key, item_type, value, description)
|
|
431
690
|
end
|
|
691
|
+
parent_id
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Return the config_id this target was bound under, or nil.
|
|
695
|
+
def config_id_for(target)
|
|
696
|
+
@bindings.each { |cid, bound| return cid if bound.equal?(target) }
|
|
697
|
+
nil
|
|
432
698
|
end
|
|
433
699
|
|
|
700
|
+
# Apply current cached values to a freshly-bound target.
|
|
701
|
+
#
|
|
702
|
+
# Handles the existing-config case: on restart, server-side values
|
|
703
|
+
# override the in-code defaults from the constructor (or Hash).
|
|
434
704
|
def sync_target_from_cache(target, config_id)
|
|
435
705
|
cache = @lock.synchronize { (@config_cache[config_id] || {}).dup }
|
|
436
706
|
cache.each do |dotted_key, value|
|
|
@@ -438,9 +708,100 @@ module Smplkit
|
|
|
438
708
|
end
|
|
439
709
|
end
|
|
440
710
|
|
|
711
|
+
# Seed the resolved cache for a freshly-bound config, or sync from it.
|
|
712
|
+
#
|
|
713
|
+
# If +config_id+ is already in the resolved cache it existed server-side
|
|
714
|
+
# (fetched on connect), so server values are authoritative — sync them
|
|
715
|
+
# onto the bound object. Otherwise the config is brand-new: seed
|
|
716
|
+
# +config_cache[config_id]+ in-memory by resolving this object's values
|
|
717
|
+
# through its bound parent chain, so +#subscribe+ / +#get_value+ work
|
|
718
|
+
# immediately with no flush or refresh. Pure in-memory — no network.
|
|
719
|
+
def seed_or_sync_binding(config_id, target)
|
|
720
|
+
already_present = @lock.synchronize { @config_cache.key?(config_id) }
|
|
721
|
+
if already_present
|
|
722
|
+
sync_target_from_cache(target, config_id)
|
|
723
|
+
return
|
|
724
|
+
end
|
|
725
|
+
seeded = resolve_bound_chain(config_id)
|
|
726
|
+
@lock.synchronize { @config_cache[config_id] = seeded }
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# Resolve a bound config's values through its bound parent chain.
|
|
730
|
+
#
|
|
731
|
+
# Walks +bound_parents+ from the child up through already-bound ancestors,
|
|
732
|
+
# flattening each bound object's in-code values, then runs the same
|
|
733
|
+
# deep-merge resolve used everywhere else (child wins over parent).
|
|
734
|
+
# Ancestors that aren't bound objects stop the walk.
|
|
735
|
+
def resolve_bound_chain(config_id)
|
|
736
|
+
chain = []
|
|
737
|
+
current = config_id
|
|
738
|
+
seen = {}
|
|
739
|
+
while !current.nil? && @bindings.key?(current) && !seen.key?(current)
|
|
740
|
+
seen[current] = true
|
|
741
|
+
items = bound_items_to_flat(@bindings[current])
|
|
742
|
+
chain << { "items" => items, "environments" => {} }
|
|
743
|
+
current = @bound_parents[current]
|
|
744
|
+
end
|
|
745
|
+
Helpers.resolve_chain(chain, @environment)
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# Flatten a bound Struct or Hash to +{dotted_key => value}+. Mirrors the
|
|
749
|
+
# discovery-declaration walk. Used to seed the local resolved cache from
|
|
750
|
+
# in-memory bindings without any network round-trip.
|
|
751
|
+
def bound_items_to_flat(target)
|
|
752
|
+
Discovery.iter_items(target).each_with_object({}) do |(item_key, _type, value, _desc), out|
|
|
753
|
+
out[item_key] = value
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def cached_proxy(config_id)
|
|
758
|
+
@lock.synchronize { @proxies[config_id] ||= LiveConfigProxy.new(self, config_id) }
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# Queue a config declaration with the owned discovery buffer.
|
|
762
|
+
def observe_config_declaration(config_id, parent:, name:, description:)
|
|
763
|
+
register_config(config_id, service: @service, environment: @environment,
|
|
764
|
+
parent: parent, name: name, description: description)
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Queue a config item declaration with the owned discovery buffer.
|
|
768
|
+
def observe_item_declaration(config_id, item_key, item_type, default, description)
|
|
769
|
+
register_config_item(config_id, item_key, item_type, default, description)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def trigger_background_flush_if_needed
|
|
773
|
+
return unless @buffer.pending_count >= CONFIG_BATCH_FLUSH_SIZE
|
|
774
|
+
|
|
775
|
+
Thread.new { threshold_flush }
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def threshold_flush
|
|
779
|
+
flush
|
|
780
|
+
rescue StandardError => e
|
|
781
|
+
Smplkit.debug("registration", "threshold config flush failed: #{e.class}: #{e.message}")
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# ----------------------------------------------------------------
|
|
785
|
+
# Live surface: refresh / change listeners
|
|
786
|
+
# ----------------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
# Re-apply in-memory seeds for bound configs not yet present server-side.
|
|
789
|
+
#
|
|
790
|
+
# A freshly-bound config lives only as a seed until it is flushed and
|
|
791
|
+
# fetched; without this, any cache rebuild (a manual refresh, or a
|
|
792
|
+
# WebSocket event for another config) would drop it. Server-present
|
|
793
|
+
# configs are already in +new_cache+ and are authoritative — only bound
|
|
794
|
+
# ids missing from it are re-seeded.
|
|
795
|
+
def merge_pending_seeds(new_cache)
|
|
796
|
+
@bindings.each_key do |bound_id|
|
|
797
|
+
new_cache[bound_id] = resolve_bound_chain(bound_id) unless new_cache.key?(bound_id)
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
|
|
441
801
|
def do_refresh(source)
|
|
442
|
-
configs =
|
|
802
|
+
configs = fetch_all_configs
|
|
443
803
|
new_cache, new_store = resolve_all(configs)
|
|
804
|
+
merge_pending_seeds(new_cache)
|
|
444
805
|
old_cache = nil
|
|
445
806
|
@lock.synchronize do
|
|
446
807
|
old_cache = @config_cache
|
|
@@ -456,8 +817,8 @@ module Smplkit
|
|
|
456
817
|
new_store = {}
|
|
457
818
|
configs.each do |cfg|
|
|
458
819
|
chain = Helpers.build_chain(cfg, by_id)
|
|
459
|
-
new_cache[cfg.
|
|
460
|
-
new_store[cfg.
|
|
820
|
+
new_cache[cfg.id] = Helpers.resolve_chain(chain, @environment)
|
|
821
|
+
new_store[cfg.id] = cfg
|
|
461
822
|
end
|
|
462
823
|
[new_cache, new_store]
|
|
463
824
|
end
|
|
@@ -479,6 +840,8 @@ module Smplkit
|
|
|
479
840
|
new_val = new_items[i_key]
|
|
480
841
|
next if old_val == new_val
|
|
481
842
|
|
|
843
|
+
# Apply to bound target first so listeners reading the object see the
|
|
844
|
+
# new value.
|
|
482
845
|
Discovery.apply_change_to_target(target, i_key, new_val) unless target.nil?
|
|
483
846
|
@metrics&.record("config.changes", unit: "changes", dimensions: { "config" => cfg_id })
|
|
484
847
|
event = ConfigChangeEvent.new(
|
|
@@ -502,38 +865,53 @@ module Smplkit
|
|
|
502
865
|
end
|
|
503
866
|
end
|
|
504
867
|
|
|
868
|
+
# ----------------------------------------------------------------
|
|
869
|
+
# Internal: event handlers (called by SharedWebSocket)
|
|
870
|
+
# ----------------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
# Re-resolve every config in +store+ and fire change listeners.
|
|
873
|
+
#
|
|
874
|
+
# Inheritance means a single config change can shift descendants' resolved
|
|
875
|
+
# values too — so whenever the raw store is mutated (config added,
|
|
876
|
+
# updated, or deleted), every config gets re-resolved against the new
|
|
877
|
+
# snapshot.
|
|
878
|
+
def rebuild_from_store(store, source:)
|
|
879
|
+
configs = store.values
|
|
880
|
+
new_cache, new_store = resolve_all(configs)
|
|
881
|
+
merge_pending_seeds(new_cache)
|
|
882
|
+
old_cache = nil
|
|
883
|
+
@lock.synchronize do
|
|
884
|
+
old_cache = @config_cache
|
|
885
|
+
@config_cache = new_cache
|
|
886
|
+
@raw_config_store = new_store
|
|
887
|
+
end
|
|
888
|
+
fire_change_listeners(old_cache, new_cache, source: source)
|
|
889
|
+
end
|
|
890
|
+
|
|
505
891
|
def handle_config_changed(data)
|
|
506
|
-
key = data["
|
|
507
|
-
return unless key
|
|
892
|
+
key = data["id"] || data["key"]
|
|
893
|
+
return handle_configs_changed(data) unless key
|
|
508
894
|
|
|
895
|
+
new_store = @lock.synchronize { @raw_config_store.dup }
|
|
509
896
|
begin
|
|
510
|
-
cfg =
|
|
511
|
-
rescue Smplkit::NotFoundError
|
|
512
|
-
# Treat as a deletion — the resource is gone.
|
|
513
|
-
handle_config_deleted(data)
|
|
514
|
-
return
|
|
897
|
+
cfg = fetch_config(key)
|
|
515
898
|
rescue StandardError => e
|
|
516
|
-
Smplkit.debug("config", "failed to fetch config #{key.inspect}: #{e.class}: #{e.message}")
|
|
899
|
+
Smplkit.debug("config", "failed to fetch config #{key.inspect} after WS event: #{e.class}: #{e.message}")
|
|
517
900
|
return
|
|
518
901
|
end
|
|
902
|
+
return if cfg.nil?
|
|
519
903
|
|
|
520
|
-
new_store =
|
|
521
|
-
@lock.synchronize do
|
|
522
|
-
new_store = @raw_config_store.dup
|
|
523
|
-
new_store[key] = cfg
|
|
524
|
-
end
|
|
904
|
+
new_store[key] = cfg
|
|
525
905
|
rebuild_from_store(new_store, source: "websocket")
|
|
526
906
|
end
|
|
527
907
|
|
|
528
908
|
def handle_config_deleted(data)
|
|
529
|
-
key = data["
|
|
530
|
-
return unless key
|
|
909
|
+
key = data["id"] || data["key"]
|
|
910
|
+
return handle_configs_changed(data) unless key
|
|
911
|
+
|
|
912
|
+
new_store = @lock.synchronize { @raw_config_store.dup }
|
|
913
|
+
return if new_store.delete(key).nil?
|
|
531
914
|
|
|
532
|
-
new_store = nil
|
|
533
|
-
@lock.synchronize do
|
|
534
|
-
new_store = @raw_config_store.dup
|
|
535
|
-
return unless new_store.delete(key)
|
|
536
|
-
end
|
|
537
915
|
rebuild_from_store(new_store, source: "websocket")
|
|
538
916
|
end
|
|
539
917
|
|
|
@@ -543,17 +921,74 @@ module Smplkit
|
|
|
543
921
|
Smplkit.debug("config", "configs_changed refresh failed: #{e.class}: #{e.message}")
|
|
544
922
|
end
|
|
545
923
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
924
|
+
# ----------------------------------------------------------------
|
|
925
|
+
# Internal: request-body construction
|
|
926
|
+
# ----------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
def config_body(config)
|
|
929
|
+
SmplkitGeneratedClient::Config::ConfigResponse.new(
|
|
930
|
+
data: SmplkitGeneratedClient::Config::ConfigResource.new(
|
|
931
|
+
type: "config",
|
|
932
|
+
id: config.key,
|
|
933
|
+
attributes: SmplkitGeneratedClient::Config::Config.new(
|
|
934
|
+
name: config.name,
|
|
935
|
+
description: config.description,
|
|
936
|
+
parent: config.parent_id,
|
|
937
|
+
items: config_items_to_wire(config.items),
|
|
938
|
+
environments: config_envs_to_wire(config.environments)
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
)
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def config_items_to_wire(items)
|
|
945
|
+
return nil if items.nil? || items.empty?
|
|
946
|
+
|
|
947
|
+
items.to_h do |item|
|
|
948
|
+
[item.name, SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
|
|
949
|
+
value: item.value, type: item.type, description: item.description
|
|
950
|
+
)]
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def config_envs_to_wire(environments)
|
|
955
|
+
return nil if environments.empty?
|
|
956
|
+
|
|
957
|
+
# Per ADR-024 §2.4 the wire shape for env overrides is a flat
|
|
958
|
+
# +{env: {key: rawValue}}+ map — no envelope, no per-key type wrapper.
|
|
959
|
+
environments.each_with_object({}) do |(env_key, env_obj), out|
|
|
960
|
+
out[env_key] = env_obj.values
|
|
961
|
+
end
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
def build_config_bulk_request(batch)
|
|
965
|
+
items = batch.map do |entry|
|
|
966
|
+
SmplkitGeneratedClient::Config::ConfigBulkItem.new(
|
|
967
|
+
id: entry["id"],
|
|
968
|
+
service: entry["service"],
|
|
969
|
+
environment: entry["environment"],
|
|
970
|
+
parent: entry["parent"],
|
|
971
|
+
name: entry["name"],
|
|
972
|
+
description: entry["description"],
|
|
973
|
+
items: bulk_items_to_wire(entry["items"])
|
|
974
|
+
)
|
|
975
|
+
end
|
|
976
|
+
SmplkitGeneratedClient::Config::ConfigBulkRequest.new(configs: items)
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def bulk_items_to_wire(items_hash)
|
|
980
|
+
return nil if items_hash.nil? || items_hash.empty?
|
|
981
|
+
|
|
982
|
+
items_hash.transform_values do |def_hash|
|
|
983
|
+
SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
|
|
984
|
+
value: def_hash["value"],
|
|
985
|
+
type: def_hash["type"],
|
|
986
|
+
description: def_hash["description"]
|
|
987
|
+
)
|
|
554
988
|
end
|
|
555
|
-
fire_change_listeners(old_cache, new_cache, source: source)
|
|
556
989
|
end
|
|
557
990
|
end
|
|
558
991
|
end
|
|
992
|
+
|
|
993
|
+
ConfigClient = Config::ConfigClient
|
|
559
994
|
end
|