smplkit 3.0.95 → 3.0.97
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 +128 -0
- data/lib/smplkit/account/models.rb +71 -0
- data/lib/smplkit/api_support.rb +91 -0
- data/lib/smplkit/audit/buffer.rb +3 -1
- data/lib/smplkit/audit/categories.rb +21 -10
- data/lib/smplkit/audit/client.rb +18 -9
- data/lib/smplkit/audit/event_types.rb +26 -10
- data/lib/smplkit/audit/events.rb +93 -17
- data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +93 -85
- data/lib/smplkit/audit/models.rb +86 -32
- data/lib/smplkit/audit/resource_types.rb +21 -9
- data/lib/smplkit/buffers.rb +250 -0
- data/lib/smplkit/client.rb +161 -70
- data/lib/smplkit/config/client.rb +874 -186
- data/lib/smplkit/config/helpers.rb +44 -6
- data/lib/smplkit/config/models.rb +114 -7
- data/lib/smplkit/config_resolution.rb +17 -9
- data/lib/smplkit/errors.rb +14 -3
- data/lib/smplkit/flags/client.rb +602 -116
- data/lib/smplkit/flags/models.rb +110 -8
- data/lib/smplkit/flags/types.rb +8 -9
- data/lib/smplkit/jobs/client.rb +306 -0
- data/lib/smplkit/jobs/models.rb +47 -18
- data/lib/smplkit/logging/client.rb +755 -191
- data/lib/smplkit/logging/helpers.rb +5 -1
- data/lib/smplkit/logging/levels.rb +3 -1
- data/lib/smplkit/logging/models.rb +163 -6
- data/lib/smplkit/logging/normalize.rb +3 -1
- data/lib/smplkit/logging/resolution.rb +4 -4
- data/lib/smplkit/logging/sources.rb +1 -1
- data/lib/smplkit/platform/client.rb +597 -0
- data/lib/smplkit/platform/models.rb +282 -0
- data/lib/smplkit/{management → platform}/types.rb +21 -4
- data/lib/smplkit/transport.rb +103 -0
- data/lib/smplkit/ws.rb +1 -1
- 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/jobs.rb +0 -226
- data/lib/smplkit/management/models.rb +0 -178
|
@@ -1,16 +1,47 @@
|
|
|
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
|
+
# * *CRUD 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.
|
|
32
|
+
#
|
|
33
|
+
# @api private
|
|
7
34
|
module Discovery
|
|
8
35
|
module_function
|
|
9
36
|
|
|
10
37
|
# Map a runtime value to a Config item type. Used both when binding a
|
|
11
|
-
# Hash/Struct target and when supplying a default to +
|
|
38
|
+
# Hash/Struct target and when supplying a default to +get_value(id, key,
|
|
12
39
|
# default)+. +true+/+false+ are checked first because Ruby's
|
|
13
40
|
# +Numeric+/+Integer+ tests would not accidentally claim them.
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
# @param value [Object] The runtime value whose item type to infer.
|
|
44
|
+
# @return [String] One of +"BOOLEAN"+, +"NUMBER"+, or +"STRING"+.
|
|
14
45
|
def value_to_item_type(value)
|
|
15
46
|
case value
|
|
16
47
|
when true, false then "BOOLEAN"
|
|
@@ -19,9 +50,15 @@ module Smplkit
|
|
|
19
50
|
end
|
|
20
51
|
end
|
|
21
52
|
|
|
22
|
-
# Walk a bound target, returning +[key, type, value, description]+
|
|
23
|
-
#
|
|
24
|
-
#
|
|
53
|
+
# Walk a bound target, returning +[key, type, value, description]+ tuples
|
|
54
|
+
# flattened to dot-notation. Nested Hashes / Structs are descended into;
|
|
55
|
+
# everything else is treated as an opaque leaf.
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
58
|
+
# @param target [Hash, Struct] The bound target to walk.
|
|
59
|
+
# @param prefix [String] Dot-notation prefix accumulated during recursion.
|
|
60
|
+
# @return [Array<Array(String, String, Object, String, nil)>] One
|
|
61
|
+
# +[key, type, value, description]+ tuple per leaf.
|
|
25
62
|
def iter_items(target, prefix: "")
|
|
26
63
|
if target.is_a?(Hash)
|
|
27
64
|
iter_hash_items(target, prefix: prefix)
|
|
@@ -32,6 +69,14 @@ module Smplkit
|
|
|
32
69
|
end
|
|
33
70
|
end
|
|
34
71
|
|
|
72
|
+
# Walk a Hash leaf-by-leaf, flattening nested Hashes / Structs to
|
|
73
|
+
# dot-notation.
|
|
74
|
+
#
|
|
75
|
+
# @api private
|
|
76
|
+
# @param hash [Hash] The Hash to walk.
|
|
77
|
+
# @param prefix [String] Dot-notation prefix accumulated during recursion.
|
|
78
|
+
# @return [Array<Array(String, String, Object, String, nil)>] One
|
|
79
|
+
# +[key, type, value, description]+ tuple per leaf.
|
|
35
80
|
def iter_hash_items(hash, prefix: "")
|
|
36
81
|
out = []
|
|
37
82
|
hash.each do |raw_key, value|
|
|
@@ -45,6 +90,14 @@ module Smplkit
|
|
|
45
90
|
out
|
|
46
91
|
end
|
|
47
92
|
|
|
93
|
+
# Walk a Struct member-by-member, flattening nested Hashes / Structs to
|
|
94
|
+
# dot-notation.
|
|
95
|
+
#
|
|
96
|
+
# @api private
|
|
97
|
+
# @param struct [Struct] The Struct to walk.
|
|
98
|
+
# @param prefix [String] Dot-notation prefix accumulated during recursion.
|
|
99
|
+
# @return [Array<Array(String, String, Object, String, nil)>] One
|
|
100
|
+
# +[key, type, value, description]+ tuple per leaf.
|
|
48
101
|
def iter_struct_items(struct, prefix: "")
|
|
49
102
|
out = []
|
|
50
103
|
struct.members.each do |member|
|
|
@@ -62,8 +115,14 @@ module Smplkit
|
|
|
62
115
|
# Apply a server-pushed value to a bound target in place. Walks the
|
|
63
116
|
# dotted key path to the leaf's parent and assigns the value via
|
|
64
117
|
# +Hash#[]=+ or +Struct#[]=+. Bails silently if any intermediate is
|
|
65
|
-
# missing or not a supported container — the server may have items
|
|
66
|
-
#
|
|
118
|
+
# missing or not a supported container — the server may have items that
|
|
119
|
+
# don't line up with what the bound target declared.
|
|
120
|
+
#
|
|
121
|
+
# @api private
|
|
122
|
+
# @param target [Hash, Struct] The bound target to mutate in place.
|
|
123
|
+
# @param dotted_key [String] The dot-notation path to the leaf to set.
|
|
124
|
+
# @param value [Object] The value to assign at the leaf.
|
|
125
|
+
# @return [void]
|
|
67
126
|
def apply_change_to_target(target, dotted_key, value)
|
|
68
127
|
parts = dotted_key.split(".")
|
|
69
128
|
current = walk_to_leaf_parent(target, parts[0..-2])
|
|
@@ -77,6 +136,14 @@ module Smplkit
|
|
|
77
136
|
end
|
|
78
137
|
end
|
|
79
138
|
|
|
139
|
+
# Walk a dotted key path to the leaf's parent container.
|
|
140
|
+
#
|
|
141
|
+
# @api private
|
|
142
|
+
# @param target [Hash, Struct] The root container to walk from.
|
|
143
|
+
# @param parts [Array<String>] Path segments leading to (but excluding)
|
|
144
|
+
# the leaf.
|
|
145
|
+
# @return [Hash, Struct, nil] The leaf's parent container, or +nil+ when
|
|
146
|
+
# any intermediate is missing or not a supported container.
|
|
80
147
|
def walk_to_leaf_parent(target, parts)
|
|
81
148
|
current = target
|
|
82
149
|
parts.each do |part|
|
|
@@ -97,6 +164,14 @@ module Smplkit
|
|
|
97
164
|
current
|
|
98
165
|
end
|
|
99
166
|
|
|
167
|
+
# Assign a value to a Struct member, ignoring members the Struct does not
|
|
168
|
+
# declare.
|
|
169
|
+
#
|
|
170
|
+
# @api private
|
|
171
|
+
# @param struct [Struct] The Struct to mutate.
|
|
172
|
+
# @param name [String] The member name to assign.
|
|
173
|
+
# @param value [Object] The value to assign.
|
|
174
|
+
# @return [void]
|
|
100
175
|
def assign_struct_member(struct, name, value)
|
|
101
176
|
sym = name.to_sym
|
|
102
177
|
return unless struct.members.include?(sym)
|
|
@@ -132,16 +207,15 @@ module Smplkit
|
|
|
132
207
|
|
|
133
208
|
# A live, read-only, dict-like view of a config's resolved values.
|
|
134
209
|
#
|
|
135
|
-
# Returned by +ConfigClient#
|
|
136
|
-
#
|
|
210
|
+
# Returned by +ConfigClient#subscribe+. Always reflects the latest
|
|
211
|
+
# server-pushed state — every read sees current values.
|
|
137
212
|
#
|
|
138
|
-
# Supports +[]+, +key?+, +keys+, +values+, +each_pair+, +to_h+, +size+,
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
# that do collide.
|
|
213
|
+
# Supports +[]+, +key?+, +keys+, +values+, +each_pair+, +to_h+, +size+, and
|
|
214
|
+
# method-style attribute access for keys that don't collide with built-in
|
|
215
|
+
# method names. Use subscript (+proxy["values"]+) for keys that do collide.
|
|
142
216
|
#
|
|
143
|
-
# For typed access via a Struct schema, use +ConfigClient#bind+ —
|
|
144
|
-
#
|
|
217
|
+
# For typed access via a Struct schema, use +ConfigClient#bind+ — bound
|
|
218
|
+
# objects stay live on the same cache, with no proxy indirection.
|
|
145
219
|
class LiveConfigProxy
|
|
146
220
|
# Methods that live on the proxy itself; never resolved against the
|
|
147
221
|
# cached values dictionary.
|
|
@@ -155,27 +229,60 @@ module Smplkit
|
|
|
155
229
|
|
|
156
230
|
attr_reader :config_id
|
|
157
231
|
|
|
232
|
+
# @return [Array<String>] The current resolved item keys.
|
|
158
233
|
def keys = current_values.keys
|
|
234
|
+
|
|
235
|
+
# @return [Array<Object>] The current resolved values.
|
|
159
236
|
def values = current_values.values
|
|
237
|
+
|
|
238
|
+
# @yieldparam key [String] Each resolved item key.
|
|
239
|
+
# @yieldparam value [Object] Each resolved value.
|
|
240
|
+
# @return [Enumerator, void] An enumerator when no block is given.
|
|
160
241
|
def each_pair(&) = current_values.each_pair(&)
|
|
161
242
|
alias each each_pair
|
|
243
|
+
|
|
244
|
+
# @return [Array<Array(String, Object)>] The current resolved items as
|
|
245
|
+
# +[key, value]+ pairs.
|
|
162
246
|
def items = current_values.to_a
|
|
247
|
+
|
|
248
|
+
# @return [Hash{String => Object}] A copy of the current resolved values.
|
|
163
249
|
def to_h = current_values.dup
|
|
250
|
+
|
|
251
|
+
# @return [Integer] The number of resolved items.
|
|
164
252
|
def size = current_values.size
|
|
165
253
|
alias length size
|
|
254
|
+
|
|
255
|
+
# @param key [String, Symbol] The item key to test for.
|
|
256
|
+
# @return [Boolean] +true+ when +key+ is present in the resolved values.
|
|
166
257
|
def key?(key) = current_values.key?(key.to_s)
|
|
167
258
|
alias include? key?
|
|
168
259
|
alias has_key? key?
|
|
169
260
|
|
|
261
|
+
# @param key [String, Symbol] The item key to read.
|
|
262
|
+
# @return [Object, nil] The current resolved value for +key+, or +nil+
|
|
263
|
+
# when absent.
|
|
170
264
|
def [](key)
|
|
171
265
|
current_values[key.to_s]
|
|
172
266
|
end
|
|
173
267
|
|
|
268
|
+
# Return the current resolved value for +key+, or a fallback.
|
|
269
|
+
#
|
|
270
|
+
# @param key [String, Symbol] The config item key to read.
|
|
271
|
+
# @param default [Object] Value returned when +key+ is not present.
|
|
272
|
+
# @return [Object] The current resolved value for +key+, or +default+ if
|
|
273
|
+
# the key is absent.
|
|
174
274
|
def get(key, default = nil)
|
|
175
275
|
values = current_values
|
|
176
276
|
values.key?(key.to_s) ? values[key.to_s] : default
|
|
177
277
|
end
|
|
178
278
|
|
|
279
|
+
# Register a change listener scoped to this config.
|
|
280
|
+
#
|
|
281
|
+
# @param item_key [String, Symbol, nil] When given, fires only when this
|
|
282
|
+
# item key changes; otherwise fires on any change to this config.
|
|
283
|
+
# @yieldparam event [ConfigChangeEvent] The change that fired the
|
|
284
|
+
# listener.
|
|
285
|
+
# @return [Proc] The registered block, unchanged.
|
|
179
286
|
def on_change(item_key = nil, &)
|
|
180
287
|
if item_key.nil?
|
|
181
288
|
@client.on_change(@config_id, &)
|
|
@@ -208,130 +315,423 @@ module Smplkit
|
|
|
208
315
|
end
|
|
209
316
|
end
|
|
210
317
|
|
|
211
|
-
#
|
|
318
|
+
# Normalize a +parent+ argument to a config id string.
|
|
212
319
|
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
# +
|
|
320
|
+
# @api private
|
|
321
|
+
# @param parent [String, Config, nil] A config id, a saved +Config+ whose
|
|
322
|
+
# id is used, or +nil+.
|
|
323
|
+
# @return [String, nil] The resolved config id, or +nil+ when +parent+ is
|
|
324
|
+
# +nil+.
|
|
325
|
+
# @raise [ArgumentError] If +parent+ is an unsaved +Config+ (no id yet).
|
|
326
|
+
def self.resolve_parent_id(parent)
|
|
327
|
+
return parent if parent.nil? || parent.is_a?(String)
|
|
328
|
+
if parent.id.nil? || parent.id == ""
|
|
329
|
+
raise ArgumentError, "parent config must be saved (have an id) before being used as a parent"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
parent.id
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Build a standalone config transport and resolve the app base URL.
|
|
336
|
+
#
|
|
337
|
+
# +base_url+/+api_key+ are used directly when supplied (the path a top-level
|
|
338
|
+
# client takes after it has already resolved them); otherwise the config
|
|
339
|
+
# resolver fills in whatever is missing (+~/.smplkit+ / env vars /
|
|
340
|
+
# defaults). The app base URL is returned alongside so a standalone client
|
|
341
|
+
# can open its own WebSocket against the event gateway.
|
|
342
|
+
#
|
|
343
|
+
# @api private
|
|
344
|
+
# @param api_key [String, nil] API key, or +nil+ to resolve it.
|
|
345
|
+
# @param base_url [String, nil] Full config-service base URL, or +nil+ to
|
|
346
|
+
# resolve it from +base_domain+/+scheme+.
|
|
347
|
+
# @param profile [String, nil] Named +~/.smplkit+ profile section.
|
|
348
|
+
# @param base_domain [String, nil] Base domain for API requests.
|
|
349
|
+
# @param scheme [String, nil] URL scheme.
|
|
350
|
+
# @param debug [Boolean, nil] Enable SDK debug logging.
|
|
351
|
+
# @param extra_headers [Hash{String => String}, nil] Headers attached to
|
|
352
|
+
# every request.
|
|
353
|
+
# @return [Array(Object, String, String)] The transport, the app base URL,
|
|
354
|
+
# and the resolved API key.
|
|
355
|
+
def self.config_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
|
|
356
|
+
cfg = ConfigResolution.resolve_client_config(
|
|
357
|
+
profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
|
|
358
|
+
)
|
|
359
|
+
resolved_key = api_key.nil? ? cfg.api_key : api_key
|
|
360
|
+
merged = {}
|
|
361
|
+
merged.merge!(cfg.extra_headers || {})
|
|
362
|
+
merged.merge!(extra_headers || {})
|
|
363
|
+
tcfg = ConfigResolution::ResolvedClientConfig.new(
|
|
364
|
+
api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
|
|
365
|
+
debug: cfg.debug, extra_headers: merged
|
|
366
|
+
)
|
|
367
|
+
app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
|
|
368
|
+
transport = Transport.build_api_client(SmplkitGeneratedClient::Config, "config", tcfg, base_url: base_url)
|
|
369
|
+
[transport, app_url, resolved_key]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# The Smpl Config client (sync).
|
|
373
|
+
#
|
|
374
|
+
# One client exposes the full surface, reachable as +client.config+
|
|
375
|
+
# (+Smplkit::Client+) or constructed directly:
|
|
376
|
+
#
|
|
377
|
+
# config = Smplkit::ConfigClient.new(environment: "production")
|
|
378
|
+
# billing = config.new("billing", name: "Billing")
|
|
379
|
+
# billing.set_number("max_seats", 50)
|
|
380
|
+
# billing.save
|
|
381
|
+
# proxy = config.subscribe("billing")
|
|
382
|
+
# puts proxy["max_seats"]
|
|
383
|
+
#
|
|
384
|
+
# The CRUD surface (+new+ / +get+ / +list+ / +delete+ and discovery)
|
|
385
|
+
# is pure CRUD. The live surface (+subscribe+ / +get_value+ / +bind+ /
|
|
386
|
+
# +on_change+ / +refresh+) connects lazily on first use — the first call
|
|
387
|
+
# flushes discovery, fetches and resolves all configs into the local cache,
|
|
388
|
+
# and opens the live-updates WebSocket. No explicit install step is
|
|
389
|
+
# required.
|
|
217
390
|
class ConfigClient
|
|
218
|
-
# Sentinel
|
|
219
|
-
#
|
|
220
|
-
# only ever identity-compare with +equal?+.
|
|
391
|
+
# Sentinel distinguishing "no default supplied" from an explicit +nil+
|
|
392
|
+
# default in +#get_value+.
|
|
221
393
|
MISSING = Object.new.freeze
|
|
222
394
|
private_constant :MISSING
|
|
223
395
|
|
|
224
|
-
|
|
396
|
+
# @param api_key [String, nil] API key. When omitted, resolved from
|
|
397
|
+
# +SMPLKIT_API_KEY+ or +~/.smplkit+.
|
|
398
|
+
# @param environment [String, nil] Deployment environment used to resolve
|
|
399
|
+
# runtime config values and to scope discovery declarations.
|
|
400
|
+
# @param base_url [String, nil] Full config-service base URL. Usually
|
|
401
|
+
# resolved from +base_domain+/+scheme+; supplied directly by the
|
|
402
|
+
# top-level clients which have already computed it.
|
|
403
|
+
# @param profile [String, nil] Named +~/.smplkit+ profile section.
|
|
404
|
+
# @param base_domain [String, nil] Base domain for API requests (default
|
|
405
|
+
# +"smplkit.com"+).
|
|
406
|
+
# @param scheme [String, nil] URL scheme (default +"https"+).
|
|
407
|
+
# @param debug [Boolean, nil] Enable SDK debug logging.
|
|
408
|
+
# @param extra_headers [Hash{String => String}, nil] Extra headers
|
|
409
|
+
# attached to every request.
|
|
410
|
+
# @param parent [Smplkit::Client, nil] Internal — the owning client. Not
|
|
411
|
+
# for direct use.
|
|
412
|
+
# @param transport [Object, nil] Internal — a pre-built config transport
|
|
413
|
+
# supplied by a top-level client so the config surface shares one
|
|
414
|
+
# connection pool. Not for direct use.
|
|
415
|
+
# @param metrics [Object, nil] Internal — the parent's metrics reporter.
|
|
416
|
+
def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
|
|
417
|
+
base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
|
|
418
|
+
parent: nil, transport: nil, metrics: nil)
|
|
225
419
|
@parent = parent
|
|
226
|
-
@manage = manage
|
|
227
420
|
@metrics = metrics
|
|
228
|
-
@environment = parent._environment
|
|
229
|
-
@service = parent
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
421
|
+
@environment = parent.nil? ? environment : parent._environment
|
|
422
|
+
@service = parent&._service
|
|
423
|
+
@standalone_api_key = nil
|
|
424
|
+
if transport.nil?
|
|
425
|
+
@http, @app_base_url, @standalone_api_key = Smplkit::Config.config_transport(
|
|
426
|
+
api_key: api_key, base_url: base_url, profile: profile,
|
|
427
|
+
base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
|
|
428
|
+
)
|
|
429
|
+
@owns_transport = true
|
|
430
|
+
else
|
|
431
|
+
@http = transport
|
|
432
|
+
@app_base_url = nil
|
|
433
|
+
@owns_transport = false
|
|
434
|
+
end
|
|
435
|
+
@api = SmplkitGeneratedClient::Config::ConfigsApi.new(@http)
|
|
436
|
+
|
|
437
|
+
# Discovery buffer is owned by this client (no management delegation).
|
|
438
|
+
@buffer = ConfigRegistrationBuffer.new
|
|
439
|
+
|
|
440
|
+
# Live-surface state.
|
|
441
|
+
@config_cache = {} # config_id -> { item_key => resolved_value }
|
|
442
|
+
@raw_config_store = {} # config_id -> Config
|
|
443
|
+
@proxies = {} # config_id -> LiveConfigProxy
|
|
444
|
+
@bindings = {} # config_id -> Hash | Struct (bound target)
|
|
445
|
+
# Parent config id each binding was bound under (nil for roots) —
|
|
446
|
+
# drives in-memory cache seeding through the bound parent chain.
|
|
447
|
+
@bound_parents = {}
|
|
236
448
|
@connected = false
|
|
237
449
|
@lock = Mutex.new
|
|
450
|
+
@listeners = [] # [callback, config_id_or_nil, item_key_or_nil]
|
|
238
451
|
@ws_manager = nil
|
|
452
|
+
@owns_ws = false
|
|
239
453
|
end
|
|
240
454
|
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
#
|
|
455
|
+
# ----------------------------------------------------------------
|
|
456
|
+
# Management surface: CRUD (no live connection)
|
|
457
|
+
# ----------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
# Return a new unsaved +Config+. Call +Config#save+ to persist.
|
|
246
460
|
#
|
|
247
|
-
#
|
|
248
|
-
# the
|
|
249
|
-
|
|
250
|
-
|
|
461
|
+
# +parent+ accepts either a config id (string) or an existing +Config+
|
|
462
|
+
# instance — passing the instance lets you skip naming the id explicitly
|
|
463
|
+
# when you already have the parent in scope.
|
|
464
|
+
#
|
|
465
|
+
# @param id [String] The config identifier (slug) the resource will be
|
|
466
|
+
# saved under.
|
|
467
|
+
# @param name [String, nil] Display name. Defaults to a title-cased form
|
|
468
|
+
# of +id+.
|
|
469
|
+
# @param description [String, nil] Optional human-readable description.
|
|
470
|
+
# @param parent [String, Config, nil] Optional parent config to inherit
|
|
471
|
+
# values from, as a config id or an existing +Config+ instance.
|
|
472
|
+
# @return [Config] A new, unsaved +Config+. Nothing is sent to the server
|
|
473
|
+
# until you call +Config#save+.
|
|
474
|
+
def new(id, name: nil, description: nil, parent: nil)
|
|
475
|
+
Config.new(
|
|
476
|
+
self,
|
|
477
|
+
key: id,
|
|
478
|
+
name: name || Smplkit::Helpers.key_to_display_name(id),
|
|
479
|
+
description: description,
|
|
480
|
+
parent_id: Smplkit::Config.resolve_parent_id(parent)
|
|
481
|
+
)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Fetch the editable +Config+ resource by id.
|
|
485
|
+
#
|
|
486
|
+
# @param id [String] The config identifier (slug) to fetch.
|
|
487
|
+
# @return [Config] The editable +Config+ resource.
|
|
488
|
+
# @raise [Smplkit::NotFoundError] If no config with that id exists.
|
|
489
|
+
def get(id)
|
|
490
|
+
response = ApiSupport::ErrorMapping.call { @api.get_config(id) }
|
|
491
|
+
Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# List configs for the authenticated account.
|
|
495
|
+
#
|
|
496
|
+
# @param page_number [Integer, nil] 1-based page to fetch. When omitted,
|
|
497
|
+
# the server's default first page is returned.
|
|
498
|
+
# @param page_size [Integer, nil] Number of configs per page. When
|
|
499
|
+
# omitted, the server's default page size is used.
|
|
500
|
+
# @return [Array<Config>] The configs on the requested page, or an empty
|
|
501
|
+
# array if there are none.
|
|
502
|
+
def list(page_number: nil, page_size: nil)
|
|
503
|
+
opts = {}
|
|
504
|
+
opts[:page_number] = page_number unless page_number.nil?
|
|
505
|
+
opts[:page_size] = page_size unless page_size.nil?
|
|
506
|
+
response = ApiSupport::ErrorMapping.call { @api.list_configs(opts) }
|
|
507
|
+
(response.data || []).map { |r| Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(r)) }
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Delete a config by id.
|
|
511
|
+
#
|
|
512
|
+
# @param id [String] The config identifier (slug) to delete.
|
|
513
|
+
# @return [void]
|
|
514
|
+
def delete(id)
|
|
515
|
+
ApiSupport::ErrorMapping.call { @api.delete_config(id) }
|
|
516
|
+
nil
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def _create_config(config)
|
|
520
|
+
response = ApiSupport::ErrorMapping.call { @api.create_config(config_body(config)) }
|
|
521
|
+
Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def _update_config(config)
|
|
525
|
+
response = ApiSupport::ErrorMapping.call { @api.update_config(config.key, config_body(config)) }
|
|
526
|
+
Helpers.config_from_json(self, ApiSupport::ResourceShim.from_model(response.data))
|
|
527
|
+
end
|
|
251
528
|
|
|
252
|
-
|
|
529
|
+
# ----------------------------------------------------------------
|
|
530
|
+
# Management surface: discovery buffer (owned directly)
|
|
531
|
+
# ----------------------------------------------------------------
|
|
253
532
|
|
|
254
|
-
|
|
255
|
-
|
|
533
|
+
# Queue a configuration declaration for bulk-discovery upload.
|
|
534
|
+
#
|
|
535
|
+
# The declaration is buffered and sent in the background; it surfaces the
|
|
536
|
+
# config in the smplkit console even if no values are set yet.
|
|
537
|
+
#
|
|
538
|
+
# @param config_id [String] The config identifier (slug) being declared.
|
|
539
|
+
# @param service [String, nil] Name of the service declaring the config,
|
|
540
|
+
# or +nil+.
|
|
541
|
+
# @param environment [String, nil] Environment the declaration is scoped
|
|
542
|
+
# to, or +nil+.
|
|
543
|
+
# @param parent [String, nil] Optional parent config id this config
|
|
544
|
+
# inherits from.
|
|
545
|
+
# @param name [String, nil] Optional display name for the config.
|
|
546
|
+
# @param description [String, nil] Optional human-readable description.
|
|
547
|
+
# @return [void]
|
|
548
|
+
def register_config(config_id, service:, environment:, parent: nil, name: nil, description: nil)
|
|
549
|
+
@buffer.declare(config_id, service: service, environment: environment,
|
|
550
|
+
parent: parent, name: name, description: description)
|
|
551
|
+
trigger_background_flush_if_needed
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Queue a config item declaration. +register_config+ must run first.
|
|
555
|
+
#
|
|
556
|
+
# The declaration is buffered and sent in the background, surfacing the
|
|
557
|
+
# item (with its type and default) in the smplkit console.
|
|
558
|
+
#
|
|
559
|
+
# @param config_id [String] The config identifier (slug) the item belongs
|
|
560
|
+
# to.
|
|
561
|
+
# @param item_key [String] Key of the item within the config.
|
|
562
|
+
# @param item_type [String] Item value type — one of +"STRING"+,
|
|
563
|
+
# +"NUMBER"+, +"BOOLEAN"+, or +"JSON"+.
|
|
564
|
+
# @param default [Object] The in-code default value for the item.
|
|
565
|
+
# @param description [String, nil] Optional human-readable description.
|
|
566
|
+
# @return [void]
|
|
567
|
+
def register_config_item(config_id, item_key, item_type, default, description = nil)
|
|
568
|
+
@buffer.add_item(config_id, item_key, item_type, default, description)
|
|
569
|
+
trigger_background_flush_if_needed
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Send any queued config and item declarations to the server.
|
|
573
|
+
#
|
|
574
|
+
# Discovery is best-effort — failures here never propagate to your code.
|
|
575
|
+
# Drained entries are not requeued; the SDK re-observes them on the next
|
|
576
|
+
# process start.
|
|
577
|
+
#
|
|
578
|
+
# @return [void]
|
|
579
|
+
def flush
|
|
580
|
+
batch = @buffer.drain
|
|
581
|
+
return if batch.empty?
|
|
582
|
+
|
|
583
|
+
body = build_config_bulk_request(batch)
|
|
256
584
|
begin
|
|
257
|
-
@
|
|
585
|
+
ApiSupport::ErrorMapping.call { @api.bulk_register_configs(body) }
|
|
258
586
|
rescue StandardError => e
|
|
259
|
-
|
|
587
|
+
# Fire-and-forget — discovery failures never propagate to caller code.
|
|
588
|
+
Smplkit.debug("registration", "config bulk register failed: #{e.class}: #{e.message}")
|
|
260
589
|
end
|
|
590
|
+
end
|
|
261
591
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
@
|
|
267
|
-
@ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
|
|
268
|
-
@ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
|
|
592
|
+
# Number of pending config declarations awaiting flush.
|
|
593
|
+
#
|
|
594
|
+
# @return [Integer] The count of buffered declarations not yet flushed.
|
|
595
|
+
def pending_count
|
|
596
|
+
@buffer.pending_count
|
|
269
597
|
end
|
|
270
598
|
|
|
599
|
+
# ----------------------------------------------------------------
|
|
600
|
+
# Live surface: bind, subscribe, get_value
|
|
601
|
+
# ----------------------------------------------------------------
|
|
602
|
+
|
|
271
603
|
# Bind a Hash or Struct to a config id; return the same object back, live.
|
|
272
604
|
#
|
|
273
605
|
# Declarative, code-first API. Two flavors:
|
|
274
606
|
#
|
|
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
|
-
#
|
|
282
|
-
#
|
|
283
|
-
# On first
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
607
|
+
# * +Hash+: keys present are leaves to register, with their values as the
|
|
608
|
+
# in-code defaults. Nested Hashes flatten to dot-notation. Keys the
|
|
609
|
+
# caller wants to inherit from +parent:+ are simply omitted.
|
|
610
|
+
# * +Struct+: every member is registered as an explicit override. Ruby
|
|
611
|
+
# Structs do not track which members were "explicitly set" vs defaulted,
|
|
612
|
+
# so there is no Hash-style omit-to-inherit. For omit-to-inherit, use a
|
|
613
|
+
# Hash target.
|
|
614
|
+
#
|
|
615
|
+
# On first boot the schema and values are registered with the server. The
|
|
616
|
+
# local cache is then seeded so reads work immediately: if the config
|
|
617
|
+
# already exists server-side (fetched on connect) its values are
|
|
618
|
+
# authoritative and synced onto the bound object; if it is brand-new, the
|
|
619
|
+
# cache entry is seeded in-memory from the bound object's values resolved
|
|
620
|
+
# through its bound parent chain (no network round-trip). On every
|
|
621
|
+
# WebSocket-delivered change thereafter the bound object is mutated in
|
|
622
|
+
# place. Readers always see the current resolved value with no proxy
|
|
623
|
+
# indirection.
|
|
624
|
+
#
|
|
625
|
+
# Idempotent. Repeated calls with the same +id+ return the
|
|
290
626
|
# originally-bound object; the new +config+ argument is ignored.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
627
|
+
#
|
|
628
|
+
# Connects lazily on first use — no explicit install step.
|
|
629
|
+
#
|
|
630
|
+
# @param id [String] The config id to register under.
|
|
631
|
+
# @param config [Hash, Struct] A populated Hash or Struct. Both supply
|
|
632
|
+
# the schema (via the keys or Struct members) and the in-code defaults.
|
|
633
|
+
# @param parent [Hash, Struct, nil] Optional parent — any object
|
|
634
|
+
# previously returned from a +#bind+ call. Activates parent-chain
|
|
635
|
+
# inheritance for keys the caller omitted.
|
|
636
|
+
# @return [Hash, Struct] The same +config+ object, registered and live.
|
|
637
|
+
# @raise [TypeError] If +config+ is neither a Hash nor a Struct.
|
|
638
|
+
# @raise [ArgumentError] If +parent+ is provided but was not previously
|
|
639
|
+
# bound via +#bind+.
|
|
640
|
+
def bind(id, config, parent: nil)
|
|
641
|
+
ensure_connected
|
|
642
|
+
unless config.is_a?(Hash) || config.is_a?(Struct)
|
|
643
|
+
raise TypeError, "bind() requires a Hash or Struct; got #{config.class.name}"
|
|
294
644
|
end
|
|
295
645
|
|
|
296
646
|
return @bindings[id] if @bindings.key?(id)
|
|
297
647
|
|
|
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)
|
|
648
|
+
parent_id = register_binding_declaration(id, config, parent)
|
|
308
649
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
650
|
+
# Register the binding BEFORE syncing so WebSocket dispatch finds it.
|
|
651
|
+
@bindings[id] = config
|
|
652
|
+
@bound_parents[id] = parent_id
|
|
653
|
+
seed_or_sync_binding(id, config)
|
|
654
|
+
config
|
|
655
|
+
end
|
|
312
656
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
657
|
+
# Return a live, dict-like +LiveConfigProxy+ for a config id.
|
|
658
|
+
#
|
|
659
|
+
# The proxy always reflects the latest resolved values; reads happen
|
|
660
|
+
# through it (+proxy["key"]+, +proxy.get("key", default)+). Subscribing
|
|
661
|
+
# registers the config declaration for code-first observability so the
|
|
662
|
+
# reference appears in the smplkit console.
|
|
663
|
+
#
|
|
664
|
+
# Connects lazily on first use — no explicit install step.
|
|
665
|
+
#
|
|
666
|
+
# @param id [String] The config identifier (slug) to subscribe to.
|
|
667
|
+
# @return [LiveConfigProxy] A live proxy whose reads always see the
|
|
668
|
+
# current resolved values.
|
|
669
|
+
# @raise [Smplkit::NotFoundError] If the config is unknown.
|
|
670
|
+
def subscribe(id)
|
|
671
|
+
ensure_connected
|
|
672
|
+
observe_config_declaration(id, parent: nil, name: nil, description: nil)
|
|
673
|
+
in_cache = @lock.synchronize { @config_cache.key?(id) }
|
|
674
|
+
raise Smplkit::NotFoundError, "Config with id '#{id}' not found" unless in_cache
|
|
316
675
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
target
|
|
676
|
+
@metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
|
|
677
|
+
cached_proxy(id)
|
|
320
678
|
end
|
|
321
679
|
|
|
322
|
-
# Read a
|
|
680
|
+
# Read a single resolved config value (inheritance-aware).
|
|
681
|
+
#
|
|
682
|
+
# The value comes from the locally-cached resolved chain, so parent
|
|
683
|
+
# configs are already folded in.
|
|
684
|
+
#
|
|
685
|
+
# Two forms:
|
|
323
686
|
#
|
|
324
|
-
#
|
|
687
|
+
# * +get_value(id, key)+ returns the resolved value. Raises +NotFoundError+
|
|
688
|
+
# if the config is unknown and +KeyError+ if the key is absent.
|
|
689
|
+
# * +get_value(id, key, default)+ returns the resolved value, falling back
|
|
690
|
+
# to +default+ if the config or key is missing. Never raises.
|
|
691
|
+
# *Registers* the config (if new) and the key (inferred type, +default+
|
|
692
|
+
# as default) for code-first observability, so the reference appears in
|
|
693
|
+
# the smplkit console.
|
|
325
694
|
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
|
|
330
|
-
|
|
695
|
+
# For a live dict-like view use +#subscribe+; for typed access via a
|
|
696
|
+
# Struct schema use +#bind+. Connects lazily on first use — no explicit
|
|
697
|
+
# install step.
|
|
698
|
+
#
|
|
699
|
+
# @param id [String] The config identifier (slug) to read from.
|
|
700
|
+
# @param key [String] The item key within the config.
|
|
701
|
+
# @param default [Object] Value returned when the config or key is
|
|
702
|
+
# missing. When omitted, a missing config or key raises instead of
|
|
703
|
+
# returning a fallback. Supplying a default also registers the config
|
|
704
|
+
# (if new) and the key — with its type inferred and +default+ as its
|
|
705
|
+
# value — so the reference appears in the smplkit console.
|
|
706
|
+
# @return [Object] The resolved value. When +default+ is supplied and the
|
|
707
|
+
# config or key is missing, returns +default+ instead.
|
|
708
|
+
# @raise [Smplkit::NotFoundError] If the config is unknown and no
|
|
709
|
+
# +default+ was supplied.
|
|
710
|
+
# @raise [KeyError] If the key is absent and no +default+ was supplied.
|
|
711
|
+
def get_value(id, key, default = MISSING)
|
|
712
|
+
ensure_connected
|
|
713
|
+
key = key.to_s
|
|
714
|
+
has_default = !default.equal?(MISSING)
|
|
715
|
+
if has_default
|
|
716
|
+
# Register the config + key so the reference shows up in the console
|
|
717
|
+
# even if it's never been declared via bind(). The buffer is
|
|
718
|
+
# idempotent at the (config_id, item_key) level.
|
|
719
|
+
observe_config_declaration(id, parent: nil, name: nil, description: nil)
|
|
720
|
+
observe_item_declaration(id, key, Discovery.value_to_item_type(default), default, nil)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
values = @lock.synchronize { @config_cache[id]&.dup }
|
|
724
|
+
if values.nil?
|
|
725
|
+
return default if has_default
|
|
331
726
|
|
|
332
|
-
|
|
727
|
+
raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
|
|
728
|
+
end
|
|
729
|
+
unless values.key?(key)
|
|
730
|
+
return default if has_default
|
|
333
731
|
|
|
334
|
-
|
|
732
|
+
raise KeyError, "Config item '#{key}' not found in config '#{id}'"
|
|
733
|
+
end
|
|
734
|
+
values[key]
|
|
335
735
|
end
|
|
336
736
|
|
|
337
737
|
# Register a change listener.
|
|
@@ -341,7 +741,18 @@ module Smplkit
|
|
|
341
741
|
# client.config.on_change { |event| ... } # global
|
|
342
742
|
# client.config.on_change("id") { |event| ... } # config-scoped
|
|
343
743
|
# client.config.on_change("id", item_key: "key") { |event| ... } # item-scoped
|
|
744
|
+
#
|
|
745
|
+
# Connects lazily on first use — no explicit install step.
|
|
746
|
+
#
|
|
747
|
+
# @param config_id [String, nil] When given, restrict the listener to
|
|
748
|
+
# changes of this config. Omit for a global listener.
|
|
749
|
+
# @param item_key [String, nil] When +config_id+ is given, restrict the
|
|
750
|
+
# listener to changes of this single item key.
|
|
751
|
+
# @yieldparam event [ConfigChangeEvent] The change that fired the
|
|
752
|
+
# listener.
|
|
753
|
+
# @return [Proc] The registered block, unchanged.
|
|
344
754
|
def on_change(config_id = nil, item_key: nil, &block)
|
|
755
|
+
ensure_connected
|
|
345
756
|
raise ArgumentError, "on_change requires a block" unless block
|
|
346
757
|
|
|
347
758
|
@listeners << [block, config_id, item_key&.to_s]
|
|
@@ -350,87 +761,166 @@ module Smplkit
|
|
|
350
761
|
|
|
351
762
|
# Re-fetch all configs and update resolved values, firing change
|
|
352
763
|
# listeners for anything that differs from the previous state.
|
|
764
|
+
#
|
|
765
|
+
# Connects lazily on first use — no explicit install step.
|
|
766
|
+
#
|
|
767
|
+
# @return [void]
|
|
768
|
+
# @raise [Smplkit::ConnectionError] If the fetch fails.
|
|
353
769
|
def refresh
|
|
354
|
-
|
|
770
|
+
ensure_connected
|
|
355
771
|
do_refresh("manual")
|
|
356
772
|
end
|
|
357
773
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
774
|
+
# Release resources — only those this client owns.
|
|
775
|
+
#
|
|
776
|
+
# Tears down the owned WebSocket (opened by a standalone client on first
|
|
777
|
+
# live use) and the owned HTTP transport (standalone construction). A
|
|
778
|
+
# wired client borrows the parent's transport and WebSocket and closes
|
|
779
|
+
# neither.
|
|
780
|
+
#
|
|
781
|
+
# @return [void]
|
|
782
|
+
def close
|
|
783
|
+
if @owns_ws && @ws_manager
|
|
784
|
+
@ws_manager.stop
|
|
785
|
+
@ws_manager = nil
|
|
786
|
+
@owns_ws = false
|
|
787
|
+
end
|
|
788
|
+
nil
|
|
361
789
|
end
|
|
790
|
+
alias _close close
|
|
362
791
|
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
792
|
+
# Construct, yield to the block, and close on exit.
|
|
793
|
+
#
|
|
794
|
+
# @param kwargs [Hash] Keyword arguments forwarded to +#initialize+.
|
|
795
|
+
# @yieldparam client [ConfigClient] The constructed client, closed when
|
|
796
|
+
# the block returns.
|
|
797
|
+
# @return [Object] The value returned by the block.
|
|
798
|
+
def self.open(**kwargs)
|
|
799
|
+
client = new(**kwargs)
|
|
800
|
+
begin
|
|
801
|
+
yield client
|
|
802
|
+
ensure
|
|
803
|
+
client.close
|
|
368
804
|
end
|
|
369
805
|
end
|
|
370
806
|
|
|
371
|
-
# Internal:
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
service: @service,
|
|
376
|
-
environment: @environment,
|
|
377
|
-
parent: parent,
|
|
378
|
-
name: name,
|
|
379
|
-
description: description
|
|
380
|
-
)
|
|
807
|
+
# Internal: return (a copy of) the resolved values for a config id.
|
|
808
|
+
# Used by +LiveConfigProxy+.
|
|
809
|
+
def _cached_values(config_id)
|
|
810
|
+
@lock.synchronize { (@config_cache[config_id] || {}).dup }
|
|
381
811
|
end
|
|
382
812
|
|
|
383
|
-
# Internal:
|
|
384
|
-
def
|
|
385
|
-
|
|
813
|
+
# Internal: trigger lazy connect. Used by +Client#wait_until_ready+.
|
|
814
|
+
def _ensure_connected
|
|
815
|
+
ensure_connected
|
|
386
816
|
end
|
|
387
817
|
|
|
388
818
|
private
|
|
389
819
|
|
|
390
|
-
|
|
391
|
-
|
|
820
|
+
# ----------------------------------------------------------------
|
|
821
|
+
# Live surface: lazy connect + transport / WebSocket helpers
|
|
822
|
+
# ----------------------------------------------------------------
|
|
392
823
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
"bind(): parent must be an object previously returned from client.config.bind(). " \
|
|
396
|
-
"Bind the parent first."
|
|
397
|
-
end
|
|
824
|
+
def ensure_ws
|
|
825
|
+
return @parent._ensure_ws unless @parent.nil?
|
|
398
826
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
827
|
+
if @ws_manager.nil?
|
|
828
|
+
@ws_manager = SharedWebSocket.new(
|
|
829
|
+
app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
|
|
830
|
+
)
|
|
831
|
+
@ws_manager.start
|
|
832
|
+
@owns_ws = true
|
|
402
833
|
end
|
|
403
|
-
@
|
|
404
|
-
cached_proxy(id)
|
|
834
|
+
@ws_manager
|
|
405
835
|
end
|
|
406
836
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
837
|
+
# Open the live connection to the running Smpl Config service.
|
|
838
|
+
#
|
|
839
|
+
# Flushes any buffered discovery declarations, fetches and resolves every
|
|
840
|
+
# config for the configured environment into the local cache, opens the
|
|
841
|
+
# shared WebSocket, and subscribes to +config_changed+ / +config_deleted+
|
|
842
|
+
# / +configs_changed+ events.
|
|
843
|
+
#
|
|
844
|
+
# Idempotent and internal — every live method calls it on first use, so
|
|
845
|
+
# the live surface auto-connects with no explicit step.
|
|
846
|
+
def ensure_connected
|
|
847
|
+
@parent&._ensure_started
|
|
848
|
+
return if @connected
|
|
849
|
+
|
|
850
|
+
# Flush any buffered discovery declarations BEFORE the initial fetch, so
|
|
851
|
+
# newly-discovered configs appear in the cache on first read.
|
|
852
|
+
begin
|
|
853
|
+
flush
|
|
854
|
+
rescue StandardError => e
|
|
855
|
+
Smplkit.debug("config", "discovery flush before connect failed: #{e.class}: #{e.message}")
|
|
412
856
|
end
|
|
413
857
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
858
|
+
# Fetch + resolve + cache + fire change listeners (against empty
|
|
859
|
+
# old_cache, so any registered listeners see "initial" events).
|
|
860
|
+
do_refresh("initial")
|
|
861
|
+
@connected = true
|
|
417
862
|
|
|
418
|
-
|
|
863
|
+
@ws_manager = ensure_ws
|
|
864
|
+
@ws_manager.on("config_changed") { |data| handle_config_changed(data) }
|
|
865
|
+
@ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
|
|
866
|
+
@ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# List configs directly from the API for the runtime cache.
|
|
870
|
+
def fetch_all_configs
|
|
871
|
+
rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_configs(opts) }
|
|
872
|
+
rows.map { |r| Helpers.config_from_json(nil, ApiSupport::ResourceShim.from_model(r)) }
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
# Fetch a single config from the API. Returns +nil+ on missing data.
|
|
876
|
+
def fetch_config(config_id)
|
|
877
|
+
response = ApiSupport::ErrorMapping.call { @api.get_config(config_id) }
|
|
878
|
+
Helpers.config_from_json(nil, ApiSupport::ResourceShim.from_model(response.data))
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# ----------------------------------------------------------------
|
|
882
|
+
# Internal: binding helpers
|
|
883
|
+
# ----------------------------------------------------------------
|
|
884
|
+
|
|
885
|
+
# Validate the parent, register the config + item declarations. Returns
|
|
886
|
+
# the resolved parent config id (or +nil+).
|
|
887
|
+
def register_binding_declaration(id, config, parent)
|
|
888
|
+
parent_id = nil
|
|
889
|
+
unless parent.nil?
|
|
890
|
+
parent_id = config_id_for(parent)
|
|
891
|
+
if parent_id.nil?
|
|
892
|
+
raise ArgumentError,
|
|
893
|
+
"bind(): parent must be an object previously returned from client.config.bind(). " \
|
|
894
|
+
"Bind the parent first."
|
|
895
|
+
end
|
|
419
896
|
end
|
|
420
|
-
unless values.key?(key)
|
|
421
|
-
return default if has_default
|
|
422
897
|
|
|
423
|
-
|
|
898
|
+
if config.is_a?(Struct)
|
|
899
|
+
class_name = config.class.name
|
|
900
|
+
config_name = class_name&.split("::")&.last
|
|
901
|
+
else
|
|
902
|
+
# Hash bind: no class to introspect for name/description.
|
|
903
|
+
config_name = nil
|
|
424
904
|
end
|
|
425
|
-
values[key]
|
|
426
|
-
end
|
|
427
905
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
906
|
+
observe_config_declaration(id, parent: parent_id, name: config_name, description: nil)
|
|
907
|
+
|
|
908
|
+
Discovery.iter_items(config).each do |item_key, item_type, value, description|
|
|
909
|
+
observe_item_declaration(id, item_key, item_type, value, description)
|
|
431
910
|
end
|
|
911
|
+
parent_id
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# Return the config_id this target was bound under, or nil.
|
|
915
|
+
def config_id_for(target)
|
|
916
|
+
@bindings.each { |cid, bound| return cid if bound.equal?(target) }
|
|
917
|
+
nil
|
|
432
918
|
end
|
|
433
919
|
|
|
920
|
+
# Apply current cached values to a freshly-bound target.
|
|
921
|
+
#
|
|
922
|
+
# Handles the existing-config case: on restart, server-side values
|
|
923
|
+
# override the in-code defaults from the constructor (or Hash).
|
|
434
924
|
def sync_target_from_cache(target, config_id)
|
|
435
925
|
cache = @lock.synchronize { (@config_cache[config_id] || {}).dup }
|
|
436
926
|
cache.each do |dotted_key, value|
|
|
@@ -438,9 +928,100 @@ module Smplkit
|
|
|
438
928
|
end
|
|
439
929
|
end
|
|
440
930
|
|
|
931
|
+
# Seed the resolved cache for a freshly-bound config, or sync from it.
|
|
932
|
+
#
|
|
933
|
+
# If +config_id+ is already in the resolved cache it existed server-side
|
|
934
|
+
# (fetched on connect), so server values are authoritative — sync them
|
|
935
|
+
# onto the bound object. Otherwise the config is brand-new: seed
|
|
936
|
+
# +config_cache[config_id]+ in-memory by resolving this object's values
|
|
937
|
+
# through its bound parent chain, so +#subscribe+ / +#get_value+ work
|
|
938
|
+
# immediately with no flush or refresh. Pure in-memory — no network.
|
|
939
|
+
def seed_or_sync_binding(config_id, target)
|
|
940
|
+
already_present = @lock.synchronize { @config_cache.key?(config_id) }
|
|
941
|
+
if already_present
|
|
942
|
+
sync_target_from_cache(target, config_id)
|
|
943
|
+
return
|
|
944
|
+
end
|
|
945
|
+
seeded = resolve_bound_chain(config_id)
|
|
946
|
+
@lock.synchronize { @config_cache[config_id] = seeded }
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
# Resolve a bound config's values through its bound parent chain.
|
|
950
|
+
#
|
|
951
|
+
# Walks +bound_parents+ from the child up through already-bound ancestors,
|
|
952
|
+
# flattening each bound object's in-code values, then runs the same
|
|
953
|
+
# deep-merge resolve used everywhere else (child wins over parent).
|
|
954
|
+
# Ancestors that aren't bound objects stop the walk.
|
|
955
|
+
def resolve_bound_chain(config_id)
|
|
956
|
+
chain = []
|
|
957
|
+
current = config_id
|
|
958
|
+
seen = {}
|
|
959
|
+
while !current.nil? && @bindings.key?(current) && !seen.key?(current)
|
|
960
|
+
seen[current] = true
|
|
961
|
+
items = bound_items_to_flat(@bindings[current])
|
|
962
|
+
chain << { "items" => items, "environments" => {} }
|
|
963
|
+
current = @bound_parents[current]
|
|
964
|
+
end
|
|
965
|
+
Helpers.resolve_chain(chain, @environment)
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
# Flatten a bound Struct or Hash to +{dotted_key => value}+. Mirrors the
|
|
969
|
+
# discovery-declaration walk. Used to seed the local resolved cache from
|
|
970
|
+
# in-memory bindings without any network round-trip.
|
|
971
|
+
def bound_items_to_flat(target)
|
|
972
|
+
Discovery.iter_items(target).each_with_object({}) do |(item_key, _type, value, _desc), out|
|
|
973
|
+
out[item_key] = value
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def cached_proxy(config_id)
|
|
978
|
+
@lock.synchronize { @proxies[config_id] ||= LiveConfigProxy.new(self, config_id) }
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
# Queue a config declaration with the owned discovery buffer.
|
|
982
|
+
def observe_config_declaration(config_id, parent:, name:, description:)
|
|
983
|
+
register_config(config_id, service: @service, environment: @environment,
|
|
984
|
+
parent: parent, name: name, description: description)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Queue a config item declaration with the owned discovery buffer.
|
|
988
|
+
def observe_item_declaration(config_id, item_key, item_type, default, description)
|
|
989
|
+
register_config_item(config_id, item_key, item_type, default, description)
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
def trigger_background_flush_if_needed
|
|
993
|
+
return unless @buffer.pending_count >= CONFIG_BATCH_FLUSH_SIZE
|
|
994
|
+
|
|
995
|
+
Thread.new { threshold_flush }
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
def threshold_flush
|
|
999
|
+
flush
|
|
1000
|
+
rescue StandardError => e
|
|
1001
|
+
Smplkit.debug("registration", "threshold config flush failed: #{e.class}: #{e.message}")
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# ----------------------------------------------------------------
|
|
1005
|
+
# Live surface: refresh / change listeners
|
|
1006
|
+
# ----------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
# Re-apply in-memory seeds for bound configs not yet present server-side.
|
|
1009
|
+
#
|
|
1010
|
+
# A freshly-bound config lives only as a seed until it is flushed and
|
|
1011
|
+
# fetched; without this, any cache rebuild (a manual refresh, or a
|
|
1012
|
+
# WebSocket event for another config) would drop it. Server-present
|
|
1013
|
+
# configs are already in +new_cache+ and are authoritative — only bound
|
|
1014
|
+
# ids missing from it are re-seeded.
|
|
1015
|
+
def merge_pending_seeds(new_cache)
|
|
1016
|
+
@bindings.each_key do |bound_id|
|
|
1017
|
+
new_cache[bound_id] = resolve_bound_chain(bound_id) unless new_cache.key?(bound_id)
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
|
|
441
1021
|
def do_refresh(source)
|
|
442
|
-
configs =
|
|
1022
|
+
configs = fetch_all_configs
|
|
443
1023
|
new_cache, new_store = resolve_all(configs)
|
|
1024
|
+
merge_pending_seeds(new_cache)
|
|
444
1025
|
old_cache = nil
|
|
445
1026
|
@lock.synchronize do
|
|
446
1027
|
old_cache = @config_cache
|
|
@@ -456,8 +1037,8 @@ module Smplkit
|
|
|
456
1037
|
new_store = {}
|
|
457
1038
|
configs.each do |cfg|
|
|
458
1039
|
chain = Helpers.build_chain(cfg, by_id)
|
|
459
|
-
new_cache[cfg.
|
|
460
|
-
new_store[cfg.
|
|
1040
|
+
new_cache[cfg.id] = Helpers.resolve_chain(chain, @environment)
|
|
1041
|
+
new_store[cfg.id] = cfg
|
|
461
1042
|
end
|
|
462
1043
|
[new_cache, new_store]
|
|
463
1044
|
end
|
|
@@ -479,6 +1060,8 @@ module Smplkit
|
|
|
479
1060
|
new_val = new_items[i_key]
|
|
480
1061
|
next if old_val == new_val
|
|
481
1062
|
|
|
1063
|
+
# Apply to bound target first so listeners reading the object see the
|
|
1064
|
+
# new value.
|
|
482
1065
|
Discovery.apply_change_to_target(target, i_key, new_val) unless target.nil?
|
|
483
1066
|
@metrics&.record("config.changes", unit: "changes", dimensions: { "config" => cfg_id })
|
|
484
1067
|
event = ConfigChangeEvent.new(
|
|
@@ -502,38 +1085,85 @@ module Smplkit
|
|
|
502
1085
|
end
|
|
503
1086
|
end
|
|
504
1087
|
|
|
1088
|
+
# ----------------------------------------------------------------
|
|
1089
|
+
# Internal: event handlers (called by SharedWebSocket)
|
|
1090
|
+
# ----------------------------------------------------------------
|
|
1091
|
+
|
|
1092
|
+
# Re-resolve every config in +store+ and fire change listeners.
|
|
1093
|
+
#
|
|
1094
|
+
# Inheritance means a single config change can shift descendants' resolved
|
|
1095
|
+
# values too — so whenever the raw store is mutated (config added,
|
|
1096
|
+
# updated, or deleted), every config gets re-resolved against the new
|
|
1097
|
+
# snapshot.
|
|
1098
|
+
def rebuild_from_store(store, source:)
|
|
1099
|
+
configs = store.values
|
|
1100
|
+
new_cache, new_store = resolve_all(configs)
|
|
1101
|
+
merge_pending_seeds(new_cache)
|
|
1102
|
+
old_cache = nil
|
|
1103
|
+
@lock.synchronize do
|
|
1104
|
+
old_cache = @config_cache
|
|
1105
|
+
@config_cache = new_cache
|
|
1106
|
+
@raw_config_store = new_store
|
|
1107
|
+
end
|
|
1108
|
+
fire_change_listeners(old_cache, new_cache, source: source)
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# Pull any referenced-but-uncached parent (and ancestors) into +store+.
|
|
1112
|
+
#
|
|
1113
|
+
# A config_changed event fetches only the changed config. If that config
|
|
1114
|
+
# inherits from a parent that isn't already in the store — e.g. a parent
|
|
1115
|
+
# created via discovery after the initial connect that never broadcast
|
|
1116
|
+
# its own event — the chain walk in +rebuild_from_store+ would stop at
|
|
1117
|
+
# the gap and the child would re-resolve missing its inherited values.
|
|
1118
|
+
# Walk every config's parent pointers and fetch each absent ancestor so
|
|
1119
|
+
# the inheritance chain resolves fully.
|
|
1120
|
+
def ensure_ancestors_cached(store)
|
|
1121
|
+
pending = store.values.filter_map { |cfg| parent_pointer(cfg) }
|
|
1122
|
+
until pending.empty?
|
|
1123
|
+
parent_id = pending.pop
|
|
1124
|
+
next if store.key?(parent_id)
|
|
1125
|
+
|
|
1126
|
+
parent = fetch_config(parent_id)
|
|
1127
|
+
next if parent.nil?
|
|
1128
|
+
|
|
1129
|
+
store[parent_id] = parent
|
|
1130
|
+
grandparent = parent_pointer(parent)
|
|
1131
|
+
pending << grandparent unless grandparent.nil?
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# The parent config id a +Config+ points at, or +nil+ for a root —
|
|
1136
|
+
# treating an empty-string parent the same as none, matching the
|
|
1137
|
+
# break condition in +Helpers.build_chain+.
|
|
1138
|
+
def parent_pointer(config)
|
|
1139
|
+
pid = config.parent_id
|
|
1140
|
+
pid unless pid.nil? || pid == ""
|
|
1141
|
+
end
|
|
1142
|
+
|
|
505
1143
|
def handle_config_changed(data)
|
|
506
|
-
key = data["
|
|
507
|
-
return unless key
|
|
1144
|
+
key = data["id"] || data["key"]
|
|
1145
|
+
return handle_configs_changed(data) unless key
|
|
508
1146
|
|
|
509
1147
|
begin
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
1148
|
+
new_store = @lock.synchronize { @raw_config_store.dup }
|
|
1149
|
+
cfg = fetch_config(key)
|
|
1150
|
+
return if cfg.nil?
|
|
519
1151
|
|
|
520
|
-
new_store = nil
|
|
521
|
-
@lock.synchronize do
|
|
522
|
-
new_store = @raw_config_store.dup
|
|
523
1152
|
new_store[key] = cfg
|
|
1153
|
+
ensure_ancestors_cached(new_store)
|
|
1154
|
+
rebuild_from_store(new_store, source: "websocket")
|
|
1155
|
+
rescue StandardError => e
|
|
1156
|
+
Smplkit.debug("config", "config_changed handler failed for #{key.inspect}: #{e.class}: #{e.message}")
|
|
524
1157
|
end
|
|
525
|
-
rebuild_from_store(new_store, source: "websocket")
|
|
526
1158
|
end
|
|
527
1159
|
|
|
528
1160
|
def handle_config_deleted(data)
|
|
529
|
-
key = data["
|
|
530
|
-
return unless key
|
|
1161
|
+
key = data["id"] || data["key"]
|
|
1162
|
+
return handle_configs_changed(data) unless key
|
|
1163
|
+
|
|
1164
|
+
new_store = @lock.synchronize { @raw_config_store.dup }
|
|
1165
|
+
return if new_store.delete(key).nil?
|
|
531
1166
|
|
|
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
1167
|
rebuild_from_store(new_store, source: "websocket")
|
|
538
1168
|
end
|
|
539
1169
|
|
|
@@ -543,17 +1173,75 @@ module Smplkit
|
|
|
543
1173
|
Smplkit.debug("config", "configs_changed refresh failed: #{e.class}: #{e.message}")
|
|
544
1174
|
end
|
|
545
1175
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1176
|
+
# ----------------------------------------------------------------
|
|
1177
|
+
# Internal: request-body construction
|
|
1178
|
+
# ----------------------------------------------------------------
|
|
1179
|
+
|
|
1180
|
+
def config_body(config)
|
|
1181
|
+
SmplkitGeneratedClient::Config::ConfigResponse.new(
|
|
1182
|
+
data: SmplkitGeneratedClient::Config::ConfigResource.new(
|
|
1183
|
+
type: "config",
|
|
1184
|
+
id: config.key,
|
|
1185
|
+
attributes: SmplkitGeneratedClient::Config::Config.new(
|
|
1186
|
+
name: config.name,
|
|
1187
|
+
description: config.description,
|
|
1188
|
+
parent: config.parent_id,
|
|
1189
|
+
items: config_items_to_wire(config.items),
|
|
1190
|
+
environments: config_envs_to_wire(config.environments)
|
|
1191
|
+
)
|
|
1192
|
+
)
|
|
1193
|
+
)
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
def config_items_to_wire(items)
|
|
1197
|
+
return nil if items.nil? || items.empty?
|
|
1198
|
+
|
|
1199
|
+
items.to_h do |item|
|
|
1200
|
+
[item.name, SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
|
|
1201
|
+
value: item.value, type: item.type, description: item.description
|
|
1202
|
+
)]
|
|
1203
|
+
end
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
def config_envs_to_wire(environments)
|
|
1207
|
+
return nil if environments.empty?
|
|
1208
|
+
|
|
1209
|
+
# The wire shape for env overrides is a flat +{env: {key: rawValue}}+
|
|
1210
|
+
# map — no envelope, no per-key type wrapper.
|
|
1211
|
+
environments.each_with_object({}) do |(env_key, env_obj), out|
|
|
1212
|
+
out[env_key] = env_obj.values
|
|
1213
|
+
end
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
def build_config_bulk_request(batch)
|
|
1217
|
+
items = batch.map do |entry|
|
|
1218
|
+
SmplkitGeneratedClient::Config::ConfigBulkItem.new(
|
|
1219
|
+
id: entry["id"],
|
|
1220
|
+
service: entry["service"],
|
|
1221
|
+
environment: entry["environment"],
|
|
1222
|
+
parent: entry["parent"],
|
|
1223
|
+
name: entry["name"],
|
|
1224
|
+
description: entry["description"],
|
|
1225
|
+
items: bulk_items_to_wire(entry["items"])
|
|
1226
|
+
)
|
|
1227
|
+
end
|
|
1228
|
+
SmplkitGeneratedClient::Config::ConfigBulkRequest.new(configs: items)
|
|
1229
|
+
end
|
|
1230
|
+
|
|
1231
|
+
def bulk_items_to_wire(items_hash)
|
|
1232
|
+
return nil if items_hash.nil? || items_hash.empty?
|
|
1233
|
+
|
|
1234
|
+
items_hash.transform_values do |def_hash|
|
|
1235
|
+
SmplkitGeneratedClient::Config::ConfigItemDefinition.new(
|
|
1236
|
+
value: def_hash["value"],
|
|
1237
|
+
type: def_hash["type"],
|
|
1238
|
+
description: def_hash["description"]
|
|
1239
|
+
)
|
|
554
1240
|
end
|
|
555
|
-
fire_change_listeners(old_cache, new_cache, source: source)
|
|
556
1241
|
end
|
|
557
1242
|
end
|
|
558
1243
|
end
|
|
1244
|
+
|
|
1245
|
+
ConfigClient = Config::ConfigClient
|
|
1246
|
+
ConfigChangeEvent = Config::ConfigChangeEvent
|
|
559
1247
|
end
|